Replace sorbet-runtime-stub with sorbet-runtime

This commit is contained in:
Bo Anderson 2022-07-16 03:32:29 +01:00
parent 30fda551e8
commit e4264c9f35
No known key found for this signature in database
GPG Key ID: 3DB94E204E137D65
93 changed files with 11362 additions and 171 deletions

3
.gitignore vendored
View File

@ -150,8 +150,7 @@
**/vendor/bundle/ruby/*/gems/simplecov-*/
**/vendor/bundle/ruby/*/gems/simplecov-html-*/
**/vendor/bundle/ruby/*/gems/sorbet-*/
**/vendor/bundle/ruby/*/gems/sorbet-runtime-*/
!**/vendor/bundle/ruby/*/gems/sorbet-runtime-stub-*/
!**/vendor/bundle/ruby/*/gems/sorbet-runtime-*/
**/vendor/bundle/ruby/*/gems/spoom-*/
**/vendor/bundle/ruby/*/gems/stackprof-*/
**/vendor/bundle/ruby/*/gems/strscan-*/

View File

@ -17,6 +17,7 @@ gem "rspec-github", require: false
gem "rspec-its", require: false
gem "rspec_junit_formatter", require: false
gem "rspec-retry", require: false
gem "rspec-sorbet", require: false
gem "rspec-wait", require: false
gem "rubocop", require: false
gem "rubocop-ast", require: false
@ -26,7 +27,6 @@ gem "warning", require: false
group :sorbet, optional: true do
gem "parlour", require: false
gem "rspec-sorbet", require: false
gem "sorbet-static-and-runtime", require: false
gem "tapioca", require: false
end
@ -44,4 +44,4 @@ gem "rubocop-rails"
gem "rubocop-rspec"
gem "rubocop-sorbet"
gem "ruby-macho"
gem "sorbet-runtime-stub"
gem "sorbet-runtime"

View File

@ -162,7 +162,6 @@ GEM
sorbet (0.5.10160)
sorbet-static (= 0.5.10160)
sorbet-runtime (0.5.10160)
sorbet-runtime-stub (0.2.0)
sorbet-static (0.5.10160-universal-darwin-14)
sorbet-static-and-runtime (0.5.10160)
sorbet (= 0.5.10160)
@ -235,7 +234,7 @@ DEPENDENCIES
ruby-macho
simplecov
simplecov-cobertura
sorbet-runtime-stub
sorbet-runtime
sorbet-static-and-runtime
tapioca
warning

View File

@ -88,7 +88,7 @@ module Homebrew
def tests
args = tests_args.parse
Homebrew.install_bundler_gems!(groups: ["sorbet"])
Homebrew.install_bundler_gems!
require "byebug" if args.byebug?

View File

@ -1,11 +1,47 @@
# typed: true
# frozen_string_literal: true
# Explicitly prevent `sorbet-runtime` from being loaded.
def gem(name, *)
raise Gem::LoadError if name == "sorbet-runtime"
require "sorbet-runtime"
super
# Disable runtime checking unless enabled.
# In the future we should consider not doing this monkey patch,
# if assured that there is no performance hit from removing this.
# There are mechanisms to achieve a middle ground (`default_checked_level`).
unless ENV["HOMEBREW_SORBET_RUNTIME"]
# Redefine T.let etc to make the `checked` parameter default to false rather than true.
# @private
module TNoChecks
def cast(value, type, checked: false)
super(value, type, checked: checked)
end
def let(value, type, checked: false)
super(value, type, checked: checked)
end
def bind(value, type, checked: false)
super(value, type, checked: checked)
end
def assert_type!(value, type, checked: false)
super(value, type, checked: checked)
end
end
# @private
module T
class << self
prepend TNoChecks
end
# Redefine T.sig to be noop.
# @private
module Sig
def sig(arg0 = nil, &blk); end
end
end
# For any cases the above doesn't handle: make sure we don't let TypeError slip through.
T::Configuration.call_validation_error_handler = ->(signature, opts) do end
T::Configuration.inline_type_error_handler = ->(error, opts) do end
end
require "sorbet-runtime-stub"

View File

@ -7,4 +7,4 @@ require_relative "standalone/load_path"
require_relative "startup/ruby_path"
require "startup/config"
require_relative "startup/bootsnap"
require_relative "startup/sorbet"
require_relative "standalone/sorbet"

View File

@ -1,10 +0,0 @@
# typed: strict
# frozen_string_literal: true
if ENV["HOMEBREW_SORBET_RUNTIME"]
# This is only supported under the brew environment.
Homebrew.install_bundler_gems!(groups: ["sorbet"])
require "sorbet-runtime"
else
require "standalone/sorbet"
end

View File

@ -98,7 +98,6 @@ $:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/simplecov-0.21.2/lib"
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/simplecov-cobertura-2.1.0/lib"
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/sorbet-static-0.5.10160-universal-darwin-15/lib"
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/sorbet-0.5.10160/lib"
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/sorbet-runtime-stub-0.2.0/lib"
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/sorbet-static-and-runtime-0.5.10160/lib"
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/thor-1.2.1/lib"
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/spoom-1.1.11/lib"

View File

@ -0,0 +1,116 @@
# frozen_string_literal: true
# typed: true
# This file is hand-crafted to encode the dependencies. They load the whole type
# system since there is such a high chance of it being used, using an autoloader
# wouldn't buy us any startup time saving.
# Namespaces without any implementation
module T; end
module T::Helpers; end
module T::Private; end
module T::Private::Abstract; end
module T::Private::Types; end
# Each section is a group that I believe need a fixed ordering. There is also
# an ordering between groups.
# These are pre-reqs for almost everything in here.
require_relative 'types/configuration'
require_relative 'types/_types'
require_relative 'types/private/decl_state'
require_relative 'types/private/class_utils'
require_relative 'types/private/runtime_levels'
require_relative 'types/private/methods/_methods'
require_relative 'types/sig'
require_relative 'types/helpers'
require_relative 'types/private/final'
require_relative 'types/private/sealed'
# The types themselves. First base classes
require_relative 'types/types/base'
require_relative 'types/types/typed_enumerable'
# Everything else
require_relative 'types/types/class_of'
require_relative 'types/types/enum'
require_relative 'types/types/fixed_array'
require_relative 'types/types/fixed_hash'
require_relative 'types/types/intersection'
require_relative 'types/types/noreturn'
require_relative 'types/types/proc'
require_relative 'types/types/attached_class'
require_relative 'types/types/self_type'
require_relative 'types/types/simple'
require_relative 'types/types/t_enum'
require_relative 'types/types/type_parameter'
require_relative 'types/types/typed_array'
require_relative 'types/types/typed_enumerator'
require_relative 'types/types/typed_enumerator_lazy'
require_relative 'types/types/typed_hash'
require_relative 'types/types/typed_range'
require_relative 'types/types/typed_set'
require_relative 'types/types/union'
require_relative 'types/types/untyped'
require_relative 'types/private/types/not_typed'
require_relative 'types/private/types/void'
require_relative 'types/private/types/string_holder'
require_relative 'types/private/types/type_alias'
require_relative 'types/types/type_variable'
require_relative 'types/types/type_member'
require_relative 'types/types/type_template'
# Call validation
require_relative 'types/private/methods/modes'
require_relative 'types/private/methods/call_validation'
# Signature validation
require_relative 'types/private/methods/signature_validation'
require_relative 'types/abstract_utils'
require_relative 'types/private/abstract/validate'
# Catch all. Sort of built by `cd extn; find types -type f | grep -v test | sort`
require_relative 'types/generic'
require_relative 'types/interface_wrapper'
require_relative 'types/private/abstract/declare'
require_relative 'types/private/abstract/hooks'
require_relative 'types/private/casts'
require_relative 'types/private/methods/decl_builder'
require_relative 'types/private/methods/signature'
require_relative 'types/private/retry'
require_relative 'types/utils'
require_relative 'types/boolean'
# Props dependencies
require_relative 'types/private/abstract/data'
require_relative 'types/private/mixins/mixins'
require_relative 'types/props/_props'
require_relative 'types/props/custom_type'
require_relative 'types/props/decorator'
require_relative 'types/props/errors'
require_relative 'types/props/plugin'
require_relative 'types/props/utils'
require_relative 'types/enum'
# Props that run sigs statically so have to be after all the others :(
require_relative 'types/props/private/setter_factory'
require_relative 'types/props/private/apply_default'
require_relative 'types/props/has_lazily_specialized_methods'
require_relative 'types/props/optional'
require_relative 'types/props/weak_constructor'
require_relative 'types/props/constructor'
require_relative 'types/props/pretty_printable'
require_relative 'types/props/private/serde_transform'
require_relative 'types/props/private/deserializer_generator'
require_relative 'types/props/private/serializer_generator'
require_relative 'types/props/serializable'
require_relative 'types/props/type_validation'
require_relative 'types/props/private/parser'
require_relative 'types/props/generated_code_validation'
require_relative 'types/struct'
require_relative 'types/non_forcing_constants'
require_relative 'types/compatibility_patches'
# Sorbet Compiler support module
require_relative 'types/private/compiler'

View File

@ -0,0 +1,316 @@
# frozen_string_literal: true
# typed: true
# This is where we define the shortcuts, so we can't use them here
# _____
# |_ _| _ _ __ ___ ___
# | || | | | '_ \ / _ \/ __|
# | || |_| | |_) | __/\__ \
# |_| \__, | .__/ \___||___/
# |___/|_|
#
# Docs at https://sorbet.org/docs/sigs
#
# Types that you can pass to `sig`:
#
# - a Ruby class
#
# - [<Type>, <Type>, ...] -- to specify a "tuple"; a fixed-size array with known types for each member
#
# - {key: <Type>, key2: <Type>, ...} -- to speicfy a "shape"; a fixed-size hash
# with known keys and type values
#
# - Any of the `T.foo` methods below
module T
# T.any(<Type>, <Type>, ...) -- matches any of the types listed
def self.any(type_a, type_b, *types)
type_a = T::Utils.coerce(type_a)
type_b = T::Utils.coerce(type_b)
types = types.map {|t| T::Utils.coerce(t)} if !types.empty?
T::Types::Union::Private::Pool.union_of_types(type_a, type_b, types)
end
# Shorthand for T.any(type, NilClass)
def self.nilable(type)
T::Types::Union::Private::Pool.union_of_types(T::Utils.coerce(type), T::Utils::Nilable::NIL_TYPE)
end
# Matches any object. In the static checker, T.untyped allows any
# method calls or operations.
def self.untyped
T::Types::Untyped::Private::INSTANCE
end
# Indicates a function never returns (e.g. "Kernel#raise")
def self.noreturn
T::Types::NoReturn::Private::INSTANCE
end
# T.all(<Type>, <Type>, ...) -- matches an object that has all of the types listed
def self.all(type_a, type_b, *types)
T::Types::Intersection.new([type_a, type_b] + types)
end
# Matches any of the listed values
# @deprecated Use T::Enum instead.
def self.deprecated_enum(values)
T::Types::Enum.new(values)
end
# Creates a proc type
def self.proc
T::Private::Methods.start_proc
end
# Matches `self`:
def self.self_type
T::Types::SelfType::Private::INSTANCE
end
# Matches the instance type in a singleton-class context
def self.attached_class
T::Types::AttachedClassType::Private::INSTANCE
end
# Matches any class that subclasses or includes the provided class
# or module
def self.class_of(klass)
T::Types::ClassOf.new(klass)
end
## END OF THE METHODS TO PASS TO `sig`.
# Constructs a type alias. Used to create a short name for a larger type. In Ruby this returns a
# wrapper that contains a proc that is evaluated to get the underlying type. This syntax however
# is needed for support by the static checker.
#
# @example
# NilableString = T.type_alias {T.nilable(String)}
#
# sig {params(arg: NilableString, default: String).returns(String)}
# def or_else(arg, default)
# arg || default
# end
#
# The name of the type alias is not preserved; Error messages will
# be printed with reference to the underlying type.
#
# TODO Remove `type` parameter. This was left in to make life easier while migrating.
def self.type_alias(type=nil, &blk)
if blk
T::Private::Types::TypeAlias.new(blk)
else
T::Utils.coerce(type)
end
end
# References a type parameter which was previously defined with
# `type_parameters`.
#
# This is used for generic methods.
#
# @example
# sig
# .type_parameters(:U)
# .params(
# blk: T.proc.params(arg0: Elem).returns(T.type_parameter(:U)),
# )
# .returns(T::Array[T.type_parameter(:U)])
# def map(&blk); end
def self.type_parameter(name)
T::Types::TypeParameter.new(name)
end
# Tells the typechecker that `value` is of type `type`. Use this to get additional checking after
# an expression that the typechecker is unable to analyze. If `checked` is true, raises an
# exception at runtime if the value doesn't match the type.
#
# Compared to `T.let`, `T.cast` is _trusted_ by static system.
def self.cast(value, type, checked: true)
return value unless checked
Private::Casts.cast(value, type, cast_method: "T.cast")
end
# Tells the typechecker to declare a variable of type `type`. Use
# like:
#
# seconds = T.let(0.0, Float)
#
# Compared to `T.cast`, `T.let` is _checked_ by static system.
#
# If `checked` is true, raises an exception at runtime if the value
# doesn't match the type.
def self.let(value, type, checked: true)
return value unless checked
Private::Casts.cast(value, type, cast_method: "T.let")
end
# Tells the type checker to treat `self` in the current block as `type`.
# Useful for blocks that are captured and executed later with instance_exec.
# Use like:
#
# seconds = lambda do
# T.bind(self, NewBinding)
# ...
# end
#
# `T.bind` behaves like `T.cast` in that it is assumed to be true statically.
#
# If `checked` is true, raises an exception at runtime if the value
# doesn't match the type (this is the default).
def self.bind(value, type, checked: true)
return value unless checked
Private::Casts.cast(value, type, cast_method: "T.bind")
end
# Tells the typechecker to ensure that `value` is of type `type` (if not, the typechecker will
# fail). Use this for debugging typechecking errors, or to ensure that type information is
# statically known and being checked appropriately. If `checked` is true, raises an exception at
# runtime if the value doesn't match the type.
def self.assert_type!(value, type, checked: true)
return value unless checked
Private::Casts.cast(value, type, cast_method: "T.assert_type!")
end
# For the static type checker, strips all type information from a value
# and returns the same value, but statically-typed as `T.untyped`.
# Can be used to tell the static checker to "trust you" by discarding type information
# you know to be incorrect. Use with care!
# (This has no effect at runtime.)
#
# We can't actually write this sig because we ourselves are inside
# the `T::` module and doing this would create a bootstrapping
# cycle. However, we also don't actually need to do so; An untyped
# identity method works just as well here.
#
# `sig {params(value: T.untyped).returns(T.untyped)}`
def self.unsafe(value)
value
end
# A convenience method to `raise` when the argument is `nil` and return it
# otherwise.
#
# Intended to be used as:
#
# needs_foo(T.must(maybe_gives_foo))
#
# Equivalent to:
#
# foo = maybe_gives_foo
# raise "nil" if foo.nil?
# needs_foo(foo)
#
# Intended to be used to promise sorbet that a given nilable value happens
# to contain a non-nil value at this point.
#
# `sig {params(arg: T.nilable(A)).returns(A)}`
def self.must(arg)
return arg if arg
return arg if arg == false
begin
raise TypeError.new("Passed `nil` into T.must")
rescue TypeError => e # raise into rescue to ensure e.backtrace is populated
T::Configuration.inline_type_error_handler(e, {kind: 'T.must', value: arg, type: nil})
end
end
# A way to ask Sorbet to show what type it thinks an expression has.
# This can be useful for debugging and checking assumptions.
# In the runtime, merely returns the value passed in.
def self.reveal_type(value)
value
end
# A way to ask Sorbet to prove that a certain branch of control flow never
# happens. Commonly used to assert that a case or if statement exhausts all
# possible cases.
def self.absurd(value)
msg = "Control flow reached T.absurd."
case value
when Kernel
msg += " Got value: #{value}"
end
begin
raise TypeError.new(msg)
rescue TypeError => e # raise into rescue to ensure e.backtrace is populated
T::Configuration.inline_type_error_handler(e, {kind: 'T.absurd', value: value, type: nil})
end
end
### Generic classes ###
module Array
def self.[](type)
if type.is_a?(T::Types::Untyped)
T::Types::TypedArray::Untyped.new
else
T::Types::TypedArray.new(type)
end
end
end
module Hash
def self.[](keys, values)
if keys.is_a?(T::Types::Untyped) && values.is_a?(T::Types::Untyped)
T::Types::TypedHash::Untyped.new
else
T::Types::TypedHash.new(keys: keys, values: values)
end
end
end
module Enumerable
def self.[](type)
if type.is_a?(T::Types::Untyped)
T::Types::TypedEnumerable::Untyped.new
else
T::Types::TypedEnumerable.new(type)
end
end
end
module Enumerator
def self.[](type)
if type.is_a?(T::Types::Untyped)
T::Types::TypedEnumerator::Untyped.new
else
T::Types::TypedEnumerator.new(type)
end
end
module Lazy
def self.[](type)
if type.is_a?(T::Types::Untyped)
T::Types::TypedEnumeratorLazy::Untyped.new
else
T::Types::TypedEnumeratorLazy.new(type)
end
end
end
end
module Range
def self.[](type)
T::Types::TypedRange.new(type)
end
end
module Set
def self.[](type)
if type.is_a?(T::Types::Untyped)
T::Types::TypedSet::Untyped.new
else
T::Types::TypedSet.new(type)
end
end
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# typed: true
module T::AbstractUtils
Methods = T::Private::Methods
# Returns whether a module is declared as abstract. After the module is finished being declared,
# this is equivalent to whether it has any abstract methods that haven't been implemented
# (because we validate that and raise an error otherwise).
#
# Note that checking `mod.is_a?(Abstract::Hooks)` is not a safe substitute for this method; when
# a class extends `Abstract::Hooks`, all of its subclasses, including the eventual concrete
# ones, will still have `Abstract::Hooks` as an ancestor.
def self.abstract_module?(mod)
!T::Private::Abstract::Data.get(mod, :abstract_type).nil?
end
def self.abstract_method?(method)
signature = Methods.signature_for_method(method)
signature&.mode == Methods::Modes.abstract
end
# Given a module, returns the set of methods declared as abstract (in itself or ancestors)
# that have not been implemented.
def self.abstract_methods_for(mod)
declared_methods = declared_abstract_methods_for(mod)
declared_methods.select do |declared_method|
actual_method = mod.instance_method(declared_method.name)
# Note that in the case where an abstract method is overridden by another abstract method,
# this method will return them both. This is intentional to ensure we validate the final
# implementation against all declarations of an abstract method (they might not all have the
# same signature).
abstract_method?(actual_method)
end
end
# Given a module, returns the set of methods declared as abstract (in itself or ancestors)
# regardless of whether they have been implemented.
def self.declared_abstract_methods_for(mod)
methods = []
mod.ancestors.each do |ancestor|
ancestor_methods = ancestor.private_instance_methods(false) + ancestor.instance_methods(false)
ancestor_methods.each do |method_name|
method = ancestor.instance_method(method_name)
methods << method if abstract_method?(method)
end
end
methods
end
end

View File

@ -0,0 +1,8 @@
# typed: strict
# frozen_string_literal: true
module T
# T::Boolean is a type alias helper for the common `T.any(TrueClass, FalseClass)`.
# Defined separately from _types.rb because it has a dependency on T::Types::Union.
Boolean = T.type_alias {T.any(TrueClass, FalseClass)}
end

View File

@ -0,0 +1,93 @@
# frozen_string_literal: true
# typed: ignore
# Work around an interaction bug with sorbet-runtime and rspec-mocks,
# which occurs when using message expectations (*_any_instance_of,
# expect, allow) and and_call_original.
#
# When a sig is defined, sorbet-runtime will replace the sigged method
# with a wrapper that, upon first invocation, re-wraps the method with a faster
# implementation.
#
# When expect_any_instance_of is used, rspec stores a reference to the first wrapper,
# to be restored later.
#
# The first wrapper is invoked as part of the test and sorbet-runtime replaces
# the method definition with the second wrapper.
#
# But when mocks are cleaned up, rspec restores back to the first wrapper.
# Upon subsequent invocations, the first wrapper is called, and sorbet-runtime
# throws a runtime error, since this is an unexpected state.
#
# We work around this by forcing re-wrapping before rspec stores a reference
# to the method.
if defined? ::RSpec::Mocks
module T
module CompatibilityPatches
module RSpecCompatibility
module RecorderExtensions
def observe!(method_name)
method = @klass.instance_method(method_name.to_sym)
T::Private::Methods.maybe_run_sig_block_for_method(method)
super(method_name)
end
end
::RSpec::Mocks::AnyInstance::Recorder.prepend(RecorderExtensions) if defined?(::RSpec::Mocks::AnyInstance::Recorder)
module MethodDoubleExtensions
def initialize(object, method_name, proxy)
if ::Kernel.instance_method(:respond_to?).bind(object).call(method_name, true)
method = ::RSpec::Support.method_handle_for(object, method_name)
T::Private::Methods.maybe_run_sig_block_for_method(method)
end
super(object, method_name, proxy)
end
end
::RSpec::Mocks::MethodDouble.prepend(MethodDoubleExtensions) if defined?(::RSpec::Mocks::MethodDouble)
end
end
end
end
# Work around for sorbet-runtime wrapped methods.
#
# When a sig is defined, sorbet-runtime will replace the sigged method
# with a wrapper. Those wrapper methods look like `foo(*args, &blk)`
# so that wrappers can handle and pass on all the arguments supplied.
#
# However, that creates a problem with runtime reflection on the methods,
# since when a sigged method is introspected, it will always return its
# `arity` as `-1`, its `parameters` as `[[:rest, :args], [:block, :blk]]`,
# and its `source_location` as `[<some_file_in_sorbet>, <some_line_number>]`.
#
# This might be a problem for some applications that rely on getting the
# correct information from these methods.
#
# This compatibility module, when prepended to the `Method` class, would fix
# the return values of `arity`, `parameters` and `source_location`.
#
# @example
# require 'sorbet-runtime'
# ::Method.prepend(T::CompatibilityPatches::MethodExtensions)
module T
module CompatibilityPatches
module MethodExtensions
def arity
arity = super
return arity if arity != -1 || self.is_a?(Proc)
sig = T::Private::Methods.signature_for_method(self)
sig ? sig.method.arity : arity
end
def source_location
sig = T::Private::Methods.signature_for_method(self)
sig ? sig.method.source_location : super
end
def parameters
sig = T::Private::Methods.signature_for_method(self)
sig ? sig.method.parameters : super
end
end
end
end

View File

@ -0,0 +1,591 @@
# typed: true
# frozen_string_literal: true
module T::Configuration
# Cache this comparisonn to avoid two allocations all over the place.
AT_LEAST_RUBY_2_7 = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.7')
# Announces to Sorbet that we are currently in a test environment, so it
# should treat any sigs which are marked `.checked(:tests)` as if they were
# just a normal sig.
#
# If this method is not called, sigs marked `.checked(:tests)` will not be
# checked. In fact, such methods won't even be wrapped--the runtime will put
# back the original method.
#
# Note: Due to the way sigs are evaluated and methods are wrapped, this
# method MUST be called before any code calls `sig`. This method raises if
# it has been called too late.
def self.enable_checking_for_sigs_marked_checked_tests
T::Private::RuntimeLevels.enable_checking_in_tests
end
# Announce to Sorbet that we would like the final checks to be enabled when
# including and extending modules. Iff this is not called, then the following
# example will not raise an error.
#
# ```ruby
# module M
# extend T::Sig
# sig(:final) {void}
# def foo; end
# end
# class C
# include M
# def foo; end
# end
# ```
def self.enable_final_checks_on_hooks
T::Private::Methods.set_final_checks_on_hooks(true)
end
# Undo the effects of a previous call to
# `enable_final_checks_on_hooks`.
def self.reset_final_checks_on_hooks
T::Private::Methods.set_final_checks_on_hooks(false)
end
@include_value_in_type_errors = true
# Whether to include values in TypeError messages.
#
# Including values is useful for debugging, but can potentially leak
# sensitive information to logs.
#
# @return [T::Boolean]
def self.include_value_in_type_errors?
@include_value_in_type_errors
end
# Configure if type errors excludes the value of the problematic type.
#
# The default is to include values in type errors:
# TypeError: Expected type Integer, got String with value "foo"
#
# When values are excluded from type errors:
# TypeError: Expected type Integer, got String
def self.exclude_value_in_type_errors
@include_value_in_type_errors = false
end
# Opposite of exclude_value_in_type_errors.
# (Including values in type errors is the default)
def self.include_value_in_type_errors
@include_value_in_type_errors = true
end
# Whether VM-defined prop serialization/deserialization routines can be enabled.
#
# @return [T::Boolean]
def self.can_enable_vm_prop_serde?
T::Props::Private::DeserializerGenerator.respond_to?(:generate2)
end
@use_vm_prop_serde = false
# Whether to use VM-defined prop serialization/deserialization routines.
#
# The default is to use runtime codegen inside sorbet-runtime itself.
#
# @return [T::Boolean]
def self.use_vm_prop_serde?
@use_vm_prop_serde || false
end
# Enable using VM-defined prop serialization/deserialization routines.
#
# This method is likely to break things outside of Stripe's systems.
def self.enable_vm_prop_serde
if !can_enable_vm_prop_serde?
hard_assert_handler('Ruby VM is not setup to use VM-defined prop serde')
end
@use_vm_prop_serde = true
end
# Disable using VM-defined prop serialization/deserialization routines.
def self.disable_vm_prop_serde
@use_vm_prop_serde = false
end
# Configure the default checked level for a sig with no explicit `.checked`
# builder. When unset, the default checked level is `:always`.
#
# Note: setting this option is potentially dangerous! Sorbet can't check all
# code statically. The runtime checks complement the checks that Sorbet does
# statically, so that methods don't have to guard themselves from being
# called incorrectly by untyped code.
#
# @param [:never, :compiled, :tests, :always] default_checked_level
def self.default_checked_level=(default_checked_level)
T::Private::RuntimeLevels.default_checked_level = default_checked_level
end
@inline_type_error_handler = nil
# Set a handler to handle `TypeError`s raised by any in-line type assertions,
# including `T.must`, `T.let`, `T.cast`, and `T.assert_type!`.
#
# By default, any `TypeError`s detected by this gem will be raised. Setting
# inline_type_error_handler to an object that implements :call (e.g. proc or
# lambda) allows users to customize the behavior when a `TypeError` is
# raised on any inline type assertion.
#
# @param [Lambda, Proc, Object, nil] value Proc that handles the error (pass
# nil to reset to default behavior)
#
# Parameters passed to value.call:
#
# @param [TypeError] error TypeError that was raised
# @param [Hash] opts A hash containing contextual information on the error:
# @option opts [String] :kind One of:
# ['T.cast', 'T.let', 'T.bind', 'T.assert_type!', 'T.must', 'T.absurd']
# @option opts [Object, nil] :type Expected param/return value type
# @option opts [Object] :value Actual param/return value
#
# @example
# T::Configuration.inline_type_error_handler = lambda do |error, opts|
# puts error.message
# end
def self.inline_type_error_handler=(value)
validate_lambda_given!(value)
@inline_type_error_handler = value
end
private_class_method def self.inline_type_error_handler_default(error, opts)
raise error
end
def self.inline_type_error_handler(error, opts={})
if @inline_type_error_handler
# Backwards compatibility before `inline_type_error_handler` took a second arg
if @inline_type_error_handler.arity == 1
@inline_type_error_handler.call(error)
else
@inline_type_error_handler.call(error, opts)
end
else
inline_type_error_handler_default(error, opts)
end
nil
end
@sig_builder_error_handler = nil
# Set a handler to handle errors that occur when the builder methods in the
# body of a sig are executed. The sig builder methods are inside a proc so
# that they can be lazily evaluated the first time the method being sig'd is
# called.
#
# By default, improper use of the builder methods within the body of a sig
# cause an ArgumentError to be raised. Setting sig_builder_error_handler to an
# object that implements :call (e.g. proc or lambda) allows users to
# customize the behavior when a sig can't be built for some reason.
#
# @param [Lambda, Proc, Object, nil] value Proc that handles the error (pass
# nil to reset to default behavior)
#
# Parameters passed to value.call:
#
# @param [StandardError] error The error that was raised
# @param [Thread::Backtrace::Location] location Location of the error
#
# @example
# T::Configuration.sig_builder_error_handler = lambda do |error, location|
# puts error.message
# end
def self.sig_builder_error_handler=(value)
validate_lambda_given!(value)
@sig_builder_error_handler = value
end
private_class_method def self.sig_builder_error_handler_default(error, location)
raise ArgumentError.new("#{location.path}:#{location.lineno}: Error interpreting `sig`:\n #{error.message}\n\n")
end
def self.sig_builder_error_handler(error, location)
if @sig_builder_error_handler
@sig_builder_error_handler.call(error, location)
else
sig_builder_error_handler_default(error, location)
end
nil
end
@sig_validation_error_handler = nil
# Set a handler to handle sig validation errors.
#
# Sig validation errors include things like abstract checks, override checks,
# and type compatibility of arguments. They happen after a sig has been
# successfully built, but the built sig is incompatible with other sigs in
# some way.
#
# By default, sig validation errors cause an exception to be raised.
# Setting sig_validation_error_handler to an object that implements :call
# (e.g. proc or lambda) allows users to customize the behavior when a method
# signature's build fails.
#
# @param [Lambda, Proc, Object, nil] value Proc that handles the error (pass
# nil to reset to default behavior)
#
# Parameters passed to value.call:
#
# @param [StandardError] error The error that was raised
# @param [Hash] opts A hash containing contextual information on the error:
# @option opts [Method, UnboundMethod] :method Method on which the signature build failed
# @option opts [T::Private::Methods::Declaration] :declaration Method
# signature declaration struct
# @option opts [T::Private::Methods::Signature, nil] :signature Signature
# that failed (nil if sig build failed before Signature initialization)
# @option opts [T::Private::Methods::Signature, nil] :super_signature Super
# method's signature (nil if method is not an override or super method
# does not have a method signature)
#
# @example
# T::Configuration.sig_validation_error_handler = lambda do |error, opts|
# puts error.message
# end
def self.sig_validation_error_handler=(value)
validate_lambda_given!(value)
@sig_validation_error_handler = value
end
private_class_method def self.sig_validation_error_handler_default(error, opts)
raise error
end
def self.sig_validation_error_handler(error, opts={})
if @sig_validation_error_handler
@sig_validation_error_handler.call(error, opts)
else
sig_validation_error_handler_default(error, opts)
end
nil
end
@call_validation_error_handler = nil
# Set a handler for type errors that result from calling a method.
#
# By default, errors from calling a method cause an exception to be raised.
# Setting call_validation_error_handler to an object that implements :call
# (e.g. proc or lambda) allows users to customize the behavior when a method
# is called with invalid parameters, or returns an invalid value.
#
# @param [Lambda, Proc, Object, nil] value Proc that handles the error
# report (pass nil to reset to default behavior)
#
# Parameters passed to value.call:
#
# @param [T::Private::Methods::Signature] signature Signature that failed
# @param [Hash] opts A hash containing contextual information on the error:
# @option opts [String] :message Error message
# @option opts [String] :kind One of:
# ['Parameter', 'Block parameter', 'Return value']
# @option opts [Symbol] :name Param or block param name (nil for return
# value)
# @option opts [Object] :type Expected param/return value type
# @option opts [Object] :value Actual param/return value
# @option opts [Thread::Backtrace::Location] :location Location of the
# caller
#
# @example
# T::Configuration.call_validation_error_handler = lambda do |signature, opts|
# puts opts[:message]
# end
def self.call_validation_error_handler=(value)
validate_lambda_given!(value)
@call_validation_error_handler = value
end
private_class_method def self.call_validation_error_handler_default(signature, opts)
raise TypeError.new(opts[:pretty_message])
end
def self.call_validation_error_handler(signature, opts={})
if @call_validation_error_handler
@call_validation_error_handler.call(signature, opts)
else
call_validation_error_handler_default(signature, opts)
end
nil
end
@log_info_handler = nil
# Set a handler for logging
#
# @param [Lambda, Proc, Object, nil] value Proc that handles the error
# report (pass nil to reset to default behavior)
#
# Parameters passed to value.call:
#
# @param [String] str Message to be logged
# @param [Hash] extra A hash containing additional parameters to be passed along to the logger.
#
# @example
# T::Configuration.log_info_handler = lambda do |str, extra|
# puts "#{str}, context: #{extra}"
# end
def self.log_info_handler=(value)
validate_lambda_given!(value)
@log_info_handler = value
end
private_class_method def self.log_info_handler_default(str, extra)
puts "#{str}, extra: #{extra}"
end
def self.log_info_handler(str, extra)
if @log_info_handler
@log_info_handler.call(str, extra)
else
log_info_handler_default(str, extra)
end
end
@soft_assert_handler = nil
# Set a handler for soft assertions
#
# These generally shouldn't stop execution of the program, but rather inform
# some party of the assertion to action on later.
#
# @param [Lambda, Proc, Object, nil] value Proc that handles the error
# report (pass nil to reset to default behavior)
#
# Parameters passed to value.call:
#
# @param [String] str Assertion message
# @param [Hash] extra A hash containing additional parameters to be passed along to the handler.
#
# @example
# T::Configuration.soft_assert_handler = lambda do |str, extra|
# puts "#{str}, context: #{extra}"
# end
def self.soft_assert_handler=(value)
validate_lambda_given!(value)
@soft_assert_handler = value
end
private_class_method def self.soft_assert_handler_default(str, extra)
puts "#{str}, extra: #{extra}"
end
def self.soft_assert_handler(str, extra)
if @soft_assert_handler
@soft_assert_handler.call(str, extra)
else
soft_assert_handler_default(str, extra)
end
end
@hard_assert_handler = nil
# Set a handler for hard assertions
#
# These generally should stop execution of the program, and optionally inform
# some party of the assertion.
#
# @param [Lambda, Proc, Object, nil] value Proc that handles the error
# report (pass nil to reset to default behavior)
#
# Parameters passed to value.call:
#
# @param [String] str Assertion message
# @param [Hash] extra A hash containing additional parameters to be passed along to the handler.
#
# @example
# T::Configuration.hard_assert_handler = lambda do |str, extra|
# raise "#{str}, context: #{extra}"
# end
def self.hard_assert_handler=(value)
validate_lambda_given!(value)
@hard_assert_handler = value
end
private_class_method def self.hard_assert_handler_default(str, _)
raise str
end
def self.hard_assert_handler(str, extra={})
if @hard_assert_handler
@hard_assert_handler.call(str, extra)
else
hard_assert_handler_default(str, extra)
end
end
@scalar_types = nil
# Set a list of class strings that are to be considered scalar.
# (pass nil to reset to default behavior)
#
# @param [String] values Class name.
#
# @example
# T::Configuration.scalar_types = ["NilClass", "TrueClass", "FalseClass", ...]
def self.scalar_types=(values)
if values.nil?
@scalar_types = values
else
bad_values = values.reject {|v| v.class == String}
unless bad_values.empty?
raise ArgumentError.new("Provided values must all be class name strings.")
end
@scalar_types = values.each_with_object({}) {|x, acc| acc[x] = true}.freeze
end
end
@default_scalar_types = {
"NilClass" => true,
"TrueClass" => true,
"FalseClass" => true,
"Integer" => true,
"Float" => true,
"String" => true,
"Symbol" => true,
"Time" => true,
"T::Enum" => true,
}.freeze
def self.scalar_types
@scalar_types || @default_scalar_types
end
# Guard against overrides of `name` or `to_s`
MODULE_NAME = Module.instance_method(:name)
private_constant :MODULE_NAME
@default_module_name_mangler = if T::Configuration::AT_LEAST_RUBY_2_7
->(type) {MODULE_NAME.bind_call(type)}
else
->(type) {MODULE_NAME.bind(type).call}
end
@module_name_mangler = nil
def self.module_name_mangler
@module_name_mangler || @default_module_name_mangler
end
# Set to override the default behavior for converting types
# to names in generated code. Used by the runtime implementation
# associated with `--stripe-packages` mode.
#
# @param [Lambda, Proc, nil] handler Proc that converts a type (Class/Module)
# to a String (pass nil to reset to default behavior)
def self.module_name_mangler=(handler)
@module_name_mangler = handler
end
@sensitivity_and_pii_handler = nil
# Set to a PII handler function. This will be called with the `sensitivity:`
# annotations on things that use `T::Props` and can modify them ahead-of-time.
#
# @param [Lambda, Proc, nil] handler Proc that takes a hash mapping symbols to the
# prop values. Pass nil to avoid changing `sensitivity:` annotations.
def self.normalize_sensitivity_and_pii_handler=(handler)
@sensitivity_and_pii_handler = handler
end
def self.normalize_sensitivity_and_pii_handler
@sensitivity_and_pii_handler
end
@redaction_handler = nil
# Set to a redaction handling function. This will be called when the
# `_redacted` version of a prop reader is used. By default this is set to
# `nil` and will raise an exception when the redacted version of a prop is
# accessed.
#
# @param [Lambda, Proc, nil] handler Proc that converts a value into its
# redacted version according to the spec passed as the second argument.
def self.redaction_handler=(handler)
@redaction_handler = handler
end
def self.redaction_handler
@redaction_handler
end
@class_owner_finder = nil
# Set to a function which can get the 'owner' of a class. This is
# used in reporting deserialization errors
#
# @param [Lambda, Proc, nil] handler Proc that takes a class and
# produces its owner, or `nil` if it does not have one.
def self.class_owner_finder=(handler)
@class_owner_finder = handler
end
def self.class_owner_finder
@class_owner_finder
end
# Temporarily disable ruby warnings while executing the given block. This is
# useful when doing something that would normally cause a warning to be
# emitted in Ruby verbose mode ($VERBOSE = true).
#
# @yield
#
def self.without_ruby_warnings
if $VERBOSE
begin
original_verbose = $VERBOSE
$VERBOSE = false
yield
ensure
$VERBOSE = original_verbose
end
else
yield
end
end
@legacy_t_enum_migration_mode = false
def self.enable_legacy_t_enum_migration_mode
@legacy_t_enum_migration_mode = true
end
def self.disable_legacy_t_enum_migration_mode
@legacy_t_enum_migration_mode = false
end
def self.legacy_t_enum_migration_mode?
@legacy_t_enum_migration_mode || false
end
@prop_freeze_handler = ->(instance, prop_name) {}
def self.prop_freeze_handler=(handler)
@prop_freeze_handler = handler
end
def self.prop_freeze_handler
@prop_freeze_handler
end
@sealed_violation_whitelist = nil
# @param [Array] sealed_violation_whitelist An array of Regexp to validate
# whether inheriting /including a sealed module outside the defining module
# should be allowed. Useful to whitelist benign violations, like shim files
# generated for an autoloader.
def self.sealed_violation_whitelist=(sealed_violation_whitelist)
if !@sealed_violation_whitelist.nil?
raise ArgumentError.new("Cannot overwrite sealed_violation_whitelist after setting it")
end
case sealed_violation_whitelist
when Array
sealed_violation_whitelist.each do |x|
case x
when Regexp then nil
else raise TypeError.new("sealed_violation_whitelist accepts an Array of Regexp")
end
end
else
raise TypeError.new("sealed_violation_whitelist= accepts an Array of Regexp")
end
@sealed_violation_whitelist = sealed_violation_whitelist
end
def self.sealed_violation_whitelist
@sealed_violation_whitelist
end
private_class_method def self.validate_lambda_given!(value)
if !value.nil? && !value.respond_to?(:call)
raise ArgumentError.new("Provided value must respond to :call")
end
end
end

View File

@ -0,0 +1,377 @@
# frozen_string_literal: true
# typed: strict
# Enumerations allow for type-safe declarations of a fixed set of values.
#
# Every value is a singleton instance of the class (i.e. `Suit::SPADE.is_a?(Suit) == true`).
#
# Each value has a corresponding serialized value. By default this is the constant's name converted
# to lowercase (e.g. `Suit::Club.serialize == 'club'`); however a custom value may be passed to the
# constructor. Enum will `freeze` the serialized value.
#
# @example Declaring an Enum:
# class Suit < T::Enum
# enums do
# CLUB = new
# SPADE = new
# DIAMOND = new
# HEART = new
# end
# end
#
# @example Custom serialization value:
# class Status < T::Enum
# enums do
# READY = new('rdy')
# ...
# end
# end
#
# @example Accessing values:
# Suit::SPADE
#
# @example Converting from serialized value to enum instance:
# Suit.deserialize('club') == Suit::CLUB
#
# @example Using enums in type signatures:
# sig {params(suit: Suit).returns(Boolean)}
# def is_red?(suit); ...; end
#
# WARNING: Enum instances are singletons that are shared among all their users. Their internals
# should be kept immutable to avoid unpredictable action at a distance.
class T::Enum
extend T::Sig
extend T::Props::CustomType
# TODO(jez) Might want to restrict this, or make subclasses provide this type
SerializedVal = T.type_alias {T.untyped}
private_constant :SerializedVal
### Enum class methods ###
sig {returns(T::Array[T.attached_class])}
def self.values
if @values.nil?
raise "Attempting to access values of #{self.class} before it has been initialized." \
" Enums are not initialized until the 'enums do' block they are defined in has finished running."
end
@values
end
# This exists for compatibility with the interface of `Hash` & mostly to support
# the HashEachMethods Rubocop.
sig {params(blk: T.nilable(T.proc.params(arg0: T.attached_class).void)).returns(T.any(T::Enumerator[T.attached_class], T::Array[T.attached_class]))}
def self.each_value(&blk)
if blk
values.each(&blk)
else
values.each
end
end
# Convert from serialized value to enum instance
#
# Note: It would have been nice to make this method final before people started overriding it.
# Note: Failed CriticalMethodsNoRuntimeTypingTest
sig {params(serialized_val: SerializedVal).returns(T.nilable(T.attached_class)).checked(:never)}
def self.try_deserialize(serialized_val)
if @mapping.nil?
raise "Attempting to access serialization map of #{self.class} before it has been initialized." \
" Enums are not initialized until the 'enums do' block they are defined in has finished running."
end
@mapping[serialized_val]
end
# Convert from serialized value to enum instance.
#
# Note: It would have been nice to make this method final before people started overriding it.
# Note: Failed CriticalMethodsNoRuntimeTypingTest
#
# @return [self]
# @raise [KeyError] if serialized value does not match any instance.
sig {overridable.params(serialized_val: SerializedVal).returns(T.attached_class).checked(:never)}
def self.from_serialized(serialized_val)
res = try_deserialize(serialized_val)
if res.nil?
raise KeyError.new("Enum #{self} key not found: #{serialized_val.inspect}")
end
res
end
# Note: It would have been nice to make this method final before people started overriding it.
# @return [Boolean] Does the given serialized value correspond with any of this enum's values.
sig {overridable.params(serialized_val: SerializedVal).returns(T::Boolean).checked(:never)}
def self.has_serialized?(serialized_val)
if @mapping.nil?
raise "Attempting to access serialization map of #{self.class} before it has been initialized." \
" Enums are not initialized until the 'enums do' block they are defined in has finished running."
end
@mapping.include?(serialized_val)
end
# Note: Failed CriticalMethodsNoRuntimeTypingTest
sig {override.params(instance: T.nilable(T::Enum)).returns(SerializedVal).checked(:never)}
def self.serialize(instance)
# This is needed otherwise if a Chalk::ODM::Document with a property of the shape
# T::Hash[T.nilable(MyEnum), Integer] and a value that looks like {nil => 0} is
# serialized, we throw the error on L102.
return nil if instance.nil?
if self == T::Enum
raise "Cannot call T::Enum.serialize directly. You must call on a specific child class."
end
if instance.class != self
raise "Cannot call #serialize on a value that is not an instance of #{self}."
end
instance.serialize
end
# Note: Failed CriticalMethodsNoRuntimeTypingTest
sig {override.params(mongo_value: SerializedVal).returns(T.attached_class).checked(:never)}
def self.deserialize(mongo_value)
if self == T::Enum
raise "Cannot call T::Enum.deserialize directly. You must call on a specific child class."
end
self.from_serialized(mongo_value)
end
### Enum instance methods ###
sig {returns(T.self_type)}
def dup
self
end
sig {returns(T.self_type).checked(:tests)}
def clone
self
end
# Note: Failed CriticalMethodsNoRuntimeTypingTest
sig {returns(SerializedVal).checked(:never)}
def serialize
assert_bound!
@serialized_val
end
sig {params(args: T.untyped).returns(T.untyped)}
def to_json(*args)
serialize.to_json(*args)
end
sig {params(args: T.untyped).returns(T.untyped)}
def as_json(*args)
serialized_val = serialize
return serialized_val unless serialized_val.respond_to?(:as_json)
serialized_val.as_json(*args)
end
sig {returns(String)}
def to_s
inspect
end
sig {returns(String)}
def inspect
"#<#{self.class.name}::#{@const_name || '__UNINITIALIZED__'}>"
end
sig {params(other: BasicObject).returns(T.nilable(Integer))}
def <=>(other)
case other
when self.class
self.serialize <=> other.serialize
else
nil
end
end
# NB: Do not call this method. This exists to allow for a safe migration path in places where enum
# values are compared directly against string values.
#
# Ruby's string has a weird quirk where `'my_string' == obj` calls obj.==('my_string') if obj
# responds to the `to_str` method. It does not actually call `to_str` however.
#
# See https://ruby-doc.org/core-2.4.0/String.html#method-i-3D-3D
sig {returns(String)}
def to_str
msg = 'Implicit conversion of Enum instances to strings is not allowed. Call #serialize instead.'
if T::Configuration.legacy_t_enum_migration_mode?
T::Configuration.soft_assert_handler(
msg,
storytime: {class: self.class.name},
)
serialize.to_s
else
raise NoMethodError.new(msg)
end
end
sig {params(other: BasicObject).returns(T::Boolean).checked(:never)}
def ==(other)
case other
when String
if T::Configuration.legacy_t_enum_migration_mode?
comparison_assertion_failed(:==, other)
self.serialize == other
else
false
end
else
super(other)
end
end
sig {params(other: BasicObject).returns(T::Boolean).checked(:never)}
def ===(other)
case other
when String
if T::Configuration.legacy_t_enum_migration_mode?
comparison_assertion_failed(:===, other)
self.serialize == other
else
false
end
else
super(other)
end
end
sig {params(method: Symbol, other: T.untyped).void}
private def comparison_assertion_failed(method, other)
T::Configuration.soft_assert_handler(
'Enum to string comparison not allowed. Compare to the Enum instance directly instead. See go/enum-migration',
storytime: {
class: self.class.name,
self: self.inspect,
other: other,
other_class: other.class.name,
method: method,
}
)
end
### Private implementation ###
sig {params(serialized_val: SerializedVal).void}
def initialize(serialized_val=nil)
raise 'T::Enum is abstract' if self.class == T::Enum
if !self.class.started_initializing?
raise "Must instantiate all enum values of #{self.class} inside 'enums do'."
end
if self.class.fully_initialized?
raise "Cannot instantiate a new enum value of #{self.class} after it has been initialized."
end
serialized_val = serialized_val.frozen? ? serialized_val : serialized_val.dup.freeze
@serialized_val = T.let(serialized_val, T.nilable(SerializedVal))
@const_name = T.let(nil, T.nilable(Symbol))
self.class._register_instance(self)
end
sig {returns(NilClass).checked(:never)}
private def assert_bound!
if @const_name.nil?
raise "Attempting to access Enum value on #{self.class} before it has been initialized." \
" Enums are not initialized until the 'enums do' block they are defined in has finished running."
end
end
sig {params(const_name: Symbol).void}
def _bind_name(const_name)
@const_name = const_name
@serialized_val = const_to_serialized_val(const_name) if @serialized_val.nil?
freeze
end
sig {params(const_name: Symbol).returns(String)}
private def const_to_serialized_val(const_name)
# Historical note: We convert to lowercase names because the majority of existing calls to
# `make_accessible` were arrays of lowercase strings. Doing this conversion allowed for the
# least amount of repetition in migrated declarations.
-const_name.to_s.downcase.freeze
end
sig {returns(T::Boolean)}
def self.started_initializing?
unless defined?(@started_initializing)
@started_initializing = T.let(false, T.nilable(T::Boolean))
end
T.must(@started_initializing)
end
sig {returns(T::Boolean)}
def self.fully_initialized?
unless defined?(@fully_initialized)
@fully_initialized = T.let(false, T.nilable(T::Boolean))
end
T.must(@fully_initialized)
end
# Maintains the order in which values are defined
sig {params(instance: T.untyped).void}
def self._register_instance(instance)
@values ||= []
@values << T.cast(instance, T.attached_class)
end
# Entrypoint for allowing people to register new enum values.
# All enum values must be defined within this block.
sig {params(blk: T.proc.void).void}
def self.enums(&blk)
raise "enums cannot be defined for T::Enum" if self == T::Enum
raise "Enum #{self} was already initialized" if fully_initialized?
raise "Enum #{self} is still initializing" if started_initializing?
@started_initializing = true
@values = T.let(nil, T.nilable(T::Array[T.attached_class]))
yield
@mapping = T.let(nil, T.nilable(T::Hash[SerializedVal, T.attached_class]))
@mapping = {}
# Freeze the Enum class and bind the constant names into each of the instances.
self.constants(false).each do |const_name|
instance = self.const_get(const_name, false)
if !instance.is_a?(self)
raise "Invalid constant #{self}::#{const_name} on enum. " \
"All constants defined for an enum must be instances itself (e.g. `Foo = new`)."
end
instance._bind_name(const_name)
serialized = instance.serialize
if @mapping.include?(serialized)
raise "Enum values must have unique serializations. Value '#{serialized}' is repeated on #{self}."
end
@mapping[serialized] = instance
end
@values.freeze
@mapping.freeze
orphaned_instances = T.must(@values) - @mapping.values
if !orphaned_instances.empty?
raise "Enum values must be assigned to constants: #{orphaned_instances.map {|v| v.instance_variable_get('@serialized_val')}}"
end
@fully_initialized = true
end
sig {params(child_class: Module).void}
def self.inherited(child_class)
super
raise "Inheriting from children of T::Enum is prohibited" if self != T::Enum
end
# Marshal support
sig {params(_level: Integer).returns(String)}
def _dump(_level)
Marshal.dump(serialize)
end
sig {params(args: String).returns(T.attached_class)}
def self._load(args)
deserialize(Marshal.load(args)) # rubocop:disable Security/MarshalLoad
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
# typed: true
# Use as a mixin with extend (`extend T::Generic`).
module T::Generic
include T::Helpers
include Kernel
### Class/Module Helpers ###
def [](*types)
self
end
def type_member(variance=:invariant, &blk)
T::Types::TypeMember.new(variance)
end
def type_template(variance=:invariant, &blk)
T::Types::TypeTemplate.new(variance)
end
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
# typed: true
# Use as a mixin with extend (`extend T::Helpers`).
# Docs at https://sorbet.org/docs/
module T::Helpers
extend T::Sig
Private = T::Private
### Class/Module Helpers ###
def abstract!
Private::Abstract::Declare.declare_abstract(self, type: :abstract)
end
def interface!
Private::Abstract::Declare.declare_abstract(self, type: :interface)
end
def final!
Private::Final.declare(self)
end
def sealed!
Private::Sealed.declare(self, Kernel.caller(1..1)&.first&.split(':')&.first)
end
# Causes a mixin to also mix in class methods from the named module.
#
# Nearly equivalent to
#
# def self.included(other)
# other.extend(mod)
# end
#
# Except that it is statically analyzed by sorbet.
def mixes_in_class_methods(mod, *mods)
Private::Mixins.declare_mixes_in_class_methods(self, [mod].concat(mods))
end
# Specify an inclusion or inheritance requirement for `self`.
#
# Example:
#
# module MyHelper
# extend T::Helpers
#
# requires_ancestor { Kernel }
# end
#
# class MyClass < BasicObject # error: `MyClass` must include `Kernel` (required by `MyHelper`)
# include MyHelper
# end
#
# TODO: implement the checks in sorbet-runtime.
def requires_ancestor(&block); end
end

View File

@ -0,0 +1,158 @@
# frozen_string_literal: true
# typed: false
# Wraps an object, exposing only the methods defined on a given class/module. The idea is that, in
# the absence of a static type checker that would prevent you from calling non-Bar methods on a
# variable of type Bar, we can use these wrappers as a way of enforcing it at runtime.
#
# Once we ship static type checking, we should get rid of this entirely.
class T::InterfaceWrapper
extend T::Sig
module Helpers
def wrap_instance(obj)
T::InterfaceWrapper.wrap_instance(obj, self)
end
def wrap_instances(arr)
T::InterfaceWrapper.wrap_instances(arr, self)
end
end
private_class_method :new # use `wrap_instance`
def self.wrap_instance(obj, interface_mod)
wrapper = wrapped_dynamic_cast(obj, interface_mod)
if wrapper.nil?
raise "#{obj.class} cannot be cast to #{interface_mod}"
end
wrapper
end
sig do
params(
arr: Array,
interface_mod: T.untyped
)
.returns(Array)
end
def self.wrap_instances(arr, interface_mod)
arr.map {|instance| self.wrap_instance(instance, interface_mod)}
end
def initialize(target_obj, interface_mod)
if target_obj.is_a?(T::InterfaceWrapper)
# wrapped_dynamic_cast should guarantee this never happens.
raise "Unexpected: wrapping a wrapper. Please report this bug at https://github.com/sorbet/sorbet/issues"
end
if !target_obj.is_a?(interface_mod)
# wrapped_dynamic_cast should guarantee this never happens.
raise "Unexpected: `is_a?` failed. Please report this bug at https://github.com/sorbet/sorbet/issues"
end
if target_obj.class == interface_mod
# wrapped_dynamic_cast should guarantee this never happens.
raise "Unexpected: exact class match. Please report this bug at https://github.com/sorbet/sorbet/issues"
end
@target_obj = target_obj
@interface_mod = interface_mod
self_methods = self.class.self_methods
# If perf becomes an issue, we can define these on an anonymous subclass, and keep a cache
# so we only need to do it once per unique `interface_mod`
T::Utils.methods_excluding_object(interface_mod).each do |method_name|
if self_methods.include?(method_name)
raise "interface_mod has a method that conflicts with #{self.class}: #{method_name}"
end
define_singleton_method(method_name) do |*args, &blk|
target_obj.send(method_name, *args, &blk)
end
if target_obj.singleton_class.public_method_defined?(method_name)
# no-op, it's already public
elsif target_obj.singleton_class.protected_method_defined?(method_name)
singleton_class.send(:protected, method_name)
elsif target_obj.singleton_class.private_method_defined?(method_name)
singleton_class.send(:private, method_name)
else
raise "This should never happen. Report this bug at https://github.com/sorbet/sorbet/issues"
end
end
end
def kind_of?(other)
is_a?(other)
end
def is_a?(other)
if !other.is_a?(Module)
raise TypeError.new("class or module required")
end
# This makes is_a? return true for T::InterfaceWrapper (and its ancestors),
# as well as for @interface_mod and its ancestors.
self.class <= other || @interface_mod <= other
end
# Prefixed because we're polluting the namespace of the interface we're wrapping, and we don't
# want anyone else (besides dynamic_cast) calling it.
def __target_obj_DO_NOT_USE # rubocop:disable Naming/MethodName
@target_obj
end
# Prefixed because we're polluting the namespace of the interface we're wrapping, and we don't
# want anyone else (besides wrapped_dynamic_cast) calling it.
def __interface_mod_DO_NOT_USE # rubocop:disable Naming/MethodName
@interface_mod
end
# "Cast" an object to another type. If `obj` is an InterfaceWrapper, returns the the wrapped
# object if that matches `type`. Otherwise, returns `obj` if it matches `type`. Otherwise,
# returns nil.
#
# @param obj [Object] object to cast
# @param mod [Module] type to cast `obj` to
#
# @example
# if (impl = T::InterfaceWrapper.dynamic_cast(iface, MyImplementation))
# impl.do_things
# end
def self.dynamic_cast(obj, mod)
if obj.is_a?(T::InterfaceWrapper)
target_obj = obj.__target_obj_DO_NOT_USE
target_obj.is_a?(mod) ? target_obj : nil
elsif obj.is_a?(mod)
obj
else
nil
end
end
# Like dynamic_cast, but puts the result in its own wrapper if necessary.
#
# @param obj [Object] object to cast
# @param mod [Module] type to cast `obj` to
def self.wrapped_dynamic_cast(obj, mod)
# Avoid unwrapping and creating an equivalent wrapper.
if obj.is_a?(T::InterfaceWrapper) && obj.__interface_mod_DO_NOT_USE == mod
return obj
end
cast_obj = dynamic_cast(obj, mod)
if cast_obj.nil?
nil
elsif cast_obj.class == mod
# Nothing to wrap, they want the full class
cast_obj
else
new(cast_obj, mod)
end
end
def self.self_methods
@self_methods ||= self.instance_methods(false).to_set
end
end

View File

@ -0,0 +1,65 @@
# frozen_string_literal: true
# typed: strict
module T::NonForcingConstants
# NOTE: This method is documented on the RBI in Sorbet's payload, so that it
# shows up in the hover/completion documentation via LSP.
T::Sig::WithoutRuntime.sig {params(val: BasicObject, klass: String, package: T.nilable(String)).returns(T::Boolean)}
def self.non_forcing_is_a?(val, klass, package: nil)
method_name = "T::NonForcingConstants.non_forcing_is_a?"
if klass.empty?
raise ArgumentError.new("The string given to `#{method_name}` must not be empty")
end
# We don't treat packages differently at runtime, but the static
# type-checker still needs to have the package and constant
# separated out. This just re-assembles the string as needed
if !package.nil?
klass = "::#{package}::#{klass}"
end
current_klass = T.let(nil, T.nilable(Module))
current_prefix = T.let(nil, T.nilable(String))
parts = klass.split('::')
parts.each do |part|
if current_klass.nil?
# First iteration
if part != "" && package.nil?
# if we've supplied a package, we're probably running in
# package mode, which means absolute references are
# meaningless
raise ArgumentError.new("The string given to `#{method_name}` must be an absolute constant reference that starts with `::`")
end
current_klass = Object
current_prefix = ''
# if this had a :: prefix, then there's no more loading to
# do---skip to the next one
next if part == ""
end
if current_klass.autoload?(part)
# There's an autoload registered for that constant, which means it's not
# yet loaded. `value` can't be an instance of something not yet loaded.
return false
end
# Sorbet guarantees that the string is an absolutely resolved name.
search_inheritance_chain = false
if !current_klass.const_defined?(part, search_inheritance_chain)
return false
end
current_klass = current_klass.const_get(part)
current_prefix = "#{current_prefix}::#{part}"
if !Module.===(current_klass)
raise ArgumentError.new("#{current_prefix} is not a class or module")
end
end
current_klass.===(val)
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
# typed: true
# We need to associate data with abstract modules. We could add instance methods to them that
# access ivars, but those methods will unnecessarily pollute the module namespace, and they'd
# have access to other private state and methods that they don't actually need. We also need to
# associate data with arbitrary classes/modules that implement abstract mixins, where we don't
# control the interface at all. So, we access data via these `get` and `set` methods.
#
# Using instance_variable_get/set here is gross, but the alternative is to use a hash keyed on
# `mod`, and we can't trust that arbitrary modules can be added to those, because there are lurky
# modules that override the `hash` method with something completely broken.
module T::Private::Abstract::Data
def self.get(mod, key)
mod.instance_variable_get("@opus_abstract__#{key}") if key?(mod, key)
end
def self.set(mod, key, value)
mod.instance_variable_set("@opus_abstract__#{key}", value)
end
def self.key?(mod, key)
mod.instance_variable_defined?("@opus_abstract__#{key}")
end
# Works like `setdefault` in Python. If key has already been set, return its value. If not,
# insert `key` with a value of `default` and return `default`.
def self.set_default(mod, key, default)
if self.key?(mod, key)
self.get(mod, key)
else
self.set(mod, key, default)
default
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
# typed: true
module T::Private::Abstract::Declare
Abstract = T::Private::Abstract
AbstractUtils = T::AbstractUtils
def self.declare_abstract(mod, type:)
if AbstractUtils.abstract_module?(mod)
raise "#{mod} is already declared as abstract"
end
if T::Private::Final.final_module?(mod)
raise "#{mod} was already declared as final and cannot be declared as abstract"
end
Abstract::Data.set(mod, :can_have_abstract_methods, true)
Abstract::Data.set(mod.singleton_class, :can_have_abstract_methods, true)
Abstract::Data.set(mod, :abstract_type, type)
mod.extend(Abstract::Hooks)
mod.extend(T::InterfaceWrapper::Helpers)
if mod.is_a?(Class)
if type == :interface
# Since `interface!` is just `abstract!` with some extra validation, we could technically
# allow this, but it's unclear there are good use cases, and it might be confusing.
raise "Classes can't be interfaces. Use `abstract!` instead of `interface!`."
end
if mod.instance_method(:initialize).owner == mod
raise "You must call `abstract!` *before* defining an initialize method"
end
# Don't need to silence warnings via without_ruby_warnings when calling
# define_method because of the guard above
mod.send(:define_method, :initialize) do |*args, &blk|
if self.class == mod
raise "#{mod} is declared as abstract; it cannot be instantiated"
end
super(*args, &blk)
end
# Ruby doesn not emit "method redefined" warnings for aliased methods
# (more robust than undef_method that would create a small window in which the method doesn't exist)
mod.send(:alias_method, :initialize, :initialize)
if mod.respond_to?(:ruby2_keywords, true)
mod.send(:ruby2_keywords, :initialize)
end
end
end
end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
# typed: true
module T::Private::Abstract::Hooks
# This will become the self.extend_object method on a module that extends Abstract::Hooks.
# It gets called when *that* module gets extended in another class/module (similar to the
# `extended` hook, but this gets calls before the ancestors of `other` get modified, which
# is important for our validation).
private def extend_object(other)
T::Private::Abstract::Data.set(self, :last_used_by, other)
super
end
# This will become the self.append_features method on a module that extends Abstract::Hooks.
# It gets called when *that* module gets included in another class/module (similar to the
# `included` hook, but this gets calls before the ancestors of `other` get modified, which
# is important for our validation).
private def append_features(other)
T::Private::Abstract::Data.set(self, :last_used_by, other)
super
end
# This will become the self.inherited method on a class that extends Abstract::Hooks.
# It gets called when *that* class gets inherited by another class.
private def inherited(other)
super
# `self` may not actually be abstract -- it could be a concrete class that inherited from an
# abstract class. We only need to check this in `inherited` because, for modules being included
# or extended, the concrete ones won't have these hooks at all. This is just an optimization.
return if !T::AbstractUtils.abstract_module?(self)
T::Private::Abstract::Data.set(self, :last_used_by, other)
end
# This will become the self.prepended method on a module that extends Abstract::Hooks.
# It will get called when *that* module gets prepended in another class/module.
private def prepended(other)
# Prepending abstract methods is weird. You'd only be able to override them via other prepended
# modules, or in subclasses. Punt until we have a use case.
Kernel.raise "Prepending abstract mixins is not currently supported."
end
end

View File

@ -0,0 +1,128 @@
# frozen_string_literal: true
# typed: true
module T::Private::Abstract::Validate
Abstract = T::Private::Abstract
AbstractUtils = T::AbstractUtils
Methods = T::Private::Methods
SignatureValidation = T::Private::Methods::SignatureValidation
def self.validate_abstract_module(mod)
type = Abstract::Data.get(mod, :abstract_type)
validate_interface(mod) if type == :interface
end
# Validates a class/module with an abstract class/module as an ancestor. This must be called
# after all methods on `mod` have been defined.
def self.validate_subclass(mod)
can_have_abstract_methods = !T::Private::Abstract::Data.get(mod, :can_have_abstract_methods)
unimplemented_methods = []
T::AbstractUtils.declared_abstract_methods_for(mod).each do |abstract_method|
implementation_method = mod.instance_method(abstract_method.name)
if AbstractUtils.abstract_method?(implementation_method)
# Note that when we end up here, implementation_method might not be the same as
# abstract_method; the latter could've been overridden by another abstract method. In either
# case, if we have a concrete definition in an ancestor, that will end up as the effective
# implementation (see CallValidation.wrap_method_if_needed), so that's what we'll validate
# against.
implementation_method = T.unsafe(nil)
mod.ancestors.each do |ancestor|
if ancestor.instance_methods.include?(abstract_method.name)
method = ancestor.instance_method(abstract_method.name)
T::Private::Methods.maybe_run_sig_block_for_method(method)
if !T::AbstractUtils.abstract_method?(method)
implementation_method = method
break
end
end
end
if !implementation_method
# There's no implementation
if can_have_abstract_methods
unimplemented_methods << describe_method(abstract_method)
end
next # Nothing to validate
end
end
implementation_signature = Methods.signature_for_method(implementation_method)
# When a signature exists and the method is defined directly on `mod`, we skip the validation
# here, because it will have already been done when the method was defined (by
# T::Private::Methods._on_method_added).
next if implementation_signature&.owner == mod
# We validate the remaining cases here: (a) methods defined directly on `mod` without a
# signature and (b) methods from ancestors (note that these ancestors can come before or
# after the abstract module in the inheritance chain -- the former coming from
# walking `mod.ancestors` above).
abstract_signature = Methods.signature_for_method(abstract_method)
# We allow implementation methods to be defined without a signature.
# In that case, get its untyped signature.
implementation_signature ||= Methods::Signature.new_untyped(
method: implementation_method,
mode: Methods::Modes.override
)
SignatureValidation.validate_override_shape(implementation_signature, abstract_signature)
SignatureValidation.validate_override_types(implementation_signature, abstract_signature)
end
method_type = mod.singleton_class? ? "class" : "instance"
if !unimplemented_methods.empty?
raise "Missing implementation for abstract #{method_type} method(s) in #{mod}:\n" \
"#{unimplemented_methods.join("\n")}\n" \
"If #{mod} is meant to be an abstract class/module, you can call " \
"`abstract!` or `interface!`. Otherwise, you must implement the method(s)."
end
end
private_class_method def self.validate_interface_all_abstract(mod, method_names)
violations = method_names.map do |method_name|
method = mod.instance_method(method_name)
if !AbstractUtils.abstract_method?(method)
describe_method(method, show_owner: false)
end
end.compact
if !violations.empty?
raise "`#{mod}` is declared as an interface, but the following methods are not declared " \
"with `abstract`:\n#{violations.join("\n")}"
end
end
private_class_method def self.validate_interface(mod)
interface_methods = T::Utils.methods_excluding_object(mod)
validate_interface_all_abstract(mod, interface_methods)
validate_interface_all_public(mod, interface_methods)
end
private_class_method def self.validate_interface_all_public(mod, method_names)
violations = method_names.map do |method_name|
if !mod.public_method_defined?(method_name)
describe_method(mod.instance_method(method_name), show_owner: false)
end
end.compact
if !violations.empty?
raise "All methods on an interface must be public. If you intend to have non-public " \
"methods, declare your class/module using `abstract!` instead of `interface!`. " \
"The following methods on `#{mod}` are not public: \n#{violations.join("\n")}"
end
end
private_class_method def self.describe_method(method, show_owner: true)
loc = if method.source_location
method.source_location.join(':')
else
"<unknown location>"
end
owner = if show_owner
" declared in #{method.owner}"
else
""
end
" * `#{method.name}`#{owner} at #{loc}"
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
# typed: false
module T::Private
module Casts
def self.cast(value, type, cast_method:)
begin
error = T::Utils.coerce(type).error_message_for_obj(value)
return value unless error
caller_loc = T.must(caller_locations(2..2)).first
suffix = "Caller: #{T.must(caller_loc).path}:#{T.must(caller_loc).lineno}"
raise TypeError.new("#{cast_method}: #{error}\n#{suffix}")
rescue TypeError => e # raise into rescue to ensure e.backtrace is populated
T::Configuration.inline_type_error_handler(e, {kind: cast_method, value: value, type: type})
value
end
end
# there's a lot of shared logic with the above one, but factoring
# it out like this makes it easier to hopefully one day delete
# this one
def self.cast_recursive(value, type, cast_method:)
begin
error = T::Utils.coerce(type).error_message_for_obj_recursive(value)
return value unless error
caller_loc = T.must(caller_locations(2..2)).first
suffix = "Caller: #{T.must(caller_loc).path}:#{T.must(caller_loc).lineno}"
raise TypeError.new("#{cast_method}: #{error}\n#{suffix}")
rescue TypeError => e # raise into rescue to ensure e.backtrace is populated
T::Configuration.inline_type_error_handler(e, {kind: cast_method, value: value, type: type})
value
end
end
end
end

View File

@ -0,0 +1,110 @@
# frozen_string_literal: true
# typed: false
# Cut down version of Chalk::Tools::ClassUtils with only :replace_method functionality.
# Extracted to a separate namespace so the type system can be used standalone.
module T::Private::ClassUtils
class ReplacedMethod
def initialize(mod, old_method, new_method, overwritten, visibility)
if old_method.name != new_method.name
raise "Method names must match. old=#{old_method.name} new=#{new_method.name}"
end
@mod = mod
@old_method = old_method
@new_method = new_method
@overwritten = overwritten
@name = old_method.name
@visibility = visibility
@restored = false
end
def restore
# The check below would also catch this, but this makes the failure mode much clearer
if @restored
raise "Method '#{@name}' on '#{@mod}' was already restored"
end
if @mod.instance_method(@name) != @new_method
raise "Trying to restore #{@mod}##{@name} but the method has changed since the call to replace_method"
end
@restored = true
if @overwritten
# The original method was overwritten. Overwrite again to restore it.
T::Configuration.without_ruby_warnings do
@mod.send(:define_method, @old_method.name, @old_method)
end
else
# The original method was in an ancestor. Restore it by removing the overriding method.
@mod.send(:remove_method, @old_method.name)
end
# Restore the visibility. Note that we need to do this even when we call remove_method
# above, because the module may have set custom visibility for a method it inherited.
@mod.send(@visibility, @old_method.name)
nil
end
def bind(obj)
@old_method.bind(obj)
end
def to_s
@old_method.to_s
end
end
# `name` must be an instance method (for class methods, pass in mod.singleton_class)
private_class_method def self.visibility_method_name(mod, name)
if mod.public_method_defined?(name)
:public
elsif mod.protected_method_defined?(name)
:protected
elsif mod.private_method_defined?(name)
:private
else
raise NameError.new("undefined method `#{name}` for `#{mod}`")
end
end
# Replaces a method, either by overwriting it (if it is defined directly on `mod`) or by
# overriding it (if it is defined by one of mod's ancestors). Returns a ReplacedMethod instance
# on which you can call `bind(...).call(...)` to call the original method, or `restore` to
# restore the original method (by overwriting or removing the override).
def self.replace_method(mod, name, &blk)
original_method = mod.instance_method(name)
original_visibility = visibility_method_name(mod, name)
original_owner = original_method.owner
mod.ancestors.each do |ancestor|
break if ancestor == mod
if ancestor == original_owner
# If we get here, that means the method we're trying to replace exists on a *prepended*
# mixin, which means in order to supersede it, we'd need to create a method on a new
# module that we'd prepend before `ancestor`. The problem with that approach is there'd
# be no way to remove that new module after prepending it, so we'd be left with these
# empty anonymous modules in the ancestor chain after calling `restore`.
#
# That's not necessarily a deal breaker, but for now, we're keeping it as unsupported.
raise "You're trying to replace `#{name}` on `#{mod}`, but that method exists in a " \
"prepended module (#{ancestor}), which we don't currently support."
end
end
overwritten = original_owner == mod
T::Configuration.without_ruby_warnings do
T::Private::DeclState.current.without_on_method_added do
mod.send(:define_method, name, &blk)
if blk.arity < 0 && mod.respond_to?(:ruby2_keywords, true)
mod.send(:ruby2_keywords, name)
end
end
end
mod.send(original_visibility, name)
new_method = mod.instance_method(name)
ReplacedMethod.new(mod, original_method, new_method, overwritten, original_visibility)
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
# typed: true
module T::Private
module Compiler
# If this code ever runs, the caller is running interpreted (or the
# compiler didn't see the call to `running_compiled?` statically.)
#
# The Sorbet Compiler replaces calls to this method unconditionally (no
# runtime guards) to return `true` when compiling a file.
def self.running_compiled?
false
end
# Returns `nil` because the compiler isn't running.
#
# The Sorbet Compiler replaces calls to this method unconditionally (no
# runtime guards) to return a String showing the Sorbet Compiler's version
# string.
def self.compiler_version
nil
end
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
# typed: true
class T::Private::DeclState
def self.current
Thread.current[:opus_types__decl_state] ||= self.new
end
def self.current=(other)
Thread.current[:opus_types__decl_state] = other
end
attr_accessor :active_declaration
attr_accessor :skip_on_method_added
def reset!
self.active_declaration = nil
end
def without_on_method_added
begin
# explicit 'self' is needed here
old_value = self.skip_on_method_added
self.skip_on_method_added = true
yield
ensure
self.skip_on_method_added = old_value
end
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# typed: false
module T::Private::Final
module NoInherit
def inherited(arg)
super(arg)
raise "#{self} was declared as final and cannot be inherited"
end
end
module NoIncludeExtend
def included(arg)
super(arg)
raise "#{self} was declared as final and cannot be included"
end
def extended(arg)
super(arg)
raise "#{self} was declared as final and cannot be extended"
end
end
def self.declare(mod)
if !mod.is_a?(Module)
raise "#{mod} is not a class or module and cannot be declared as final with `final!`"
end
if final_module?(mod)
raise "#{mod} was already declared as final and cannot be re-declared as final"
end
if T::AbstractUtils.abstract_module?(mod)
raise "#{mod} was already declared as abstract and cannot be declared as final"
end
if T::Private::Sealed.sealed_module?(mod)
raise "#{mod} was already declared as sealed and cannot be declared as final"
end
mod.extend(mod.is_a?(Class) ? NoInherit : NoIncludeExtend)
mark_as_final_module(mod)
mark_as_final_module(mod.singleton_class)
T::Private::Methods.install_hooks(mod)
end
def self.final_module?(mod)
mod.instance_variable_defined?(:@sorbet_final_module)
end
private_class_method def self.mark_as_final_module(mod)
mod.instance_variable_set(:@sorbet_final_module, true)
end
end

View File

@ -0,0 +1,581 @@
# frozen_string_literal: true
# typed: false
module T::Private::Methods
@installed_hooks = {}
@signatures_by_method = {}
@sig_wrappers = {}
@sigs_that_raised = {}
# stores method names that were declared final without regard for where.
# enables early rejection of names that we know can't induce final method violations.
@was_ever_final_names = {}
# maps from a module's object_id to the set of final methods declared in that module.
# we also overload entries slightly: if the value is nil, that means that the
# module has final methods somewhere along its ancestor chain, but does not itself
# have any final methods.
#
# we need the latter information to know whether we need to check along the ancestor
# chain for final method violations. we need the former information because we
# care about exactly where a final method is defined (e.g. including the same module
# twice is permitted). we could do this with two tables, but it seems slightly
# cleaner with a single table.
# Effectively T::Hash[Module, T.nilable(Set))]
@modules_with_final = Hash.new {|hash, key| hash[key] = nil}
# this stores the old [included, extended] hooks for Module and inherited hook for Class that we override when
# enabling final checks for when those hooks are called. the 'hooks' here don't have anything to do with the 'hooks'
# in installed_hooks.
@old_hooks = nil
ARG_NOT_PROVIDED = Object.new
PROC_TYPE = Object.new
DeclarationBlock = Struct.new(:mod, :loc, :blk, :final, :raw)
def self.declare_sig(mod, loc, arg, &blk)
T::Private::DeclState.current.active_declaration = _declare_sig_internal(mod, loc, arg, &blk)
nil
end
# See tests for how to use this. But you shouldn't be using this.
def self._declare_sig(mod, arg=nil, &blk)
_declare_sig_internal(mod, caller_locations(1, 1).first, arg, raw: true, &blk)
end
private_class_method def self._declare_sig_internal(mod, loc, arg, raw: false, &blk)
install_hooks(mod)
if T::Private::DeclState.current.active_declaration
T::Private::DeclState.current.reset!
raise "You called sig twice without declaring a method in between"
end
if !arg.nil? && arg != :final
raise "Invalid argument to `sig`: #{arg}"
end
DeclarationBlock.new(mod, loc, blk, arg == :final, raw)
end
def self._with_declared_signature(mod, declblock, &blk)
# If declblock is provided, this code is equivalent to the check in
# _declare_sig_internal, above.
# If declblock is not provided and we have an active declaration, we are
# obviously doing something wrong.
if T::Private::DeclState.current.active_declaration
T::Private::DeclState.current.reset!
raise "You called sig twice without declaring a method in between"
end
if declblock
T::Private::DeclState.current.active_declaration = declblock
end
mod.module_exec(&blk)
end
def self.start_proc
DeclBuilder.new(PROC_TYPE, false)
end
def self.finalize_proc(decl)
decl.finalized = true
if decl.mode != Modes.standard
raise "Procs cannot have override/abstract modifiers"
end
if decl.mod != PROC_TYPE
raise "You are passing a DeclBuilder as a type. Did you accidentally use `self` inside a `sig` block?"
end
if decl.returns == ARG_NOT_PROVIDED
raise "Procs must specify a return type"
end
if decl.on_failure != ARG_NOT_PROVIDED
raise "Procs cannot use .on_failure"
end
if decl.params == ARG_NOT_PROVIDED
decl.params = {}
end
T::Types::Proc.new(decl.params, decl.returns)
end
# Returns the signature for a method whose definition was preceded by `sig`.
#
# @param method [UnboundMethod]
# @return [T::Private::Methods::Signature]
def self.signature_for_method(method)
signature_for_key(method_to_key(method))
end
private_class_method def self.signature_for_key(key)
maybe_run_sig_block_for_key(key)
# If a subclass Sub inherits a method `foo` from Base, then
# Sub.instance_method(:foo) != Base.instance_method(:foo) even though they resolve to the
# same method. Similarly, Foo.method(:bar) != Foo.singleton_class.instance_method(:bar).
# So, we always do the look up by the method on the owner (Base in this example).
@signatures_by_method[key]
end
# when target includes a module with instance methods source_method_names, ensure there is zero intersection between
# the final instance methods of target and source_method_names. so, for every m in source_method_names, check if there
# is already a method defined on one of target_ancestors with the same name that is final.
#
# we assume that source_method_names has already been filtered to only include method
# names that were declared final at one point.
def self._check_final_ancestors(target, target_ancestors, source_method_names, source)
source_ancestors = nil
# use reverse_each to check farther-up ancestors first, for better error messages.
target_ancestors.reverse_each do |ancestor|
final_methods = @modules_with_final.fetch(ancestor.object_id, nil)
# In this case, either ancestor didn't have any final methods anywhere in its
# ancestor chain, or ancestor did have final methods somewhere in its ancestor
# chain, but no final methods defined in ancestor itself. Either way, there
# are no final methods to check here, so we can move on to the next ancestor.
next unless final_methods
source_method_names.each do |method_name|
next unless final_methods.include?(method_name)
# If we get here, we are defining a method that some ancestor declared as
# final. however, we permit a final method to be defined multiple
# times if it is the same final method being defined each time.
if source
if !source_ancestors
source_ancestors = source.ancestors
# filter out things without actual final methods just to make sure that
# the below checks (which should be uncommon) go as quickly as possible.
source_ancestors.select! do |a|
@modules_with_final.fetch(a.object_id, nil)
end
end
# final-ness means that there should be no more than one index for which
# the below block returns true.
defining_ancestor_idx = source_ancestors.index do |a|
@modules_with_final.fetch(a.object_id).include?(method_name)
end
next if defining_ancestor_idx && source_ancestors[defining_ancestor_idx] == ancestor
end
definition_file, definition_line = T::Private::Methods.signature_for_method(ancestor.instance_method(method_name)).method.source_location
is_redefined = target == ancestor
caller_loc = caller_locations&.find {|l| !l.to_s.match?(%r{sorbet-runtime[^/]*/lib/})}
extra_info = "\n"
if caller_loc
extra_info = (is_redefined ? "Redefined" : "Overridden") + " here: #{caller_loc.path}:#{caller_loc.lineno}\n"
end
error_message = "The method `#{method_name}` on #{ancestor} was declared as final and cannot be " +
(is_redefined ? "redefined" : "overridden in #{target}")
pretty_message = "#{error_message}\n" \
"Made final here: #{definition_file}:#{definition_line}\n" \
"#{extra_info}"
begin
raise pretty_message
rescue => e
# sig_validation_error_handler raises by default; on the off chance that
# it doesn't raise, we need to ensure that the rest of signature building
# sees a consistent state. This sig failed to validate, so we should get
# rid of it. If we don't do this, errors of the form "You called sig
# twice without declaring a method in between" will non-deterministically
# crop up in tests.
T::Private::DeclState.current.reset!
T::Configuration.sig_validation_error_handler(e, {})
end
end
end
end
def self.add_module_with_final_method(mod, method_name, is_singleton_method)
m = is_singleton_method ? mod.singleton_class : mod
mid = m.object_id
methods = @modules_with_final[mid]
if methods.nil?
methods = {}
@modules_with_final[mid] = methods
end
methods[method_name] = true
nil
end
def self.note_module_deals_with_final(mod)
# Side-effectfully initialize the value if it's not already there
@modules_with_final[mod.object_id]
@modules_with_final[mod.singleton_class.object_id]
end
# Only public because it needs to get called below inside the replace_method blocks below.
def self._on_method_added(hook_mod, method_name, is_singleton_method: false)
if T::Private::DeclState.current.skip_on_method_added
return
end
current_declaration = T::Private::DeclState.current.active_declaration
mod = is_singleton_method ? hook_mod.singleton_class : hook_mod
if T::Private::Final.final_module?(mod) && (current_declaration.nil? || !current_declaration.final)
raise "#{mod} was declared as final but its method `#{method_name}` was not declared as final"
end
# Don't compute mod.ancestors if we don't need to bother checking final-ness.
if @was_ever_final_names.include?(method_name) && @modules_with_final.include?(mod.object_id)
_check_final_ancestors(mod, mod.ancestors, [method_name], nil)
# We need to fetch the active declaration again, as _check_final_ancestors
# may have reset it (see the comment in that method for details).
current_declaration = T::Private::DeclState.current.active_declaration
end
if current_declaration.nil?
return
end
T::Private::DeclState.current.reset!
if method_name == :method_added || method_name == :singleton_method_added
raise(
"Putting a `sig` on `#{method_name}` is not supported" \
" (sorbet-runtime uses this method internally to perform `sig` validation logic)"
)
end
original_method = mod.instance_method(method_name)
sig_block = lambda do
T::Private::Methods.run_sig(hook_mod, method_name, original_method, current_declaration)
end
# Always replace the original method with this wrapper,
# which is called only on the *first* invocation.
# This wrapper is very slow, so it will subsequently re-wrap with a much faster wrapper
# (or unwrap back to the original method).
key = method_owner_and_name_to_key(mod, method_name)
unless current_declaration.raw
T::Private::ClassUtils.replace_method(mod, method_name) do |*args, &blk|
method_sig = T::Private::Methods.maybe_run_sig_block_for_key(key)
method_sig ||= T::Private::Methods._handle_missing_method_signature(
self,
original_method,
__callee__,
)
# Should be the same logic as CallValidation.wrap_method_if_needed but we
# don't want that extra layer of indirection in the callstack
if method_sig.mode == T::Private::Methods::Modes.abstract
# We're in an interface method, keep going up the chain
if defined?(super)
super(*args, &blk)
else
raise NotImplementedError.new("The method `#{method_sig.method_name}` on #{mod} is declared as `abstract`. It does not have an implementation.")
end
# Note, this logic is duplicated (intentionally, for micro-perf) at `CallValidation.wrap_method_if_needed`,
# make sure to keep changes in sync.
elsif method_sig.check_level == :always || (method_sig.check_level == :tests && T::Private::RuntimeLevels.check_tests?)
CallValidation.validate_call(self, original_method, method_sig, args, blk)
elsif T::Configuration::AT_LEAST_RUBY_2_7
original_method.bind_call(self, *args, &blk)
else
original_method.bind(self).call(*args, &blk)
end
end
end
@sig_wrappers[key] = sig_block
if current_declaration.final
@was_ever_final_names[method_name] = true
# use hook_mod, not mod, because for example, we want class C to be marked as having final if we def C.foo as
# final. change this to mod to see some final_method tests fail.
note_module_deals_with_final(hook_mod)
add_module_with_final_method(hook_mod, method_name, is_singleton_method)
end
end
def self._handle_missing_method_signature(receiver, original_method, callee)
method_sig = T::Private::Methods.signature_for_method(original_method)
if !method_sig
raise "`sig` not present for method `#{callee}` on #{receiver.inspect} but you're trying to run it anyways. " \
"This should only be executed if you used `alias_method` to grab a handle to a method after `sig`ing it, but that clearly isn't what you are doing. " \
"Maybe look to see if an exception was thrown in your `sig` lambda or somehow else your `sig` wasn't actually applied to the method."
end
if receiver.class <= original_method.owner
receiving_class = receiver.class
elsif receiver.singleton_class <= original_method.owner
receiving_class = receiver.singleton_class
elsif receiver.is_a?(Module) && receiver <= original_method.owner
receiving_class = receiver
else
raise "#{receiver} is not related to #{original_method} - how did we get here?"
end
# Check for a case where `alias` or `alias_method` was called for a
# method which had already had a `sig` applied. In that case, we want
# to avoid hitting this slow path again, by moving to a faster validator
# just like we did or will for the original method.
#
# If this isn't an `alias` or `alias_method` case, we're probably in the
# middle of some metaprogramming using a Method object, e.g. a pattern like
# `arr.map(&method(:foo))`. There's nothing really we can do to optimize
# that here.
receiving_method = receiving_class.instance_method(callee)
if receiving_method != original_method && receiving_method.original_name == original_method.name
aliasing_mod = receiving_method.owner
method_sig = method_sig.as_alias(callee)
unwrap_method(aliasing_mod, method_sig, original_method)
end
method_sig
end
# Executes the `sig` block, and converts the resulting Declaration
# to a Signature.
def self.run_sig(hook_mod, method_name, original_method, declaration_block)
current_declaration =
begin
run_builder(declaration_block)
rescue DeclBuilder::BuilderError => e
T::Configuration.sig_builder_error_handler(e, declaration_block.loc)
nil
end
signature =
if current_declaration
build_sig(hook_mod, method_name, original_method, current_declaration, declaration_block.loc)
else
Signature.new_untyped(method: original_method)
end
unwrap_method(signature.method.owner, signature, original_method)
signature
end
def self.run_builder(declaration_block)
builder = DeclBuilder.new(declaration_block.mod, declaration_block.raw)
builder
.instance_exec(&declaration_block.blk)
.finalize!
.decl
end
def self.build_sig(hook_mod, method_name, original_method, current_declaration, loc)
begin
# We allow `sig` in the current module's context (normal case) and
if hook_mod != current_declaration.mod &&
# inside `class << self`, and
hook_mod.singleton_class != current_declaration.mod &&
# on `self` at the top level of a file
current_declaration.mod != TOP_SELF
raise "A method (#{method_name}) is being added on a different class/module (#{hook_mod}) than the " \
"last call to `sig` (#{current_declaration.mod}). Make sure each call " \
"to `sig` is immediately followed by a method definition on the same " \
"class/module."
end
signature = Signature.new(
method: original_method,
method_name: method_name,
raw_arg_types: current_declaration.params,
raw_return_type: current_declaration.returns,
bind: current_declaration.bind,
mode: current_declaration.mode,
check_level: current_declaration.checked,
on_failure: current_declaration.on_failure,
override_allow_incompatible: current_declaration.override_allow_incompatible,
defined_raw: current_declaration.raw,
)
SignatureValidation.validate(signature)
signature
rescue => e
super_method = original_method&.super_method
super_signature = signature_for_method(super_method) if super_method
T::Configuration.sig_validation_error_handler(
e,
method: original_method,
declaration: current_declaration,
signature: signature,
super_signature: super_signature
)
Signature.new_untyped(method: original_method)
end
end
def self.unwrap_method(mod, signature, original_method)
maybe_wrapped_method = CallValidation.wrap_method_if_needed(mod, signature, original_method)
@signatures_by_method[method_to_key(maybe_wrapped_method)] = signature
end
def self.has_sig_block_for_method(method)
has_sig_block_for_key(method_to_key(method))
end
private_class_method def self.has_sig_block_for_key(key)
@sig_wrappers.key?(key)
end
def self.maybe_run_sig_block_for_method(method)
maybe_run_sig_block_for_key(method_to_key(method))
end
# Only public so that it can be accessed in the closure for _on_method_added
def self.maybe_run_sig_block_for_key(key)
run_sig_block_for_key(key) if has_sig_block_for_key(key)
end
def self.run_sig_block_for_method(method)
run_sig_block_for_key(method_to_key(method))
end
private_class_method def self.run_sig_block_for_key(key)
blk = @sig_wrappers[key]
if !blk
sig = @signatures_by_method[key]
if sig
# We already ran the sig block, perhaps in another thread.
return sig
else
raise "No `sig` wrapper for #{key_to_method(key)}"
end
end
begin
sig = blk.call
rescue
@sigs_that_raised[key] = true
raise
end
if @sigs_that_raised[key]
raise "A previous invocation of #{key_to_method(key)} raised, and the current one succeeded. Please don't do that."
end
@sig_wrappers.delete(key)
sig
end
def self.run_all_sig_blocks
loop do
break if @sig_wrappers.empty?
key, = @sig_wrappers.first
run_sig_block_for_key(key)
end
end
def self.all_checked_tests_sigs
@signatures_by_method.values.select {|sig| sig.check_level == :tests}
end
# the module target is adding the methods from the module source to itself. we need to check that for all instance
# methods M on source, M is not defined on any of target's ancestors.
def self._hook_impl(target, singleton_class, source)
# we do not need to call add_was_ever_final here, because we have already marked
# any such methods when source was originally defined.
if !@modules_with_final.include?(target.object_id)
if !@modules_with_final.include?(source.object_id)
return
end
note_module_deals_with_final(target)
install_hooks(target)
return
end
methods = source.instance_methods
methods.select! do |method_name|
@was_ever_final_names.include?(method_name)
end
if methods.empty?
return
end
target_ancestors = singleton_class ? target.singleton_class.ancestors : target.ancestors
_check_final_ancestors(target, target_ancestors, methods, source)
end
def self.set_final_checks_on_hooks(enable)
is_enabled = !@old_hooks.nil?
if enable == is_enabled
return
end
if is_enabled
@old_hooks.each(&:restore)
@old_hooks = nil
else
old_included = T::Private::ClassUtils.replace_method(Module, :included) do |arg|
old_included.bind(self).call(arg)
::T::Private::Methods._hook_impl(arg, false, self)
end
old_extended = T::Private::ClassUtils.replace_method(Module, :extended) do |arg|
old_extended.bind(self).call(arg)
::T::Private::Methods._hook_impl(arg, true, self)
end
old_inherited = T::Private::ClassUtils.replace_method(Class, :inherited) do |arg|
old_inherited.bind(self).call(arg)
::T::Private::Methods._hook_impl(arg, false, self)
end
@old_hooks = [old_included, old_extended, old_inherited]
end
end
module MethodHooks
def method_added(name)
super(name)
::T::Private::Methods._on_method_added(self, name, is_singleton_method: false)
end
end
module SingletonMethodHooks
def singleton_method_added(name)
super(name)
::T::Private::Methods._on_method_added(self, name, is_singleton_method: true)
end
end
def self.install_hooks(mod)
return if @installed_hooks.include?(mod)
@installed_hooks[mod] = true
if mod == TOP_SELF
# self at the top-level of a file is weirdly special in Ruby
# The Ruby VM on startup creates an `Object.new` and stashes it.
# Unlike when we're using sig inside a module, `self` is actually a
# normal object, not an instance of Module.
#
# Thus we can't ask things like mod.singleton_class? (since that's
# defined only on Module, not on Object) and even if we could, the places
# where we need to install the hooks are special.
mod.extend(SingletonMethodHooks) # def self.foo; end (at top level)
Object.extend(MethodHooks) # def foo; end (at top level)
return
end
# See https://github.com/sorbet/sorbet/pull/3964 for an explanation of why this
# check (which theoretically should not be needed) is actually needed.
if !mod.is_a?(Module)
return
end
if mod.singleton_class?
mod.include(SingletonMethodHooks)
else
mod.extend(MethodHooks)
end
mod.extend(SingletonMethodHooks)
end
# use this directly if you don't want/need to box up the method into an object to pass to method_to_key.
private_class_method def self.method_owner_and_name_to_key(owner, name)
"#{owner.object_id}##{name}"
end
private_class_method def self.method_to_key(method)
method_owner_and_name_to_key(method.owner, method.name)
end
private_class_method def self.key_to_method(key)
id, name = key.split("#")
obj = ObjectSpace._id2ref(id.to_i)
obj.instance_method(name)
end
end
# This has to be here, and can't be nested inside `T::Private::Methods`,
# because the value of `self` depends on lexical (nesting) scope, and we
# specifically need a reference to the file-level self, i.e. `main:Object`
T::Private::Methods::TOP_SELF = self

View File

@ -0,0 +1,221 @@
# frozen_string_literal: true
# typed: false
module T::Private::Methods::CallValidation
CallValidation = T::Private::Methods::CallValidation
Modes = T::Private::Methods::Modes
# Wraps a method with a layer of validation for the given type signature.
# This wrapper is meant to be fast, and is applied by a previous wrapper,
# which was placed by `_on_method_added`.
#
# @param method_sig [T::Private::Methods::Signature]
# @return [UnboundMethod] the new wrapper method (or the original one if we didn't wrap it)
def self.wrap_method_if_needed(mod, method_sig, original_method)
original_visibility = visibility_method_name(mod, method_sig.method_name)
if method_sig.mode == T::Private::Methods::Modes.abstract
T::Private::ClassUtils.replace_method(mod, method_sig.method_name) do |*args, &blk|
# TODO: write a cop to ensure that abstract methods have an empty body
#
# We allow abstract methods to be implemented by things further down the ancestor chain.
# So, if a super method exists, call it.
if defined?(super)
super(*args, &blk)
else
raise NotImplementedError.new(
"The method `#{method_sig.method_name}` on #{mod} is declared as `abstract`. It does not have an implementation."
)
end
end
# Do nothing in this case; this method was not wrapped in _on_method_added.
elsif method_sig.defined_raw
# Note, this logic is duplicated (intentionally, for micro-perf) at `Methods._on_method_added`,
# make sure to keep changes in sync.
# This is a trapdoor point for each method:
# if a given method is wrapped, it stays wrapped; and if not, it's never wrapped.
# (Therefore, we need the `@wrapped_tests_with_validation` check in `T::RuntimeLevels`.)
elsif method_sig.check_level == :always || (method_sig.check_level == :tests && T::Private::RuntimeLevels.check_tests?)
create_validator_method(mod, original_method, method_sig, original_visibility)
else
T::Configuration.without_ruby_warnings do
# get all the shims out of the way and put back the original method
T::Private::DeclState.current.without_on_method_added do
mod.send(:define_method, method_sig.method_name, original_method)
end
mod.send(original_visibility, method_sig.method_name)
end
end
# Return the newly created method (or the original one if we didn't replace it)
mod.instance_method(method_sig.method_name)
end
@is_allowed_to_have_fast_path = true
def self.is_allowed_to_have_fast_path
@is_allowed_to_have_fast_path
end
def self.disable_fast_path
@is_allowed_to_have_fast_path = false
end
def self.create_validator_method(mod, original_method, method_sig, original_visibility)
has_fixed_arity = method_sig.kwarg_types.empty? && !method_sig.has_rest && !method_sig.has_keyrest &&
original_method.parameters.all? {|(kind, _name)| kind == :req}
ok_for_fast_path = has_fixed_arity && !method_sig.bind && method_sig.arg_types.length < 5 && is_allowed_to_have_fast_path
all_args_are_simple = ok_for_fast_path && method_sig.arg_types.all? {|_name, type| type.is_a?(T::Types::Simple)}
simple_method = all_args_are_simple && method_sig.return_type.is_a?(T::Types::Simple)
simple_procedure = all_args_are_simple && method_sig.return_type.is_a?(T::Private::Types::Void)
T::Configuration.without_ruby_warnings do
T::Private::DeclState.current.without_on_method_added do
if simple_method
create_validator_method_fast(mod, original_method, method_sig)
elsif simple_procedure
create_validator_procedure_fast(mod, original_method, method_sig)
elsif ok_for_fast_path && method_sig.return_type.is_a?(T::Private::Types::Void)
create_validator_procedure_medium(mod, original_method, method_sig)
elsif ok_for_fast_path
create_validator_method_medium(mod, original_method, method_sig)
else
create_validator_slow(mod, original_method, method_sig)
end
end
end
mod.send(original_visibility, method_sig.method_name)
end
def self.create_validator_slow(mod, original_method, method_sig)
mod.send(:define_method, method_sig.method_name) do |*args, &blk|
CallValidation.validate_call(self, original_method, method_sig, args, blk)
end
if mod.respond_to?(:ruby2_keywords, true)
mod.send(:ruby2_keywords, method_sig.method_name)
end
end
def self.validate_call(instance, original_method, method_sig, args, blk)
# This method is called for every `sig`. It's critical to keep it fast and
# reduce number of allocations that happen here.
if method_sig.bind
message = method_sig.bind.error_message_for_obj(instance)
if message
CallValidation.report_error(
method_sig,
message,
'Bind',
nil,
method_sig.bind,
instance
)
end
end
# NOTE: We don't bother validating for missing or extra kwargs;
# the method call itself will take care of that.
method_sig.each_args_value_type(args) do |name, arg, type|
message = type.error_message_for_obj(arg)
if message
CallValidation.report_error(
method_sig,
message,
'Parameter',
name,
type,
arg,
caller_offset: 2
)
end
end
if method_sig.block_type
message = method_sig.block_type.error_message_for_obj(blk)
if message
CallValidation.report_error(
method_sig,
message,
'Block parameter',
method_sig.block_name,
method_sig.block_type,
blk
)
end
end
# The following line breaks are intentional to show nice pry message
# PRY note:
# this code is sig validation code.
# Please issue `finish` to step out of it
return_value = T::Configuration::AT_LEAST_RUBY_2_7 ? original_method.bind_call(instance, *args, &blk) : original_method.bind(instance).call(*args, &blk)
# The only type that is allowed to change the return value is `.void`.
# It ignores what you returned and changes it to be a private singleton.
if method_sig.return_type.is_a?(T::Private::Types::Void)
T::Private::Types::Void::VOID
else
message = method_sig.return_type.error_message_for_obj(return_value)
if message
CallValidation.report_error(
method_sig,
message,
'Return value',
nil,
method_sig.return_type,
return_value,
)
end
return_value
end
end
def self.report_error(method_sig, error_message, kind, name, type, value, caller_offset: 0)
caller_loc = T.must(caller_locations(3 + caller_offset, 1))[0]
definition_file, definition_line = method_sig.method.source_location
pretty_message = "#{kind}#{name ? " '#{name}'" : ''}: #{error_message}\n" \
"Caller: #{caller_loc.path}:#{caller_loc.lineno}\n" \
"Definition: #{definition_file}:#{definition_line}"
T::Configuration.call_validation_error_handler(
method_sig,
message: error_message,
pretty_message: pretty_message,
kind: kind,
name: name,
type: type,
value: value,
location: caller_loc
)
end
# `name` must be an instance method (for class methods, pass in mod.singleton_class)
private_class_method def self.visibility_method_name(mod, name)
if mod.public_method_defined?(name)
:public
elsif mod.protected_method_defined?(name)
:protected
elsif mod.private_method_defined?(name)
:private
else
raise NameError.new("undefined method `#{name}` for `#{mod}`")
end
end
end
if T::Configuration::AT_LEAST_RUBY_2_7
require_relative './call_validation_2_7'
else
require_relative './call_validation_2_6'
end

View File

@ -0,0 +1,232 @@
# frozen_string_literal: true
# typed: true
module T::Private::Methods
Declaration = Struct.new(:mod, :params, :returns, :bind, :mode, :checked, :finalized, :on_failure, :override_allow_incompatible, :type_parameters, :raw)
class DeclBuilder
attr_reader :decl
class BuilderError < StandardError; end
private def check_live!
if decl.finalized
raise BuilderError.new("You can't modify a signature declaration after it has been used.")
end
end
def initialize(mod, raw)
# TODO RUBYPLAT-1278 - with ruby 2.5, use kwargs here
@decl = Declaration.new(
mod,
ARG_NOT_PROVIDED, # params
ARG_NOT_PROVIDED, # returns
ARG_NOT_PROVIDED, # bind
Modes.standard, # mode
ARG_NOT_PROVIDED, # checked
false, # finalized
ARG_NOT_PROVIDED, # on_failure
nil, # override_allow_incompatible
ARG_NOT_PROVIDED, # type_parameters
raw
)
end
def params(**params)
check_live!
if !decl.params.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You can't call .params twice")
end
if params.empty?
raise BuilderError.new("params expects keyword arguments")
end
decl.params = params
self
end
def returns(type)
check_live!
if decl.returns.is_a?(T::Private::Types::Void)
raise BuilderError.new("You can't call .returns after calling .void.")
end
if !decl.returns.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You can't call .returns multiple times in a signature.")
end
decl.returns = type
self
end
def void
check_live!
if !decl.returns.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You can't call .void after calling .returns.")
end
decl.returns = T::Private::Types::Void.new
self
end
def bind(type)
check_live!
if !decl.bind.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You can't call .bind multiple times in a signature.")
end
decl.bind = type
self
end
def checked(level)
check_live!
if !decl.checked.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You can't call .checked multiple times in a signature.")
end
if (level == :never || level == :compiled) && !decl.on_failure.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You can't use .checked(:#{level}) with .on_failure because .on_failure will have no effect.")
end
if !T::Private::RuntimeLevels::LEVELS.include?(level)
raise BuilderError.new("Invalid `checked` level '#{level}'. Use one of: #{T::Private::RuntimeLevels::LEVELS}.")
end
decl.checked = level
self
end
def on_failure(*args)
check_live!
if !decl.on_failure.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You can't call .on_failure multiple times in a signature.")
end
if decl.checked == :never || decl.checked == :compiled
raise BuilderError.new("You can't use .on_failure with .checked(:#{decl.checked}) because .on_failure will have no effect.")
end
decl.on_failure = args
self
end
def abstract
check_live!
case decl.mode
when Modes.standard
decl.mode = Modes.abstract
when Modes.abstract
raise BuilderError.new(".abstract cannot be repeated in a single signature")
else
raise BuilderError.new("`.abstract` cannot be combined with `.override` or `.overridable`.")
end
self
end
def final
check_live!
raise BuilderError.new("The syntax for declaring a method final is `sig(:final) {...}`, not `sig {final. ...}`")
end
def override(allow_incompatible: false)
check_live!
case decl.mode
when Modes.standard
decl.mode = Modes.override
decl.override_allow_incompatible = allow_incompatible
when Modes.override, Modes.overridable_override
raise BuilderError.new(".override cannot be repeated in a single signature")
when Modes.overridable
decl.mode = Modes.overridable_override
else
raise BuilderError.new("`.override` cannot be combined with `.abstract`.")
end
self
end
def overridable
check_live!
case decl.mode
when Modes.abstract
raise BuilderError.new("`.overridable` cannot be combined with `.#{decl.mode}`")
when Modes.override
decl.mode = Modes.overridable_override
when Modes.standard
decl.mode = Modes.overridable
when Modes.overridable, Modes.overridable_override
raise BuilderError.new(".overridable cannot be repeated in a single signature")
end
self
end
# Declares valid type paramaters which can be used with `T.type_parameter` in
# this `sig`.
#
# This is used for generic methods. Example usage:
#
# sig do
# type_parameters(:U)
# .params(blk: T.proc.params(arg0: Elem).returns(T.type_parameter(:U)))
# .returns(T::Array[T.type_parameter(:U)])
# end
# def map(&blk); end
def type_parameters(*names)
check_live!
names.each do |name|
raise BuilderError.new("not a symbol: #{name}") unless name.is_a?(Symbol)
end
if !decl.type_parameters.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You can't call .type_parameters multiple times in a signature.")
end
decl.type_parameters = names
self
end
def finalize!
check_live!
if decl.returns.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("You must provide a return type; use the `.returns` or `.void` builder methods.")
end
if decl.bind.equal?(ARG_NOT_PROVIDED)
decl.bind = nil
end
if decl.checked.equal?(ARG_NOT_PROVIDED)
default_checked_level = T::Private::RuntimeLevels.default_checked_level
if (default_checked_level == :never || default_checked_level == :compiled) && !decl.on_failure.equal?(ARG_NOT_PROVIDED)
raise BuilderError.new("To use .on_failure you must additionally call .checked(:tests) or .checked(:always), otherwise, the .on_failure has no effect.")
end
decl.checked = default_checked_level
end
if decl.on_failure.equal?(ARG_NOT_PROVIDED)
decl.on_failure = nil
end
if decl.params.equal?(ARG_NOT_PROVIDED)
decl.params = {}
end
if decl.type_parameters.equal?(ARG_NOT_PROVIDED)
decl.type_parameters = {}
end
decl.finalized = true
self
end
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
# typed: true
module T::Private::Methods::Modes
def self.standard
'standard'
end
def self.abstract
'abstract'
end
def self.overridable
'overridable'
end
def self.override
'override'
end
def self.overridable_override
'overridable_override'
end
def self.untyped
'untyped'
end
MODES = [self.standard, self.abstract, self.overridable, self.override, self.overridable_override, self.untyped].freeze
OVERRIDABLE_MODES = [self.override, self.overridable, self.overridable_override, self.untyped, self.abstract].freeze
OVERRIDE_MODES = [self.override, self.overridable_override].freeze
NON_OVERRIDE_MODES = MODES - OVERRIDE_MODES
end

View File

@ -0,0 +1,225 @@
# frozen_string_literal: true
# typed: true
class T::Private::Methods::Signature
attr_reader :method, :method_name, :arg_types, :kwarg_types, :block_type, :block_name,
:rest_type, :rest_name, :keyrest_type, :keyrest_name, :bind,
:return_type, :mode, :req_arg_count, :req_kwarg_names, :has_rest, :has_keyrest,
:check_level, :parameters, :on_failure, :override_allow_incompatible,
:defined_raw
def self.new_untyped(method:, mode: T::Private::Methods::Modes.untyped, parameters: method.parameters)
# Using `Untyped` ensures we'll get an error if we ever try validation on these.
not_typed = T::Private::Types::NotTyped.new
raw_return_type = not_typed
# Map missing parameter names to "argN" positionally
parameters = parameters.each_with_index.map do |(param_kind, param_name), index|
[param_kind, param_name || "arg#{index}"]
end
raw_arg_types = parameters.map do |_param_kind, param_name|
[param_name, not_typed]
end.to_h
self.new(
method: method,
method_name: method.name,
raw_arg_types: raw_arg_types,
raw_return_type: raw_return_type,
bind: nil,
mode: mode,
check_level: :never,
parameters: parameters,
on_failure: nil,
)
end
def initialize(method:, method_name:, raw_arg_types:, raw_return_type:, bind:, mode:, check_level:, on_failure:, parameters: method.parameters, override_allow_incompatible: false, defined_raw: false)
@method = method
@method_name = method_name
@arg_types = []
@kwarg_types = {}
@block_type = nil
@block_name = nil
@rest_type = nil
@rest_name = nil
@keyrest_type = nil
@keyrest_name = nil
@return_type = T::Utils.coerce(raw_return_type)
@bind = bind ? T::Utils.coerce(bind) : bind
@mode = mode
@check_level = check_level
@req_arg_count = 0
@req_kwarg_names = []
@has_rest = false
@has_keyrest = false
@parameters = parameters
@on_failure = on_failure
@override_allow_incompatible = override_allow_incompatible
@defined_raw = defined_raw
declared_param_names = raw_arg_types.keys
# If sig params are declared but there is a single parameter with a missing name
# **and** the method ends with a "=", assume it is a writer method generated
# by attr_writer or attr_accessor
writer_method = declared_param_names != [nil] && parameters == [[:req]] && method_name[-1] == "="
# For writer methods, map the single parameter to the method name without the "=" at the end
parameters = [[:req, method_name[0...-1].to_sym]] if writer_method
param_names = parameters.map {|_, name| name}
missing_names = param_names - declared_param_names
extra_names = declared_param_names - param_names
if !missing_names.empty?
raise "The declaration for `#{method.name}` is missing parameter(s): #{missing_names.join(', ')}"
end
if !extra_names.empty?
raise "The declaration for `#{method.name}` has extra parameter(s): #{extra_names.join(', ')}"
end
if parameters.size != raw_arg_types.size
raise "The declaration for `#{method.name}` has arguments with duplicate names"
end
parameters.zip(raw_arg_types) do |(param_kind, param_name), (type_name, raw_type)|
if type_name != param_name
hint = ""
# Ruby reorders params so that required keyword arguments
# always precede optional keyword arguments. We can't tell
# whether the culprit is the Ruby reordering or user error, so
# we error but include a note
if param_kind == :keyreq && parameters.any? {|k, _| k == :key}
hint = "\n\nNote: Any required keyword arguments must precede any optional keyword " \
"arguments. If your method declaration matches your `def`, try reordering any " \
"optional keyword parameters to the end of the method list."
end
raise "Parameter `#{type_name}` is declared out of order (declared as arg number " \
"#{declared_param_names.index(type_name) + 1}, defined in the method as arg number " \
"#{param_names.index(type_name) + 1}).#{hint}\nMethod: #{method_desc}"
end
type = T::Utils.coerce(raw_type)
case param_kind
when :req
if @arg_types.length > @req_arg_count
# Note that this is actually is supported by Ruby, but it would add complexity to
# support it here, and I'm happy to discourage its use anyway.
#
# If you are seeing this error and surprised by it, it's possible that you have
# overridden the method described in the error message. For example, Rails defines
# def self.update!(id = :all, attributes)
# on AR models. If you have also defined `self.update!` on an AR model you might
# see this error. The simplest resolution is to rename your method.
raise "Required params after optional params are not supported in method declarations. Method: #{method_desc}"
end
@arg_types << [param_name, type]
@req_arg_count += 1
when :opt
@arg_types << [param_name, type]
when :key, :keyreq
@kwarg_types[param_name] = type
if param_kind == :keyreq
@req_kwarg_names << param_name
end
when :block
@block_name = param_name
@block_type = type
when :rest
@has_rest = true
@rest_name = param_name
@rest_type = type
when :keyrest
@has_keyrest = true
@keyrest_name = param_name
@keyrest_type = type
else
raise "Unexpected param_kind: `#{param_kind}`. Method: #{method_desc}"
end
end
end
attr_writer :method_name
protected :method_name=
def as_alias(alias_name)
new_sig = clone
new_sig.method_name = alias_name
new_sig
end
def arg_count
@arg_types.length
end
def kwarg_names
@kwarg_types.keys
end
def owner
@method.owner
end
def dsl_method
"#{@mode}_method"
end
# @return [Hash] a mapping like `{arg_name: [val, type], ...}`, for only those args actually present.
def each_args_value_type(args)
# Manually split out args and kwargs based on ruby's behavior. Do not try to implement this by
# getting ruby to determine the kwargs for you (e.g., by defining this method to take *args and
# **kwargs). That won't work, because ruby's behavior for determining kwargs is dependent on the
# the other parameters in the method definition, and our method definition here doesn't (and
# can't) match the definition of the method we're validating. In addition, Ruby has a bug that
# causes forwarding **kwargs to do the wrong thing: see https://bugs.ruby-lang.org/issues/10708
# and https://bugs.ruby-lang.org/issues/11860.
args_length = args.length
if (args_length > @req_arg_count) && (!@kwarg_types.empty? || @has_keyrest) && args[-1].is_a?(Hash)
kwargs = args[-1]
args_length -= 1
else
kwargs = EMPTY_HASH
end
arg_types = @arg_types
if @has_rest
rest_count = args_length - @arg_types.length
rest_count = 0 if rest_count.negative?
arg_types += [[@rest_name, @rest_type]] * rest_count
elsif (args_length < @req_arg_count) || (args_length > @arg_types.length)
expected_str = @req_arg_count.to_s
if @arg_types.length != @req_arg_count
expected_str += "..#{@arg_types.length}"
end
raise ArgumentError.new("wrong number of arguments (given #{args_length}, expected #{expected_str})")
end
begin
it = 0
while it < args_length
yield arg_types[it][0], args[it], arg_types[it][1]
it += 1
end
end
kwargs.each do |name, val|
type = @kwarg_types[name]
if !type && @has_keyrest
type = @keyrest_type
end
yield name, val, type if type
end
end
def method_desc
loc = if @method.source_location
@method.source_location.join(':')
else
"<unknown location>"
end
"#{@method} at #{loc}"
end
EMPTY_HASH = {}.freeze
end

View File

@ -0,0 +1,225 @@
# frozen_string_literal: true
# typed: true
module T::Private::Methods::SignatureValidation
Methods = T::Private::Methods
Modes = Methods::Modes
def self.validate(signature)
if signature.method_name == :initialize && signature.method.owner.is_a?(Class)
# Constructors are special. They look like overrides in terms of a super_method existing,
# but in practice, you never call them polymorphically. Conceptually, they're standard
# methods (this is consistent with how they're treated in other languages, e.g. Java)
if signature.mode != Modes.standard
raise "`initialize` should not use `.abstract` or `.implementation` or any other inheritance modifiers."
end
return
end
super_method = signature.method.super_method
if super_method && super_method.owner != signature.method.owner
Methods.maybe_run_sig_block_for_method(super_method)
super_signature = Methods.signature_for_method(super_method)
# If the super_method has any kwargs we can't build a
# Signature for it, so we'll just skip validation in that case.
if !super_signature && !super_method.parameters.select {|kind, _| kind == :rest || kind == :kwrest}.empty?
nil
else
# super_signature can be nil when we're overriding a method (perhaps a builtin) that didn't use
# one of the method signature helpers. Use an untyped signature so we can still validate
# everything but types.
#
# We treat these signatures as overridable, that way people can use `.override` with
# overrides of builtins. In the future we could try to distinguish when the method is a
# builtin and treat non-builtins as non-overridable (so you'd be forced to declare them with
# `.overridable`).
#
super_signature ||= Methods::Signature.new_untyped(method: super_method)
validate_override_mode(signature, super_signature)
validate_override_shape(signature, super_signature)
validate_override_types(signature, super_signature)
end
else
validate_non_override_mode(signature)
end
end
private_class_method def self.pretty_mode(signature)
if signature.mode == Modes.overridable_override
'.overridable.override'
else
".#{signature.mode}"
end
end
def self.validate_override_mode(signature, super_signature)
case signature.mode
when *Modes::OVERRIDE_MODES
# Peaceful
when *Modes::NON_OVERRIDE_MODES
if super_signature.mode == Modes.standard
# Peaceful
elsif super_signature.mode == Modes.abstract
raise "You must use `.override` when overriding the abstract method `#{signature.method_name}`.\n" \
" Abstract definition: #{method_loc_str(super_signature.method)}\n" \
" Implementation definition: #{method_loc_str(signature.method)}\n"
elsif super_signature.mode != Modes.untyped
raise "You must use `.override` when overriding the existing method `#{signature.method_name}`.\n" \
" Parent definition: #{method_loc_str(super_signature.method)}\n" \
" Child definition: #{method_loc_str(signature.method)}\n"
end
else
raise "Unexpected mode: #{signature.mode}. Please report this bug at https://github.com/sorbet/sorbet/issues"
end
end
def self.validate_non_override_mode(signature)
case signature.mode
when Modes.override
if signature.method_name == :each && signature.method.owner < Enumerable
# Enumerable#each is the only method in Sorbet's RBI payload that defines an abstract method.
# Enumerable#each does not actually exist at runtime, but it is required to be implemented by
# any class which includes Enumerable. We want to declare Enumerable#each as abstract so that
# people can call it anything which implements the Enumerable interface, and so that it's a
# static error to forget to implement it.
#
# This is a one-off hack, and we should think carefully before adding more methods here.
nil
else
raise "You marked `#{signature.method_name}` as #{pretty_mode(signature)}, but that method doesn't already exist in this class/module to be overriden.\n" \
" Either check for typos and for missing includes or super classes to make the parent method shows up\n" \
" ... or remove #{pretty_mode(signature)} here: #{method_loc_str(signature.method)}\n"
end
when Modes.standard, *Modes::NON_OVERRIDE_MODES
# Peaceful
nil
else
raise "Unexpected mode: #{signature.mode}. Please report this bug at https://github.com/sorbet/sorbet/issues"
end
# Given a singleton class, we can check if it belongs to a
# module by looking at its superclass; given `module M`,
# `M.singleton_class.superclass == Module`, which is not true
# for any class.
owner = signature.method.owner
if (signature.mode == Modes.abstract || Modes::OVERRIDABLE_MODES.include?(signature.mode)) &&
owner.singleton_class? && owner.superclass == Module
raise "Defining an overridable class method (via #{pretty_mode(signature)}) " \
"on a module is not allowed. Class methods on " \
"modules do not get inherited and thus cannot be overridden."
end
end
def self.validate_override_shape(signature, super_signature)
return if signature.override_allow_incompatible
return if super_signature.mode == Modes.untyped
method_name = signature.method_name
mode_verb = super_signature.mode == Modes.abstract ? 'implements' : 'overrides'
if !signature.has_rest && signature.arg_count < super_signature.arg_count
raise "Your definition of `#{method_name}` must accept at least #{super_signature.arg_count} " \
"positional arguments to be compatible with the method it #{mode_verb}: " \
"#{base_override_loc_str(signature, super_signature)}"
end
if !signature.has_rest && super_signature.has_rest
raise "Your definition of `#{method_name}` must have `*#{super_signature.rest_name}` " \
"to be compatible with the method it #{mode_verb}: " \
"#{base_override_loc_str(signature, super_signature)}"
end
if signature.req_arg_count > super_signature.req_arg_count
raise "Your definition of `#{method_name}` must have no more than #{super_signature.req_arg_count} " \
"required argument(s) to be compatible with the method it #{mode_verb}: " \
"#{base_override_loc_str(signature, super_signature)}"
end
if !signature.has_keyrest
# O(nm), but n and m are tiny here
missing_kwargs = super_signature.kwarg_names - signature.kwarg_names
if !missing_kwargs.empty?
raise "Your definition of `#{method_name}` is missing these keyword arg(s): #{missing_kwargs} " \
"which are defined in the method it #{mode_verb}: " \
"#{base_override_loc_str(signature, super_signature)}"
end
end
if !signature.has_keyrest && super_signature.has_keyrest
raise "Your definition of `#{method_name}` must have `**#{super_signature.keyrest_name}` " \
"to be compatible with the method it #{mode_verb}: " \
"#{base_override_loc_str(signature, super_signature)}"
end
# O(nm), but n and m are tiny here
extra_req_kwargs = signature.req_kwarg_names - super_signature.req_kwarg_names
if !extra_req_kwargs.empty?
raise "Your definition of `#{method_name}` has extra required keyword arg(s) " \
"#{extra_req_kwargs} relative to the method it #{mode_verb}, making it incompatible: " \
"#{base_override_loc_str(signature, super_signature)}"
end
if super_signature.block_name && !signature.block_name
raise "Your definition of `#{method_name}` must accept a block parameter to be compatible " \
"with the method it #{mode_verb}: " \
"#{base_override_loc_str(signature, super_signature)}"
end
end
def self.validate_override_types(signature, super_signature)
return if signature.override_allow_incompatible
return if super_signature.mode == Modes.untyped
return unless [signature, super_signature].all? do |sig|
sig.check_level == :always || sig.check_level == :compiled || (sig.check_level == :tests && T::Private::RuntimeLevels.check_tests?)
end
mode_noun = super_signature.mode == Modes.abstract ? 'implementation' : 'override'
# arg types must be contravariant
super_signature.arg_types.zip(signature.arg_types).each_with_index do |((_super_name, super_type), (name, type)), index|
if !super_type.subtype_of?(type)
raise "Incompatible type for arg ##{index + 1} (`#{name}`) in signature for #{mode_noun} of method " \
"`#{signature.method_name}`:\n" \
"* Base: `#{super_type}` (in #{method_loc_str(super_signature.method)})\n" \
"* #{mode_noun.capitalize}: `#{type}` (in #{method_loc_str(signature.method)})\n" \
"(The types must be contravariant.)"
end
end
# kwarg types must be contravariant
super_signature.kwarg_types.each do |name, super_type|
type = signature.kwarg_types[name]
if !super_type.subtype_of?(type)
raise "Incompatible type for arg `#{name}` in signature for #{mode_noun} of method `#{signature.method_name}`:\n" \
"* Base: `#{super_type}` (in #{method_loc_str(super_signature.method)})\n" \
"* #{mode_noun.capitalize}: `#{type}` (in #{method_loc_str(signature.method)})\n" \
"(The types must be contravariant.)"
end
end
# return types must be covariant
if !signature.return_type.subtype_of?(super_signature.return_type)
raise "Incompatible return type in signature for #{mode_noun} of method `#{signature.method_name}`:\n" \
"* Base: `#{super_signature.return_type}` (in #{method_loc_str(super_signature.method)})\n" \
"* #{mode_noun.capitalize}: `#{signature.return_type}` (in #{method_loc_str(signature.method)})\n" \
"(The types must be covariant.)"
end
end
private_class_method def self.base_override_loc_str(signature, super_signature)
mode_noun = super_signature.mode == Modes.abstract ? 'Implementation' : 'Override'
"\n * Base definition: in #{method_loc_str(super_signature.method)}" \
"\n * #{mode_noun}: in #{method_loc_str(signature.method)}"
end
private_class_method def self.method_loc_str(method)
loc = if method.source_location
method.source_location.join(':')
else
"<unknown location>"
end
"#{method.owner} at #{loc}"
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
# typed: true
module T::Private
module MixesInClassMethods
def included(other)
mods = Abstract::Data.get(self, :class_methods_mixins)
mods.each {|mod| other.extend(mod)}
super
end
end
module Mixins
def self.declare_mixes_in_class_methods(mixin, class_methods)
if mixin.is_a?(Class)
raise "Classes cannot be used as mixins, and so mixes_in_class_methods cannot be used on a Class."
end
if Abstract::Data.key?(mixin, :class_methods_mixins)
class_methods = Abstract::Data.get(mixin, :class_methods_mixins) + class_methods
end
mixin.singleton_class.include(MixesInClassMethods)
Abstract::Data.set(mixin, :class_methods_mixins, class_methods)
end
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
# typed: true
module T::Private::Retry
# A special singleton used for static analysis of exceptions.
module RETRY
freeze
end
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
# typed: true
# Used in `sig.checked(level)` to determine when runtime type checking
# is enabled on a method.
module T::Private::RuntimeLevels
LEVELS = [
# Validate every call in every environment
:always,
# Validate in tests, but not in production
:tests,
# Don't even validate in tests, b/c too expensive,
# or b/c we fully trust the static typing
:never,
# Validate the sig when the file is using the Sorbet Compiler.
# Behaves like :never when interpreted.
:compiled,
].freeze
@check_tests = false
@wrapped_tests_with_validation = false
@has_read_default_checked_level = false
@default_checked_level = :always
def self.check_tests?
# Assume that this code path means that some `sig.checked(:tests)`
# has been wrapped (or not wrapped) already, which is a trapdoor
# for toggling `@check_tests`.
@wrapped_tests_with_validation = true
@check_tests
end
def self.enable_checking_in_tests
if !@check_tests && @wrapped_tests_with_validation
all_checked_tests_sigs = T::Private::Methods.all_checked_tests_sigs
locations = all_checked_tests_sigs.map {|sig| sig.method.source_location.join(':')}.join("\n- ")
raise "Toggle `:tests`-level runtime type checking earlier. " \
"There are already some methods wrapped with `sig.checked(:tests)`:\n" \
"- #{locations}"
end
_toggle_checking_tests(true)
end
def self.default_checked_level
@has_read_default_checked_level = true
@default_checked_level
end
def self.default_checked_level=(default_checked_level)
if @has_read_default_checked_level
raise "Set the default checked level earlier. There are already some methods whose sig blocks have evaluated which would not be affected by the new default."
end
@default_checked_level = default_checked_level
end
def self._toggle_checking_tests(checked)
@check_tests = checked
end
end

View File

@ -0,0 +1,91 @@
# frozen_string_literal: true
# typed: false
module T::Private::Sealed
module NoInherit
def inherited(child)
super
this_line = Kernel.caller.find {|line| !line.match(/in `inherited'$/)}
T::Private::Sealed.validate_inheritance(this_line, self, child, 'inherited')
@sorbet_sealed_module_all_subclasses << child
end
def sealed_subclasses
@sorbet_sealed_module_all_subclasses_set ||= # rubocop:disable Naming/MemoizedInstanceVariableName
begin
require 'set'
Set.new(@sorbet_sealed_module_all_subclasses).freeze
end
end
end
module NoIncludeExtend
def included(child)
super
this_line = Kernel.caller.find {|line| !line.match(/in `included'$/)}
T::Private::Sealed.validate_inheritance(this_line, self, child, 'included')
@sorbet_sealed_module_all_subclasses << child
end
def extended(child)
super
this_line = Kernel.caller.find {|line| !line.match(/in `extended'$/)}
T::Private::Sealed.validate_inheritance(this_line, self, child, 'extended')
@sorbet_sealed_module_all_subclasses << child
end
def sealed_subclasses
# this will freeze the set so that you can never get into a
# state where you use the subclasses list and then something
# else will add to it
@sorbet_sealed_module_all_subclasses_set ||= # rubocop:disable Naming/MemoizedInstanceVariableName
begin
require 'set'
Set.new(@sorbet_sealed_module_all_subclasses).freeze
end
end
end
def self.declare(mod, decl_file)
if !mod.is_a?(Module)
raise "#{mod} is not a class or module and cannot be declared `sealed!`"
end
if sealed_module?(mod)
raise "#{mod} was already declared `sealed!` and cannot be re-declared `sealed!`"
end
if T::Private::Final.final_module?(mod)
raise "#{mod} was already declared `final!` and cannot be declared `sealed!`"
end
mod.extend(mod.is_a?(Class) ? NoInherit : NoIncludeExtend)
if !decl_file
raise "Couldn't determine declaration file for sealed class."
end
mod.instance_variable_set(:@sorbet_sealed_module_decl_file, decl_file)
mod.instance_variable_set(:@sorbet_sealed_module_all_subclasses, [])
end
def self.sealed_module?(mod)
mod.instance_variable_defined?(:@sorbet_sealed_module_decl_file)
end
def self.validate_inheritance(this_line, parent, child, verb)
this_file = this_line&.split(':')&.first
decl_file = parent.instance_variable_get(:@sorbet_sealed_module_decl_file) if sealed_module?(parent)
if !this_file
raise "Could not use backtrace to determine file for #{verb} child #{child}"
end
if !decl_file
raise "#{parent} does not seem to be a sealed module (#{verb} by #{child})"
end
if !this_file.start_with?(decl_file)
whitelist = T::Configuration.sealed_violation_whitelist
if !whitelist.nil? && whitelist.any? {|pattern| this_file =~ pattern}
return
end
raise "#{parent} was declared sealed and can only be #{verb} in #{decl_file}, not #{this_file}"
end
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
# typed: true
# A placeholder for when an untyped thing must provide a type.
# Raises an exception if it is ever used for validation.
class T::Private::Types::NotTyped < T::Types::Base
ERROR_MESSAGE = "Validation is being done on a `NotTyped`. Please report this bug at https://github.com/sorbet/sorbet/issues"
# overrides Base
def name
"<NOT-TYPED>"
end
# overrides Base
def valid?(obj)
raise ERROR_MESSAGE
end
# overrides Base
private def subtype_of_single?(other)
raise ERROR_MESSAGE
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
# typed: true
# Holds a string. Useful for showing type aliases in error messages
class T::Private::Types::StringHolder < T::Types::Base
attr_reader :string
def initialize(string)
@string = string
end
# overrides Base
def name
string
end
# overrides Base
def valid?(obj)
false
end
# overrides Base
private def subtype_of_single?(other)
false
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# typed: true
module T::Private::Types
# Wraps a proc for a type alias to defer its evaluation.
class TypeAlias < T::Types::Base
def initialize(callable)
@callable = callable
end
def aliased_type
@aliased_type ||= T::Utils.coerce(@callable.call)
end
# overrides Base
def name
aliased_type.name
end
# overrides Base
def recursively_valid?(obj)
aliased_type.recursively_valid?(obj)
end
# overrides Base
def valid?(obj)
aliased_type.valid?(obj)
end
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
# typed: true
# A marking class for when methods return void.
# Should never appear in types directly.
class T::Private::Types::Void < T::Types::Base
ERROR_MESSAGE = "Validation is being done on an `Void`. Please report this bug at https://github.com/sorbet/sorbet/issues"
# The actual return value of `.void` methods.
#
# Uses `module VOID` because this gives it a readable name when someone
# examines it in Pry or with `#inspect` like:
#
# T::Private::Types::Void::VOID
#
module VOID
freeze
end
# overrides Base
def name
"<VOID>"
end
# overrides Base
def valid?(obj)
raise ERROR_MESSAGE
end
# overrides Base
private def subtype_of_single?(other)
raise ERROR_MESSAGE
end
end

View File

@ -0,0 +1,169 @@
# frozen_string_literal: true
# typed: true
# A mixin for defining typed properties (attributes).
# To get serialization methods (to/from JSON-style hashes), add T::Props::Serializable.
# To get a constructor based on these properties, inherit from T::Struct.
module T::Props
extend T::Helpers
#####
# CAUTION: This mixin is used in hundreds of classes; we want to keep its surface area as narrow
# as possible and avoid polluting (and possibly conflicting with) the classes that use it.
#
# It currently has *zero* instance methods; let's try to keep it that way.
# For ClassMethods (below), try to add things to T::Props::Decorator instead unless you are sure
# it needs to be exposed here.
#####
module ClassMethods
extend T::Sig
extend T::Helpers
def props
decorator.props
end
def plugins
@plugins ||= []
end
def decorator_class
Decorator
end
def decorator
@decorator ||= decorator_class.new(self)
end
def reload_decorator!
@decorator = decorator_class.new(self)
end
# Define a new property. See {file:README.md} for some concrete
# examples.
#
# Defining a property defines a method with the same name as the
# property, that returns the current value, and a `prop=` method
# to set its value. Properties will be inherited by subclasses of
# a document class.
#
# @param name [Symbol] The name of this property
# @param cls [Class,T::Types::Base] The type of this
# property. If the type is itself a `Document` subclass, this
# property will be recursively serialized/deserialized.
# @param rules [Hash] Options to control this property's behavior.
# @option rules [T::Boolean,Symbol] :optional If `true`, this property
# is never required to be set before an instance is serialized.
# If `:on_load` (default), when this property is missing or nil, a
# new model cannot be saved, and an existing model can only be
# saved if the property was already missing when it was loaded.
# If `false`, when the property is missing/nil after deserialization, it
# will be set to the default value (as defined by the `default` or
# `factory` option) or will raise if they are not present.
# Deprecated: For `Model`s, if `:optional` is set to the special value
# `:existing`, the property can be saved as nil even if it was
# deserialized with a non-nil value. (Deprecated because there should
# never be a need for this behavior; the new behavior of non-optional
# properties should be sufficient.)
# @option rules [Array] :enum An array of legal values; The
# property is required to take on one of those values.
# @option rules [T::Boolean] :dont_store If true, this property will
# not be saved on the hash resulting from
# {T::Props::Serializable#serialize}
# @option rules [Object] :ifunset A value to be returned if this
# property is requested but has never been set (is set to
# `nil`). It is applied at property-access time, and never saved
# back onto the object or into the database.
#
# ``:ifunset`` is considered **DEPRECATED** and should not be used
# in new code, in favor of just setting a default value.
# @option rules [Model, Symbol, Proc] :foreign A model class that this
# property is a reference to. Passing `:foreign` will define a
# `:"#{name}_"` method, that will load and return the
# corresponding foreign model.
#
# A symbol can be passed to avoid load-order dependencies; It
# will be lazily resolved relative to the enclosing module of the
# defining class.
#
# A callable (proc or method) can be passed to dynamically specify the
# foreign model. This will be passed the object instance so that other
# properties of the object can be used to determine the relevant model
# class. It should return a string/symbol class name or the foreign model
# class directly.
#
# @option rules [Object] :default A default value that will be set
# by `#initialize` if none is provided in the initialization
# hash. This will not affect objects loaded by {.from_hash}.
# @option rules [Proc] :factory A `Proc` that will be called to
# generate an initial value for this prop on `#initialize`, if
# none is provided.
# @option rules [T::Boolean] :immutable If true, this prop cannot be
# modified after an instance is created or loaded from a hash.
# @option rules [T::Boolean] :override It is an error to redeclare a
# `prop` that has already been declared (including on a
# superclass), unless `:override` is set to `true`.
# @option rules [Symbol, Array] :redaction A redaction directive that may
# be passed to Chalk::Tools::RedactionUtils.redact_with_directive to
# sanitize this parameter for display. Will define a
# `:"#{name}_redacted"` method, which will return the value in sanitized
# form.
#
# @return [void]
sig {params(name: Symbol, cls: T.untyped, rules: T.untyped).void}
def prop(name, cls, rules={})
cls = T::Utils.coerce(cls) if !cls.is_a?(Module)
decorator.prop_defined(name, cls, rules)
end
# Validates the value of the specified prop. This method allows the caller to
# validate a value for a prop without having to set the data on the instance.
# Throws if invalid.
#
# @param prop [Symbol]
# @param val [Object]
# @return [void]
def validate_prop_value(prop, val)
decorator.validate_prop_value(prop, val)
end
# Needs to be documented
def plugin(mod)
decorator.plugin(mod)
end
# Shorthand helper to define a `prop` with `immutable => true`
sig {params(name: Symbol, cls_or_args: T.untyped, args: T::Hash[Symbol, T.untyped]).void}
def const(name, cls_or_args, args={})
if (cls_or_args.is_a?(Hash) && cls_or_args.key?(:immutable)) || args.key?(:immutable)
Kernel.raise ArgumentError.new("Cannot pass 'immutable' argument when using 'const' keyword to define a prop")
end
if cls_or_args.is_a?(Hash)
self.prop(name, cls_or_args.merge(immutable: true))
else
self.prop(name, cls_or_args, args.merge(immutable: true))
end
end
def included(child)
decorator.model_inherited(child)
super
end
def prepended(child)
decorator.model_inherited(child)
super
end
def extended(child)
decorator.model_inherited(child.singleton_class)
super
end
def inherited(child)
decorator.model_inherited(child)
super
end
end
mixes_in_class_methods(ClassMethods)
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
# typed: false
module T::Props::Constructor
include T::Props::WeakConstructor
end
module T::Props::Constructor::DecoratorMethods
extend T::Sig
# Set values for all props that have no defaults. Override what `WeakConstructor`
# does in order to raise errors on nils instead of ignoring them.
#
# @return [Integer] A count of props that we successfully initialized (which
# we'll use to check for any unrecognized input.)
#
# checked(:never) - O(runtime object construction)
sig {params(instance: T::Props::Constructor, hash: T::Hash[Symbol, T.untyped]).returns(Integer).checked(:never)}
def construct_props_without_defaults(instance, hash)
# Use `each_pair` rather than `count` because, as of Ruby 2.6, the latter delegates to Enumerator
# and therefore allocates for each entry.
result = 0
props_without_defaults&.each_pair do |p, setter_proc|
begin
val = hash[p]
instance.instance_exec(val, &setter_proc)
if val || hash.key?(p)
result += 1
end
rescue TypeError, T::Props::InvalidValueError
if !hash.key?(p)
raise ArgumentError.new("Missing required prop `#{p}` for class `#{instance.class.name}`")
else
raise
end
end
end
result
end
end

View File

@ -0,0 +1,108 @@
# frozen_string_literal: true
# typed: strict
module T::Props
module CustomType
extend T::Sig
extend T::Helpers
abstract!
include Kernel # for `is_a?`
# Alias for backwards compatibility
sig(:final) do
params(
value: BasicObject,
)
.returns(T::Boolean)
.checked(:never)
end
def instance?(value)
self.===(value)
end
# Alias for backwards compatibility
sig(:final) do
params(
value: BasicObject,
)
.returns(T::Boolean)
.checked(:never)
end
def valid?(value)
instance?(value)
end
# Given an instance of this type, serialize that into a scalar type
# supported by T::Props.
#
# @param [Object] instance
# @return An instance of one of T::Configuration.scalar_types
sig {abstract.params(instance: T.untyped).returns(T.untyped).checked(:never)}
def serialize(instance); end
# Given the serialized form of your type, this returns an instance
# of that custom type representing that value.
#
# @param scalar One of T::Configuration.scalar_types
# @return Object
sig {abstract.params(scalar: T.untyped).returns(T.untyped).checked(:never)}
def deserialize(scalar); end
sig {override.params(_base: Module).void}
def self.included(_base)
super
raise 'Please use "extend", not "include" to attach this module'
end
sig(:final) {params(val: Object).returns(T::Boolean).checked(:never)}
def self.scalar_type?(val)
# We don't need to check for val's included modules in
# T::Configuration.scalar_types, because T::Configuration.scalar_types
# are all classes.
klass = T.let(val.class, T.nilable(Class))
until klass.nil?
return true if T::Configuration.scalar_types.include?(klass.to_s)
klass = klass.superclass
end
false
end
# We allow custom types to serialize to Arrays, so that we can
# implement set-like fields that store a unique-array, but forbid
# hashes; Custom hash types should be implemented via an emebdded
# T::Struct (or a subclass like Chalk::ODM::Document) or via T.
sig(:final) {params(val: Object).returns(T::Boolean).checked(:never)}
def self.valid_serialization?(val)
case val
when Array
val.each do |v|
return false unless scalar_type?(v)
end
true
else
scalar_type?(val)
end
end
sig(:final) do
params(instance: Object)
.returns(T.untyped)
.checked(:never)
end
def self.checked_serialize(instance)
val = T.cast(instance.class, T::Props::CustomType).serialize(instance)
unless valid_serialization?(val)
msg = "#{instance.class} did not serialize to a valid scalar type. It became a: #{val.class}"
if val.is_a?(Hash)
msg += "\nIf you want to store a structured Hash, consider using a T::Struct as your type."
end
raise TypeError.new(msg)
end
val
end
end
end

View File

@ -0,0 +1,669 @@
# frozen_string_literal: true
# typed: strict
# NB: This is not actually a decorator. It's just named that way for consistency
# with DocumentDecorator and ModelDecorator (which both seem to have been written
# with an incorrect understanding of the decorator pattern). These "decorators"
# should really just be static methods on private modules (we'd also want/need to
# replace decorator overrides in plugins with class methods that expose the necessary
# functionality).
class T::Props::Decorator
extend T::Sig
Rules = T.type_alias {T::Hash[Symbol, T.untyped]}
DecoratedInstance = T.type_alias {Object} # Would be T::Props, but that produces circular reference errors in some circumstances
PropType = T.type_alias {T::Types::Base}
PropTypeOrClass = T.type_alias {T.any(PropType, Module)}
class NoRulesError < StandardError; end
EMPTY_PROPS = T.let({}.freeze, T::Hash[Symbol, Rules])
private_constant :EMPTY_PROPS
sig {params(klass: T.untyped).void.checked(:never)}
def initialize(klass)
@class = T.let(klass, T.all(Module, T::Props::ClassMethods))
@class.plugins.each do |mod|
T::Props::Plugin::Private.apply_decorator_methods(mod, self)
end
@props = T.let(EMPTY_PROPS, T::Hash[Symbol, Rules])
end
# checked(:never) - O(prop accesses)
sig {returns(T::Hash[Symbol, Rules]).checked(:never)}
attr_reader :props
sig {returns(T::Array[Symbol])}
def all_props
props.keys
end
# checked(:never) - O(prop accesses)
sig {params(prop: T.any(Symbol, String)).returns(Rules).checked(:never)}
def prop_rules(prop)
props[prop.to_sym] || raise("No such prop: #{prop.inspect}")
end
# checked(:never) - Rules hash is expensive to check
sig {params(prop: Symbol, rules: Rules).void.checked(:never)}
def add_prop_definition(prop, rules)
override = rules.delete(:override)
if props.include?(prop) && !override
raise ArgumentError.new("Attempted to redefine prop #{prop.inspect} that's already defined without specifying :override => true: #{prop_rules(prop)}")
elsif !props.include?(prop) && override
raise ArgumentError.new("Attempted to override a prop #{prop.inspect} that doesn't already exist")
end
@props = @props.merge(prop => rules.freeze).freeze
end
# Heads up!
#
# There are already too many ad-hoc options on the prop DSL.
#
# We have already done a lot of work to remove unnecessary and confusing
# options. If you're considering adding a new rule key, please come chat with
# the Sorbet team first, as we'd really like to learn more about how to best
# solve the problem you're encountering.
VALID_RULE_KEYS = T.let(%i[
enum
foreign
ifunset
immutable
override
redaction
sensitivity
without_accessors
clobber_existing_method!
extra
setter_validate
_tnilable
].map {|k| [k, true]}.to_h.freeze, T::Hash[Symbol, T::Boolean])
private_constant :VALID_RULE_KEYS
sig {params(key: Symbol).returns(T::Boolean).checked(:never)}
def valid_rule_key?(key)
!!VALID_RULE_KEYS[key]
end
# checked(:never) - O(prop accesses)
sig {returns(T.all(Module, T::Props::ClassMethods)).checked(:never)}
def decorated_class
@class
end
# Accessors
# Use this to validate that a value will validate for a given prop. Useful for knowing whether a value can be set on a model without setting it.
#
# checked(:never) - potentially O(prop accesses) depending on usage pattern
sig {params(prop: Symbol, val: T.untyped).void.checked(:never)}
def validate_prop_value(prop, val)
# We call `setter_proc` here without binding to an instance, so it'll run
# `instance_variable_set` if validation passes, but nothing will care.
# We only care about the validation.
prop_rules(prop).fetch(:setter_proc).call(val)
end
# For performance, don't use named params here.
# Passing in rules here is purely a performance optimization.
# Unlike the other methods that take rules, this one calls prop_rules for
# the default, which raises if the prop doesn't exist (this maintains
# preexisting behavior).
#
# Note this path is NOT used by generated setters on instances,
# which are defined using `setter_proc` directly.
#
# checked(:never) - O(prop accesses)
sig do
params(
instance: DecoratedInstance,
prop: Symbol,
val: T.untyped,
rules: Rules
)
.void
.checked(:never)
end
def prop_set(instance, prop, val, rules=prop_rules(prop))
instance.instance_exec(val, &rules.fetch(:setter_proc))
end
alias_method :set, :prop_set
# Only Models have any custom get logic but we need to call this on
# non-Models since we don't know at code gen time what we have.
sig do
params(
instance: DecoratedInstance,
prop: Symbol,
value: T.untyped
)
.returns(T.untyped)
.checked(:never)
end
def prop_get_logic(instance, prop, value)
value
end
# For performance, don't use named params here.
# Passing in rules here is purely a performance optimization.
#
# Note this path is NOT used by generated getters on instances,
# unless `ifunset` is used on the prop, or `prop_get` is overridden.
#
# checked(:never) - O(prop accesses)
sig do
params(
instance: DecoratedInstance,
prop: T.any(String, Symbol),
rules: Rules
)
.returns(T.untyped)
.checked(:never)
end
def prop_get(instance, prop, rules=prop_rules(prop))
val = instance.instance_variable_get(rules[:accessor_key]) if instance.instance_variable_defined?(rules[:accessor_key])
if !val.nil?
val
elsif (d = rules[:ifunset])
T::Props::Utils.deep_clone_object(d)
else
nil
end
end
sig do
params(
instance: DecoratedInstance,
prop: T.any(String, Symbol),
rules: Rules
)
.returns(T.untyped)
.checked(:never)
end
def prop_get_if_set(instance, prop, rules=prop_rules(prop))
instance.instance_variable_get(rules[:accessor_key]) if instance.instance_variable_defined?(rules[:accessor_key])
end
alias_method :get, :prop_get_if_set # Alias for backwards compatibility
# checked(:never) - O(prop accesses)
sig do
params(
instance: DecoratedInstance,
prop: Symbol,
foreign_class: Module,
rules: Rules,
opts: T::Hash[Symbol, T.untyped],
)
.returns(T.untyped)
.checked(:never)
end
def foreign_prop_get(instance, prop, foreign_class, rules=prop_rules(prop), opts={})
return if !(value = prop_get(instance, prop, rules))
T.unsafe(foreign_class).load(value, {}, opts)
end
# TODO: we should really be checking all the methods on `cls`, not just Object
BANNED_METHOD_NAMES = T.let(Object.instance_methods.each_with_object({}) {|x, acc| acc[x] = true}.freeze, T::Hash[Symbol, TrueClass])
# checked(:never) - Rules hash is expensive to check
sig do
params(
name: Symbol,
cls: Module,
rules: Rules,
type: PropTypeOrClass
)
.void
.checked(:never)
end
def prop_validate_definition!(name, cls, rules, type)
validate_prop_name(name)
if rules.key?(:pii)
raise ArgumentError.new("The 'pii:' option for props has been renamed " \
"to 'sensitivity:' (in prop #{@class.name}.#{name})")
end
if rules.keys.any? {|k| !valid_rule_key?(k)}
raise ArgumentError.new("At least one invalid prop arg supplied in #{self}: #{rules.keys.inspect}")
end
if !rules[:clobber_existing_method!] && !rules[:without_accessors] && BANNED_METHOD_NAMES.include?(name.to_sym)
raise ArgumentError.new(
"#{name} can't be used as a prop in #{@class} because a method with " \
"that name already exists (defined by #{@class.instance_method(name).owner} " \
"at #{@class.instance_method(name).source_location || '<unknown>'}). " \
"(If using this name is unavoidable, try `without_accessors: true`.)"
)
end
extra = rules[:extra]
if !extra.nil? && !extra.is_a?(Hash)
raise ArgumentError.new("Extra metadata must be a Hash in prop #{@class.name}.#{name}")
end
nil
end
SAFE_NAME = T.let(/\A[A-Za-z_][A-Za-z0-9_-]*\z/.freeze, Regexp)
# Used to validate both prop names and serialized forms
sig {params(name: T.any(Symbol, String)).void}
private def validate_prop_name(name)
if !name.match?(SAFE_NAME)
raise ArgumentError.new("Invalid prop name in #{@class.name}: #{name}")
end
end
# This converts the type from a T::Type to a regular old ruby class.
sig {params(type: T::Types::Base).returns(Module)}
private def convert_type_to_class(type)
case type
when T::Types::TypedArray, T::Types::FixedArray
Array
when T::Types::TypedHash, T::Types::FixedHash
Hash
when T::Types::TypedSet
Set
when T::Types::Union
# The below unwraps our T.nilable types for T::Props if we can.
# This lets us do things like specify: const T.nilable(String), foreign: Opus::DB::Model::Merchant
non_nil_type = T::Utils.unwrap_nilable(type)
if non_nil_type
convert_type_to_class(non_nil_type)
else
Object
end
when T::Types::Simple
type.raw_type
else
# This isn't allowed unless whitelisted_for_underspecification is
# true, due to the check in prop_validate_definition
Object
end
end
# Returns `true` when the type of the prop is nilable, or the field is typed
# as `T.untyped`, a `:default` is present in the rules hash, and its value is
# `nil`. The latter case is a workaround for explicitly not supporting the use
# of `T.nilable(T.untyped)`.
#
# checked(:never) - Rules hash is expensive to check
sig do
params(
cls: PropTypeOrClass,
rules: Rules,
)
.void
.checked(:never)
end
private def prop_nilable?(cls, rules)
T::Utils::Nilable.is_union_with_nilclass(cls) || (cls == T.untyped && rules.key?(:default) && rules[:default].nil?)
end
# checked(:never) - Rules hash is expensive to check
sig do
params(
name: T.any(Symbol, String),
cls: PropTypeOrClass,
rules: Rules,
)
.void
.checked(:never)
end
def prop_defined(name, cls, rules={})
cls = T::Utils.resolve_alias(cls)
if prop_nilable?(cls, rules)
# :_tnilable is introduced internally for performance purpose so that clients do not need to call
# T::Utils::Nilable.is_tnilable(cls) again.
# It is strictly internal: clients should always use T::Props::Utils.required_prop?() or
# T::Props::Utils.optional_prop?() for checking whether a field is required or optional.
rules[:_tnilable] = true
end
name = name.to_sym
type = cls
if !cls.is_a?(Module)
cls = convert_type_to_class(cls)
end
type_object = smart_coerce(type, enum: rules[:enum])
prop_validate_definition!(name, cls, rules, type_object)
# Retrive the possible underlying object with T.nilable.
type = T::Utils::Nilable.get_underlying_type(type)
sensitivity_and_pii = {sensitivity: rules[:sensitivity]}
normalize = T::Configuration.normalize_sensitivity_and_pii_handler
if normalize
sensitivity_and_pii = normalize.call(sensitivity_and_pii)
# We check for Class so this is only applied on concrete
# documents/models; We allow mixins containing props to not
# specify their PII nature, as long as every class into which they
# are ultimately included does.
#
if sensitivity_and_pii[:pii] && @class.is_a?(Class) && !T.unsafe(@class).contains_pii?
raise ArgumentError.new(
'Cannot include a pii prop in a class that declares `contains_no_pii`'
)
end
end
rules = rules.merge(
# TODO: The type of this element is confusing. We should refactor so that
# it can be always `type_object` (a PropType) or always `cls` (a Module)
type: type,
type_object: type_object,
accessor_key: "@#{name}".to_sym,
sensitivity: sensitivity_and_pii[:sensitivity],
pii: sensitivity_and_pii[:pii],
# extra arbitrary metadata attached by the code defining this property
extra: rules[:extra]&.freeze,
)
validate_not_missing_sensitivity(name, rules)
# for backcompat (the `:array` key is deprecated but because the name is
# so generic it's really hard to be sure it's not being relied on anymore)
if type.is_a?(T::Types::TypedArray)
inner = T::Utils::Nilable.get_underlying_type(type.type)
if inner.is_a?(Module)
rules[:array] = inner
end
end
rules[:setter_proc] = T::Props::Private::SetterFactory.build_setter_proc(@class, name, rules).freeze
add_prop_definition(name, rules)
# NB: using `without_accessors` doesn't make much sense unless you also define some other way to
# get at the property (e.g., Chalk::ODM::Document exposes `get` and `set`).
define_getter_and_setter(name, rules) unless rules[:without_accessors]
handle_foreign_option(name, cls, rules, rules[:foreign]) if rules[:foreign]
handle_redaction_option(name, rules[:redaction]) if rules[:redaction]
end
# checked(:never) - Rules hash is expensive to check
sig {params(name: Symbol, rules: Rules).void.checked(:never)}
private def define_getter_and_setter(name, rules)
T::Configuration.without_ruby_warnings do
if !rules[:immutable]
if method(:prop_set).owner != T::Props::Decorator
@class.send(:define_method, "#{name}=") do |val|
T.unsafe(self.class).decorator.prop_set(self, name, val, rules)
end
else
# Fast path (~4x faster as of Ruby 2.6)
@class.send(:define_method, "#{name}=", &rules.fetch(:setter_proc))
end
end
if method(:prop_get).owner != T::Props::Decorator || rules.key?(:ifunset)
@class.send(:define_method, name) do
T.unsafe(self.class).decorator.prop_get(self, name, rules)
end
else
# Fast path (~30x faster as of Ruby 2.6)
@class.send(:attr_reader, name) # send is used because `attr_reader` is private in 2.4
end
end
end
sig do
params(type: PropTypeOrClass, enum: T.untyped)
.returns(T::Types::Base)
end
private def smart_coerce(type, enum:)
# Backwards compatibility for pre-T::Types style
type = T::Utils.coerce(type)
if enum.nil?
type
else
nonnil_type = T::Utils.unwrap_nilable(type)
if nonnil_type
T.unsafe(T.nilable(T.all(nonnil_type, T.deprecated_enum(enum))))
else
T.unsafe(T.all(T.unsafe(type), T.deprecated_enum(enum)))
end
end
end
# checked(:never) - Rules hash is expensive to check
sig {params(prop_name: Symbol, rules: Rules).void.checked(:never)}
private def validate_not_missing_sensitivity(prop_name, rules)
if rules[:sensitivity].nil?
if rules[:redaction]
T::Configuration.hard_assert_handler(
"#{@class}##{prop_name} has a 'redaction:' annotation but no " \
"'sensitivity:' annotation. This is probably wrong, because if a " \
"prop needs redaction then it is probably sensitive. Add a " \
"sensitivity annotation like 'sensitivity: Opus::Sensitivity::PII." \
"whatever', or explicitly override this check with 'sensitivity: []'."
)
end
# TODO(PRIVACYENG-982) Ideally we'd also check for 'password' and possibly
# other terms, but this interacts badly with ProtoDefinedDocument because
# the proto syntax currently can't declare "sensitivity: []"
if /\bsecret\b/.match?(prop_name)
T::Configuration.hard_assert_handler(
"#{@class}##{prop_name} has the word 'secret' in its name, but no " \
"'sensitivity:' annotation. This is probably wrong, because if a " \
"prop is named 'secret' then it is probably sensitive. Add a " \
"sensitivity annotation like 'sensitivity: Opus::Sensitivity::NonPII." \
"security_token', or explicitly override this check with " \
"'sensitivity: []'."
)
end
end
end
# Create `#{prop_name}_redacted` method
sig do
params(
prop_name: Symbol,
redaction: T.untyped,
)
.void
end
private def handle_redaction_option(prop_name, redaction)
redacted_method = "#{prop_name}_redacted"
@class.send(:define_method, redacted_method) do
value = self.public_send(prop_name)
handler = T::Configuration.redaction_handler
if !handler
raise "Using `redaction:` on a prop requires specifying `T::Configuration.redaction_handler`"
end
handler.call(value, redaction)
end
end
sig do
params(
option_sym: Symbol,
foreign: T.untyped,
valid_type_msg: String,
)
.void
end
private def validate_foreign_option(option_sym, foreign, valid_type_msg:)
if foreign.is_a?(Symbol) || foreign.is_a?(String)
raise ArgumentError.new(
"Using a symbol/string for `#{option_sym}` is no longer supported. Instead, use a Proc " \
"that returns the class, e.g., foreign: -> {Foo}"
)
end
if !foreign.is_a?(Proc) && !foreign.is_a?(Array) && !foreign.respond_to?(:load)
raise ArgumentError.new("The `#{option_sym}` option must be #{valid_type_msg}")
end
end
# checked(:never) - Rules hash is expensive to check
sig do
params(
prop_name: T.any(String, Symbol),
rules: Rules,
foreign: T.untyped,
)
.void
.checked(:never)
end
private def define_foreign_method(prop_name, rules, foreign)
fk_method = "#{prop_name}_"
# n.b. there's no clear reason *not* to allow additional options
# here, but we're baking in `allow_direct_mutation` since we
# *haven't* allowed additional options in the past and want to
# default to keeping this interface narrow.
@class.send(:define_method, fk_method) do |allow_direct_mutation: nil|
foreign = T.let(foreign, T.untyped)
if foreign.is_a?(Proc)
resolved_foreign = foreign.call
if !resolved_foreign.respond_to?(:load)
raise ArgumentError.new(
"The `foreign` proc for `#{prop_name}` must return a model class. " \
"Got `#{resolved_foreign.inspect}` instead."
)
end
# `foreign` is part of the closure state, so this will persist to future invocations
# of the method, optimizing it so this only runs on the first invocation.
foreign = resolved_foreign
end
opts = if allow_direct_mutation.nil?
{}
else
{allow_direct_mutation: allow_direct_mutation}
end
T.unsafe(self.class).decorator.foreign_prop_get(self, prop_name, foreign, rules, opts)
end
force_fk_method = "#{fk_method}!"
@class.send(:define_method, force_fk_method) do |allow_direct_mutation: nil|
loaded_foreign = send(fk_method, allow_direct_mutation: allow_direct_mutation)
if !loaded_foreign
T::Configuration.hard_assert_handler(
'Failed to load foreign model',
storytime: {method: force_fk_method, class: self.class}
)
end
loaded_foreign
end
end
# checked(:never) - Rules hash is expensive to check
sig do
params(
prop_name: Symbol,
prop_cls: Module,
rules: Rules,
foreign: T.untyped,
)
.void
.checked(:never)
end
private def handle_foreign_option(prop_name, prop_cls, rules, foreign)
validate_foreign_option(
:foreign, foreign, valid_type_msg: "a model class or a Proc that returns one"
)
if prop_cls != String
raise ArgumentError.new("`foreign` can only be used with a prop type of String")
end
if foreign.is_a?(Array)
# We don't support arrays with `foreign` because it's hard to both preserve ordering and
# keep them from being lurky performance hits by issuing a bunch of un-batched DB queries.
# We could potentially address that by porting over something like AmbiguousIDLoader.
raise ArgumentError.new(
"Using an array for `foreign` is no longer supported. Instead, please use a union type of " \
"token types for the prop type, e.g., T.any(Opus::Autogen::Tokens::FooModelToken, Opus::Autogen::Tokens::BarModelToken)"
)
end
unless foreign.is_a?(Proc)
T::Configuration.soft_assert_handler(<<~MESSAGE, storytime: {prop: prop_name, value: foreign}, notify: 'jerry')
Please use a Proc that returns a model class instead of the model class itself as the argument to `foreign`. In other words:
instead of `prop :foo, String, foreign: FooModel`
use `prop :foo, String, foreign: -> {FooModel}`
MESSAGE
end
define_foreign_method(prop_name, rules, foreign)
end
# TODO: rename this to props_inherited
#
# This gets called when a module or class that extends T::Props gets included, extended,
# prepended, or inherited.
sig {params(child: Module).void.checked(:never)}
def model_inherited(child)
child.extend(T::Props::ClassMethods)
child = T.cast(child, T.all(Module, T::Props::ClassMethods))
child.plugins.concat(decorated_class.plugins)
decorated_class.plugins.each do |mod|
# NB: apply_class_methods must not be an instance method on the decorator itself,
# otherwise we'd have to call child.decorator here, which would create the decorator
# before any `decorator_class` override has a chance to take effect (see the comment below).
T::Props::Plugin::Private.apply_class_methods(mod, child)
end
props.each do |name, rules|
copied_rules = rules.dup
# NB: Calling `child.decorator` here is a timb bomb that's going to give someone a really bad
# time. Any class that defines props and also overrides the `decorator_class` method is going
# to reach this line before its override take effect, turning it into a no-op.
child.decorator.add_prop_definition(name, copied_rules)
# It's a bit tricky to support `prop_get` hooks added by plugins without
# sacrificing the `attr_reader` fast path or clobbering customized getters
# defined manually on a child.
#
# To make this work, we _do_ clobber getters defined on the child, but only if:
# (a) it's needed in order to support a `prop_get` hook, and
# (b) it's safe because the getter was defined by this file.
#
unless rules[:without_accessors]
if clobber_getter?(child, name)
child.send(:define_method, name) do
T.unsafe(self.class).decorator.prop_get(self, name, rules)
end
end
if !rules[:immutable] && clobber_setter?(child, name)
child.send(:define_method, "#{name}=") do |val|
T.unsafe(self.class).decorator.prop_set(self, name, val, rules)
end
end
end
end
end
sig {params(child: T.all(Module, T::Props::ClassMethods), prop: Symbol).returns(T::Boolean).checked(:never)}
private def clobber_getter?(child, prop)
!!(child.decorator.method(:prop_get).owner != method(:prop_get).owner &&
child.instance_method(prop).source_location&.first == __FILE__)
end
sig {params(child: T.all(Module, T::Props::ClassMethods), prop: Symbol).returns(T::Boolean).checked(:never)}
private def clobber_setter?(child, prop)
!!(child.decorator.method(:prop_set).owner != method(:prop_set).owner &&
child.instance_method("#{prop}=").source_location&.first == __FILE__)
end
sig {params(mod: Module).void.checked(:never)}
def plugin(mod)
decorated_class.plugins << mod
T::Props::Plugin::Private.apply_class_methods(mod, decorated_class)
T::Props::Plugin::Private.apply_decorator_methods(mod, self)
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
# typed: strict
module T::Props
class Error < StandardError; end
class InvalidValueError < Error; end
class ImmutableProp < Error; end
end

View File

@ -0,0 +1,277 @@
# frozen_string_literal: true
# typed: true
module T::Props
# Helper to validate generated code, to mitigate security concerns around
# `class_eval`. Not called by default; the expectation is this will be used
# in a test iterating over all T::Props::Serializable subclasses.
#
# We validate the exact expected structure of the generated methods as far
# as we can, and then where cloning produces an arbitrarily nested structure,
# we just validate a lack of side effects.
module GeneratedCodeValidation
extend Private::Parse
class ValidationError < RuntimeError; end
def self.validate_deserialize(source)
parsed = parse(source)
# def %<name>(hash)
# ...
# end
assert_equal(:def, parsed.type)
name, args, body = parsed.children
assert_equal(:__t_props_generated_deserialize, name)
assert_equal(s(:args, s(:arg, :hash)), args)
assert_equal(:begin, body.type)
init, *prop_clauses, ret = body.children
# found = %<prop_count>
# ...
# found
assert_equal(:lvasgn, init.type)
init_name, init_val = init.children
assert_equal(:found, init_name)
assert_equal(:int, init_val.type)
assert_equal(s(:lvar, :found), ret)
prop_clauses.each_with_index do |clause, i|
if i.even?
validate_deserialize_hash_read(clause)
else
validate_deserialize_ivar_set(clause)
end
end
end
def self.validate_serialize(source)
parsed = parse(source)
# def %<name>(strict)
# ...
# end
assert_equal(:def, parsed.type)
name, args, body = parsed.children
assert_equal(:__t_props_generated_serialize, name)
assert_equal(s(:args, s(:arg, :strict)), args)
assert_equal(:begin, body.type)
init, *prop_clauses, ret = body.children
# h = {}
# ...
# h
assert_equal(s(:lvasgn, :h, s(:hash)), init)
assert_equal(s(:lvar, :h), ret)
prop_clauses.each do |clause|
validate_serialize_clause(clause)
end
end
private_class_method def self.validate_serialize_clause(clause)
assert_equal(:if, clause.type)
condition, if_body, else_body = clause.children
# if @%<accessor_key>.nil?
assert_equal(:send, condition.type)
receiver, method = condition.children
assert_equal(:ivar, receiver.type)
assert_equal(:nil?, method)
unless if_body.nil?
# required_prop_missing_from_serialize(%<prop>) if strict
assert_equal(:if, if_body.type)
if_strict_condition, if_strict_body, if_strict_else = if_body.children
assert_equal(s(:lvar, :strict), if_strict_condition)
assert_equal(:send, if_strict_body.type)
on_strict_receiver, on_strict_method, on_strict_arg = if_strict_body.children
assert_equal(nil, on_strict_receiver)
assert_equal(:required_prop_missing_from_serialize, on_strict_method)
assert_equal(:sym, on_strict_arg.type)
assert_equal(nil, if_strict_else)
end
# h[%<serialized_form>] = ...
assert_equal(:send, else_body.type)
receiver, method, h_key, h_val = else_body.children
assert_equal(s(:lvar, :h), receiver)
assert_equal(:[]=, method)
assert_equal(:str, h_key.type)
validate_lack_of_side_effects(h_val, whitelisted_methods_for_serialize)
end
private_class_method def self.validate_deserialize_hash_read(clause)
# val = hash[%<serialized_form>s]
assert_equal(:lvasgn, clause.type)
name, val = clause.children
assert_equal(:val, name)
assert_equal(:send, val.type)
receiver, method, arg = val.children
assert_equal(s(:lvar, :hash), receiver)
assert_equal(:[], method)
assert_equal(:str, arg.type)
end
private_class_method def self.validate_deserialize_ivar_set(clause)
# %<accessor_key>s = if val.nil?
# found -= 1 unless hash.key?(%<serialized_form>s)
# %<nil_handler>s
# else
# %<serialized_val>s
# end
assert_equal(:ivasgn, clause.type)
ivar_name, deser_val = clause.children
unless ivar_name.is_a?(Symbol)
raise ValidationError.new("Unexpected ivar: #{ivar_name}")
end
assert_equal(:if, deser_val.type)
condition, if_body, else_body = deser_val.children
assert_equal(s(:send, s(:lvar, :val), :nil?), condition)
assert_equal(:begin, if_body.type)
update_found, handle_nil = if_body.children
assert_equal(:if, update_found.type)
found_condition, found_if_body, found_else_body = update_found.children
assert_equal(:send, found_condition.type)
receiver, method, arg = found_condition.children
assert_equal(s(:lvar, :hash), receiver)
assert_equal(:key?, method)
assert_equal(:str, arg.type)
assert_equal(nil, found_if_body)
assert_equal(s(:op_asgn, s(:lvasgn, :found), :-, s(:int, 1)), found_else_body)
validate_deserialize_handle_nil(handle_nil)
if else_body.type == :kwbegin
rescue_expression, = else_body.children
assert_equal(:rescue, rescue_expression.type)
try, rescue_body = rescue_expression.children
validate_lack_of_side_effects(try, whitelisted_methods_for_deserialize)
assert_equal(:resbody, rescue_body.type)
exceptions, assignment, handler = rescue_body.children
assert_equal(:array, exceptions.type)
exceptions.children.each {|c| assert_equal(:const, c.type)}
assert_equal(:lvasgn, assignment.type)
assert_equal([:e], assignment.children)
deserialization_error, val_return = handler.children
assert_equal(:send, deserialization_error.type)
receiver, method, *args = deserialization_error.children
assert_equal(nil, receiver)
assert_equal(:raise_deserialization_error, method)
args.each {|a| validate_lack_of_side_effects(a, whitelisted_methods_for_deserialize)}
validate_lack_of_side_effects(val_return, whitelisted_methods_for_deserialize)
else
validate_lack_of_side_effects(else_body, whitelisted_methods_for_deserialize)
end
end
private_class_method def self.validate_deserialize_handle_nil(node)
case node.type
when :hash, :array, :str, :sym, :int, :float, :true, :false, :nil, :const # rubocop:disable Lint/BooleanSymbol
# Primitives and constants are safe
when :send
receiver, method, arg = node.children
if receiver.nil?
# required_prop_missing_from_deserialize(%<prop>)
assert_equal(:required_prop_missing_from_deserialize, method)
assert_equal(:sym, arg.type)
elsif receiver == self_class_decorator
# self.class.decorator.raise_nil_deserialize_error(%<serialized_form>)
assert_equal(:raise_nil_deserialize_error, method)
assert_equal(:str, arg.type)
elsif method == :default
# self.class.decorator.props_with_defaults.fetch(%<prop>).default
assert_equal(:send, receiver.type)
inner_receiver, inner_method, inner_arg = receiver.children
assert_equal(
s(:send, self_class_decorator, :props_with_defaults),
inner_receiver,
)
assert_equal(:fetch, inner_method)
assert_equal(:sym, inner_arg.type)
else
raise ValidationError.new("Unexpected receiver in nil handler: #{node.inspect}")
end
else
raise ValidationError.new("Unexpected nil handler: #{node.inspect}")
end
end
private_class_method def self.self_class_decorator
@self_class_decorator ||= s(:send, s(:send, s(:self), :class), :decorator).freeze
end
private_class_method def self.validate_lack_of_side_effects(node, whitelisted_methods_by_receiver_type)
case node.type
when :const
# This is ok, because we'll have validated what method has been called
# if applicable
when :hash, :array, :str, :sym, :int, :float, :true, :false, :nil, :self # rubocop:disable Lint/BooleanSymbol
# Primitives & self are ok
when :lvar, :arg, :ivar
# Reading local & instance variables & arguments is ok
unless node.children.all? {|c| c.is_a?(Symbol)}
raise ValidationError.new("Unexpected child for #{node.type}: #{node.inspect}")
end
when :args, :mlhs, :block, :begin, :if
# Blocks etc are read-only if their contents are read-only
node.children.each {|c| validate_lack_of_side_effects(c, whitelisted_methods_by_receiver_type) if c}
when :send
# Sends are riskier so check a whitelist
receiver, method, *args = node.children
if receiver
if receiver.type == :send
key = receiver
else
key = receiver.type
validate_lack_of_side_effects(receiver, whitelisted_methods_by_receiver_type)
end
if !whitelisted_methods_by_receiver_type[key]&.include?(method)
raise ValidationError.new("Unexpected method #{method} called on #{receiver.inspect}")
end
end
args.each do |arg|
validate_lack_of_side_effects(arg, whitelisted_methods_by_receiver_type)
end
else
raise ValidationError.new("Unexpected node type #{node.type}: #{node.inspect}")
end
end
private_class_method def self.assert_equal(expected, actual)
if expected != actual
raise ValidationError.new("Expected #{expected}, got #{actual}")
end
end
# Method calls generated by SerdeTransform
private_class_method def self.whitelisted_methods_for_serialize
@whitelisted_methods_for_serialize ||= {
lvar: %i{dup map transform_values transform_keys each_with_object nil? []= serialize},
ivar: %i[dup map transform_values transform_keys each_with_object serialize],
const: %i[checked_serialize deep_clone_object],
}
end
# Method calls generated by SerdeTransform
private_class_method def self.whitelisted_methods_for_deserialize
@whitelisted_methods_for_deserialize ||= {
lvar: %i{dup map transform_values transform_keys each_with_object nil? []= to_f},
const: %i[deserialize from_hash deep_clone_object],
}
end
end
end

View File

@ -0,0 +1,140 @@
# frozen_string_literal: true
# typed: false
module T::Props
# Helper for generating methods that replace themselves with a specialized
# version on first use. The main use case is when we want to generate a
# method using the full set of props on a class; we can't do that during
# prop definition because we have no way of knowing whether we are defining
# the last prop.
#
# See go/M8yrvzX2 (Stripe-internal) for discussion of security considerations.
# In outline, while `class_eval` is a bit scary, we believe that as long as
# all inputs are defined in version control (and this is enforced by calling
# `disable_lazy_evaluation!` appropriately), risk isn't significantly higher
# than with build-time codegen.
module HasLazilySpecializedMethods
extend T::Sig
class SourceEvaluationDisabled < RuntimeError
def initialize
super("Evaluation of lazily-defined methods is disabled")
end
end
# Disable any future evaluation of lazily-defined methods.
#
# This is intended to be called after startup but before interacting with
# the outside world, to limit attack surface for our `class_eval` use.
#
# Note it does _not_ prevent explicit calls to `eagerly_define_lazy_methods!`
# from working.
sig {void}
def self.disable_lazy_evaluation!
@lazy_evaluation_disabled ||= true
end
sig {returns(T::Boolean)}
def self.lazy_evaluation_enabled?
!defined?(@lazy_evaluation_disabled) || !@lazy_evaluation_disabled
end
module DecoratorMethods
extend T::Sig
sig {returns(T::Hash[Symbol, T.proc.returns(String)]).checked(:never)}
private def lazily_defined_methods
@lazily_defined_methods ||= {}
end
sig {returns(T::Hash[Symbol, T.untyped]).checked(:never)}
private def lazily_defined_vm_methods
@lazily_defined_vm_methods ||= {}
end
sig {params(name: Symbol).void}
private def eval_lazily_defined_method!(name)
if !HasLazilySpecializedMethods.lazy_evaluation_enabled?
raise SourceEvaluationDisabled.new
end
source = lazily_defined_methods.fetch(name).call
cls = decorated_class
cls.class_eval(source.to_s)
cls.send(:private, name)
end
sig {params(name: Symbol).void}
private def eval_lazily_defined_vm_method!(name)
if !HasLazilySpecializedMethods.lazy_evaluation_enabled?
raise SourceEvaluationDisabled.new
end
lazily_defined_vm_methods.fetch(name).call
cls = decorated_class
cls.send(:private, name)
end
sig {params(name: Symbol, blk: T.proc.returns(String)).void}
private def enqueue_lazy_method_definition!(name, &blk)
lazily_defined_methods[name] = blk
cls = decorated_class
if cls.method_defined?(name)
# Ruby does not emit "method redefined" warnings for aliased methods
# (more robust than undef_method that would create a small window in which the method doesn't exist)
cls.send(:alias_method, name, name)
end
cls.send(:define_method, name) do |*args|
self.class.decorator.send(:eval_lazily_defined_method!, name)
send(name, *args)
end
if cls.respond_to?(:ruby2_keywords, true)
cls.send(:ruby2_keywords, name)
end
cls.send(:private, name)
end
sig {params(name: Symbol, blk: T.untyped).void}
private def enqueue_lazy_vm_method_definition!(name, &blk)
lazily_defined_vm_methods[name] = blk
cls = decorated_class
cls.send(:define_method, name) do |*args|
self.class.decorator.send(:eval_lazily_defined_vm_method!, name)
send(name, *args)
end
if cls.respond_to?(:ruby2_keywords, true)
cls.send(:ruby2_keywords, name)
end
cls.send(:private, name)
end
sig {void}
def eagerly_define_lazy_methods!
return if lazily_defined_methods.empty?
source = lazily_defined_methods.values.map(&:call).map(&:to_s).join("\n\n")
cls = decorated_class
cls.class_eval(source)
lazily_defined_methods.each_key {|name| cls.send(:private, name)}
lazily_defined_methods.clear
end
sig {void}
def eagerly_define_lazy_vm_methods!
return if lazily_defined_vm_methods.empty?
lazily_defined_vm_methods.values.map(&:call)
cls = decorated_class
lazily_defined_vm_methods.each_key {|name| cls.send(:private, name)}
lazily_defined_vm_methods.clear
end
end
end
end

View File

@ -0,0 +1,89 @@
# frozen_string_literal: true
# typed: false
module T::Props::Optional
include T::Props::Plugin
end
##############################################
# NB: This must stay in the same file where T::Props::Optional is defined due to
# T::Props::Decorator#apply_plugin; see https://git.corp.stripe.com/stripe-internal/pay-server/blob/fc7f15593b49875f2d0499ffecfd19798bac05b3/chalk/odm/lib/chalk-odm/document_decorator.rb#L716-L717
module T::Props::Optional::DecoratorMethods
extend T::Sig
# Heads up!
#
# There are already too many ad-hoc options on the prop DSL.
#
# We have already done a lot of work to remove unnecessary and confusing
# options. If you're considering adding a new rule key, please come chat with
# the Sorbet team first, as we'd really like to learn more about how to best
# solve the problem you're encountering.
VALID_RULE_KEYS = {
default: true,
factory: true,
}.freeze
private_constant :VALID_RULE_KEYS
DEFAULT_SETTER_RULE_KEY = :_t_props_private_apply_default
private_constant :DEFAULT_SETTER_RULE_KEY
def valid_rule_key?(key)
super || VALID_RULE_KEYS[key]
end
def prop_optional?(prop)
prop_rules(prop)[:fully_optional]
end
def compute_derived_rules(rules)
rules[:fully_optional] = !T::Props::Utils.need_nil_write_check?(rules)
rules[:need_nil_read_check] = T::Props::Utils.need_nil_read_check?(rules)
end
# checked(:never) - O(runtime object construction)
sig {returns(T::Hash[Symbol, T::Props::Private::ApplyDefault]).checked(:never)}
attr_reader :props_with_defaults
# checked(:never) - O(runtime object construction)
sig {returns(T::Hash[Symbol, T::Props::Private::SetterFactory::SetterProc]).checked(:never)}
attr_reader :props_without_defaults
def add_prop_definition(prop, rules)
compute_derived_rules(rules)
default_setter = T::Props::Private::ApplyDefault.for(decorated_class, rules)
if default_setter
@props_with_defaults ||= {}
@props_with_defaults[prop] = default_setter
props_without_defaults&.delete(prop) # Handle potential override
rules[DEFAULT_SETTER_RULE_KEY] = default_setter
else
@props_without_defaults ||= {}
@props_without_defaults[prop] = rules.fetch(:setter_proc)
props_with_defaults&.delete(prop) # Handle potential override
end
super
end
def prop_validate_definition!(name, cls, rules, type)
result = super
if rules.key?(:default) && rules.key?(:factory)
raise ArgumentError.new("Setting both :default and :factory is invalid. See: go/chalk-docs")
end
result
end
def has_default?(rules)
rules.include?(DEFAULT_SETTER_RULE_KEY)
end
def get_default(rules, instance_class)
rules[DEFAULT_SETTER_RULE_KEY]&.default
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
# typed: false
module T::Props::Plugin
include T::Props
extend T::Helpers
module ClassMethods
def included(child)
super
child.plugin(self)
end
end
mixes_in_class_methods(ClassMethods)
module Private
# These need to be non-instance methods so we can use them without prematurely creating the
# child decorator in `model_inherited` (see comments there for details).
#
# The dynamic constant access below forces this file to be `typed: false`
def self.apply_class_methods(plugin, target)
if plugin.const_defined?('ClassMethods')
# FIXME: This will break preloading, selective test execution, etc if `mod::ClassMethods`
# is ever defined in a separate file from `mod`.
target.extend(plugin::ClassMethods)
end
end
def self.apply_decorator_methods(plugin, target)
if plugin.const_defined?('DecoratorMethods')
# FIXME: This will break preloading, selective test execution, etc if `mod::DecoratorMethods`
# is ever defined in a separate file from `mod`.
target.extend(plugin::DecoratorMethods)
end
end
end
end

View File

@ -0,0 +1,107 @@
# frozen_string_literal: true
# typed: true
module T::Props::PrettyPrintable
include T::Props::Plugin
# Return a string representation of this object and all of its props
def inspect
T.unsafe(T.cast(self, Object).class).decorator.inspect_instance(self)
end
# Override the PP gem with something that's similar, but gives us a hook
# to do redaction
def pretty_inspect
T.unsafe(T.cast(self, Object).class).decorator.inspect_instance(self, multiline: true)
end
module DecoratorMethods
extend T::Sig
sig {params(key: Symbol).returns(T::Boolean).checked(:never)}
def valid_rule_key?(key)
super || key == :inspect
end
sig do
params(instance: T::Props::PrettyPrintable, multiline: T::Boolean, indent: String)
.returns(String)
end
def inspect_instance(instance, multiline: false, indent: ' ')
components =
inspect_instance_components(
instance,
multiline: multiline,
indent: indent
)
.reject(&:empty?)
# Not using #<> here as that makes pry highlight these objects
# as if they were all comments, whereas this makes them look
# like the structured thing they are.
if multiline
"#{components[0]}:\n" + T.must(components[1..-1]).join("\n")
else
"<#{components.join(' ')}>"
end
end
sig do
params(instance: T::Props::PrettyPrintable, multiline: T::Boolean, indent: String)
.returns(T::Array[String])
end
private def inspect_instance_components(instance, multiline:, indent:)
pretty_props = T.unsafe(self).all_props.map do |prop|
[prop, inspect_prop_value(instance, prop, multiline: multiline, indent: indent)]
end
joined_props = join_props_with_pretty_values(
pretty_props,
multiline: multiline,
indent: indent
)
[
T.unsafe(self).decorated_class.to_s,
joined_props,
]
end
sig do
params(instance: T::Props::PrettyPrintable, prop: Symbol, multiline: T::Boolean, indent: String)
.returns(String)
.checked(:never)
end
private def inspect_prop_value(instance, prop, multiline:, indent:)
val = T.unsafe(self).get(instance, prop)
rules = T.unsafe(self).prop_rules(prop)
if (custom_inspect = rules[:inspect])
if T::Utils.arity(custom_inspect) == 1
custom_inspect.call(val)
else
custom_inspect.call(val, {multiline: multiline, indent: indent})
end
elsif rules[:sensitivity] && !rules[:sensitivity].empty? && !val.nil?
"<REDACTED #{rules[:sensitivity].join(', ')}>"
else
val.inspect
end
end
sig do
params(pretty_kvs: T::Array[[Symbol, String]], multiline: T::Boolean, indent: String)
.returns(String)
end
private def join_props_with_pretty_values(pretty_kvs, multiline:, indent: ' ')
pairs = pretty_kvs
.sort_by {|k, _v| k.to_s}
.map {|k, v| "#{k}=#{v}"}
if multiline
indent + pairs.join("\n#{indent}")
else
pairs.join(', ')
end
end
end
end

View File

@ -0,0 +1,170 @@
# frozen_string_literal: true
# typed: strict
module T::Props
module Private
class ApplyDefault
extend T::Sig
extend T::Helpers
abstract!
# checked(:never) - O(object construction x prop count)
sig {returns(SetterFactory::SetterProc).checked(:never)}
attr_reader :setter_proc
# checked(:never) - We do this with `T.let` instead
sig {params(accessor_key: Symbol, setter_proc: SetterFactory::SetterProc).void.checked(:never)}
def initialize(accessor_key, setter_proc)
@accessor_key = T.let(accessor_key, Symbol)
@setter_proc = T.let(setter_proc, SetterFactory::SetterProc)
end
# checked(:never) - O(object construction x prop count)
sig {abstract.returns(T.untyped).checked(:never)}
def default; end
# checked(:never) - O(object construction x prop count)
sig {abstract.params(instance: T.all(T::Props::Optional, Object)).void.checked(:never)}
def set_default(instance); end
NO_CLONE_TYPES = T.let([TrueClass, FalseClass, NilClass, Symbol, Numeric, T::Enum].freeze, T::Array[Module])
# checked(:never) - Rules hash is expensive to check
sig {params(cls: Module, rules: T::Hash[Symbol, T.untyped]).returns(T.nilable(ApplyDefault)).checked(:never)}
def self.for(cls, rules)
accessor_key = rules.fetch(:accessor_key)
setter = rules.fetch(:setter_proc)
if rules.key?(:factory)
ApplyDefaultFactory.new(cls, rules.fetch(:factory), accessor_key, setter)
elsif rules.key?(:default)
default = rules.fetch(:default)
case default
when *NO_CLONE_TYPES
return ApplyPrimitiveDefault.new(default, accessor_key, setter)
when String
if default.frozen?
return ApplyPrimitiveDefault.new(default, accessor_key, setter)
end
when Array
if default.empty? && default.class == Array
return ApplyEmptyArrayDefault.new(accessor_key, setter)
end
when Hash
if default.empty? && default.default.nil? && T.unsafe(default).default_proc.nil? && default.class == Hash
return ApplyEmptyHashDefault.new(accessor_key, setter)
end
end
ApplyComplexDefault.new(default, accessor_key, setter)
else
nil
end
end
end
class ApplyFixedDefault < ApplyDefault
abstract!
# checked(:never) - We do this with `T.let` instead
sig {params(default: BasicObject, accessor_key: Symbol, setter_proc: SetterFactory::SetterProc).void.checked(:never)}
def initialize(default, accessor_key, setter_proc)
# FIXME: Ideally we'd check here that the default is actually a valid
# value for this field, but existing code relies on the fact that we don't.
#
# :(
#
# setter_proc.call(default)
@default = T.let(default, BasicObject)
super(accessor_key, setter_proc)
end
# checked(:never) - O(object construction x prop count)
sig {override.params(instance: T.all(T::Props::Optional, Object)).void.checked(:never)}
def set_default(instance)
instance.instance_variable_set(@accessor_key, default)
end
end
class ApplyPrimitiveDefault < ApplyFixedDefault
# checked(:never) - O(object construction x prop count)
sig {override.returns(T.untyped).checked(:never)}
attr_reader :default
end
class ApplyComplexDefault < ApplyFixedDefault
# checked(:never) - O(object construction x prop count)
sig {override.returns(T.untyped).checked(:never)}
def default
T::Props::Utils.deep_clone_object(@default)
end
end
# Special case since it's so common, and a literal `[]` is meaningfully
# faster than falling back to ApplyComplexDefault or even calling
# `some_empty_array.dup`
class ApplyEmptyArrayDefault < ApplyDefault
# checked(:never) - O(object construction x prop count)
sig {override.params(instance: T.all(T::Props::Optional, Object)).void.checked(:never)}
def set_default(instance)
instance.instance_variable_set(@accessor_key, [])
end
# checked(:never) - O(object construction x prop count)
sig {override.returns(T::Array[T.untyped]).checked(:never)}
def default
[]
end
end
# Special case since it's so common, and a literal `{}` is meaningfully
# faster than falling back to ApplyComplexDefault or even calling
# `some_empty_hash.dup`
class ApplyEmptyHashDefault < ApplyDefault
# checked(:never) - O(object construction x prop count)
sig {override.params(instance: T.all(T::Props::Optional, Object)).void.checked(:never)}
def set_default(instance)
instance.instance_variable_set(@accessor_key, {})
end
# checked(:never) - O(object construction x prop count)
sig {override.returns(T::Hash[T.untyped, T.untyped]).checked(:never)}
def default
{}
end
end
class ApplyDefaultFactory < ApplyDefault
# checked(:never) - We do this with `T.let` instead
sig do
params(
cls: Module,
factory: T.any(Proc, Method),
accessor_key: Symbol,
setter_proc: SetterFactory::SetterProc,
)
.void
.checked(:never)
end
def initialize(cls, factory, accessor_key, setter_proc)
@class = T.let(cls, Module)
@factory = T.let(factory, T.any(Proc, Method))
super(accessor_key, setter_proc)
end
# checked(:never) - O(object construction x prop count)
sig {override.params(instance: T.all(T::Props::Optional, Object)).void.checked(:never)}
def set_default(instance)
# Use the actual setter to validate the factory returns a legitimate
# value every time
instance.instance_exec(default, &@setter_proc)
end
# checked(:never) - O(object construction x prop count)
sig {override.returns(T.untyped).checked(:never)}
def default
@class.class_exec(&@factory)
end
end
end
end

View File

@ -0,0 +1,160 @@
# frozen_string_literal: true
# typed: strict
module T::Props
module Private
# Generates a specialized `deserialize` implementation for a subclass of
# T::Props::Serializable.
#
# The basic idea is that we analyze the props and for each prop, generate
# the simplest possible logic as a block of Ruby source, so that we don't
# pay the cost of supporting types like T:::Hash[CustomType, SubstructType]
# when deserializing a simple Integer. Then we join those together,
# with a little shared logic to be able to detect when we get input keys
# that don't match any prop.
module DeserializerGenerator
extend T::Sig
# Generate a method that takes a T::Hash[String, T.untyped] representing
# serialized props, sets instance variables for each prop found in the
# input, and returns the count of we props set (which we can use to check
# for unexpected input keys with minimal effect on the fast path).
sig do
params(
props: T::Hash[Symbol, T::Hash[Symbol, T.untyped]],
defaults: T::Hash[Symbol, T::Props::Private::ApplyDefault],
)
.returns(String)
.checked(:never)
end
def self.generate(props, defaults)
stored_props = props.reject {|_, rules| rules[:dont_store]}
parts = stored_props.map do |prop, rules|
# All of these strings should already be validated (directly or
# indirectly) in `validate_prop_name`, so we don't bother with a nice
# error message, but we double check here to prevent a refactoring
# from introducing a security vulnerability.
raise unless T::Props::Decorator::SAFE_NAME.match?(prop.to_s)
hash_key = rules.fetch(:serialized_form)
raise unless T::Props::Decorator::SAFE_NAME.match?(hash_key)
ivar_name = rules.fetch(:accessor_key).to_s
raise unless ivar_name.start_with?('@') && T::Props::Decorator::SAFE_NAME.match?(ivar_name[1..-1])
transformation = SerdeTransform.generate(
T::Utils::Nilable.get_underlying_type_object(rules.fetch(:type_object)),
SerdeTransform::Mode::DESERIALIZE,
'val'
)
transformed_val = if transformation
# Rescuing exactly NoMethodError is intended as a temporary hack
# to preserve the semantics from before codegen. More generally
# we are inconsistent about typechecking on deser and need to decide
# our strategy here.
<<~RUBY
begin
#{transformation}
rescue NoMethodError => e
raise_deserialization_error(
#{prop.inspect},
val,
e,
)
val
end
RUBY
else
'val'
end
nil_handler = generate_nil_handler(
prop: prop,
serialized_form: hash_key,
default: defaults[prop],
nilable_type: T::Props::Utils.optional_prop?(rules),
raise_on_nil_write: !!rules[:raise_on_nil_write],
)
<<~RUBY
val = hash[#{hash_key.inspect}]
#{ivar_name} = if val.nil?
found -= 1 unless hash.key?(#{hash_key.inspect})
#{nil_handler}
else
#{transformed_val}
end
RUBY
end
<<~RUBY
def __t_props_generated_deserialize(hash)
found = #{stored_props.size}
#{parts.join("\n\n")}
found
end
RUBY
end
# This is very similar to what we do in ApplyDefault, but has a few
# key differences that mean we don't just re-use the code:
#
# 1. Where the logic in construction is that we generate a default
# if & only if the prop key isn't present in the input, here we'll
# generate a default even to override an explicit nil, but only
# if the prop is actually required.
# 2. Since we're generating raw Ruby source, we can remove a layer
# of indirection for marginally better performance; this seems worth
# it for the common cases of literals and empty arrays/hashes.
# 3. We need to care about the distinction between `raise_on_nil_write`
# and actually non-nilable, where new-instance construction doesn't.
#
# So we fall back to ApplyDefault only when one of the cases just
# mentioned doesn't apply.
sig do
params(
prop: Symbol,
serialized_form: String,
default: T.nilable(ApplyDefault),
nilable_type: T::Boolean,
raise_on_nil_write: T::Boolean,
)
.returns(String)
.checked(:never)
end
private_class_method def self.generate_nil_handler(
prop:,
serialized_form:,
default:,
nilable_type:,
raise_on_nil_write:
)
if !nilable_type
case default
when NilClass
"self.class.decorator.raise_nil_deserialize_error(#{serialized_form.inspect})"
when ApplyPrimitiveDefault
literal = default.default
case literal
when String, Integer, Symbol, Float, TrueClass, FalseClass, NilClass
literal.inspect
else
"self.class.decorator.props_with_defaults.fetch(#{prop.inspect}).default"
end
when ApplyEmptyArrayDefault
'[]'
when ApplyEmptyHashDefault
'{}'
else
"self.class.decorator.props_with_defaults.fetch(#{prop.inspect}).default"
end
elsif raise_on_nil_write
"required_prop_missing_from_deserialize(#{prop.inspect})"
else
'nil'
end
end
end
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
# typed: false
module T::Props
module Private
module Parse
def parse(source)
@current_ruby ||= require_parser(:CurrentRuby)
@current_ruby.parse(source)
end
def s(type, *children)
@node ||= require_parser(:AST, :Node)
@node.new(type, children)
end
private def require_parser(*constants)
# This is an optional dependency for sorbet-runtime in general,
# but is required here
require 'parser/current'
# Hack to work around the static checker thinking the constant is
# undefined
cls = Kernel.const_get(:Parser, true)
while (const = constants.shift)
cls = cls.const_get(const, false)
end
cls
end
end
end
end

View File

@ -0,0 +1,186 @@
# frozen_string_literal: true
# typed: strict
module T::Props
module Private
module SerdeTransform
extend T::Sig
class Serialize; end
private_constant :Serialize
class Deserialize; end
private_constant :Deserialize
ModeType = T.type_alias {T.any(Serialize, Deserialize)}
private_constant :ModeType
module Mode
SERIALIZE = T.let(Serialize.new.freeze, Serialize)
DESERIALIZE = T.let(Deserialize.new.freeze, Deserialize)
end
NO_TRANSFORM_TYPES = T.let(
[TrueClass, FalseClass, NilClass, Symbol, String].freeze,
T::Array[Module],
)
private_constant :NO_TRANSFORM_TYPES
sig do
params(
type: T::Types::Base,
mode: ModeType,
varname: String,
)
.returns(T.nilable(String))
.checked(:never)
end
def self.generate(type, mode, varname)
case type
when T::Types::TypedArray
inner = generate(type.type, mode, 'v')
if inner.nil?
"#{varname}.dup"
else
"#{varname}.map {|v| #{inner}}"
end
when T::Types::TypedSet
inner = generate(type.type, mode, 'v')
if inner.nil?
"#{varname}.dup"
else
"Set.new(#{varname}) {|v| #{inner}}"
end
when T::Types::TypedHash
keys = generate(type.keys, mode, 'k')
values = generate(type.values, mode, 'v')
if keys && values
"#{varname}.each_with_object({}) {|(k,v),h| h[#{keys}] = #{values}}"
elsif keys
"#{varname}.transform_keys {|k| #{keys}}"
elsif values
"#{varname}.transform_values {|v| #{values}}"
else
"#{varname}.dup"
end
when T::Types::Simple
raw = type.raw_type
if NO_TRANSFORM_TYPES.any? {|cls| raw <= cls}
nil
elsif raw <= Float
case mode
when Deserialize then "#{varname}.to_f"
when Serialize then nil
else T.absurd(mode)
end
elsif raw <= Numeric
nil
elsif raw < T::Props::Serializable
handle_serializable_subtype(varname, raw, mode)
elsif raw.singleton_class < T::Props::CustomType
handle_custom_type(varname, T.unsafe(raw), mode)
elsif T::Configuration.scalar_types.include?(raw.name)
# It's a bit of a hack that this is separate from NO_TRANSFORM_TYPES
# and doesn't check inheritance (like `T::Props::CustomType.scalar_type?`
# does), but it covers the main use case (pay-server's custom `Boolean`
# module) without either requiring `T::Configuration.scalar_types` to
# accept modules instead of strings (which produces load-order issues
# and subtle behavior changes) or eating the performance cost of doing
# an inheritance check by manually crawling a class hierarchy and doing
# string comparisons.
nil
else
"T::Props::Utils.deep_clone_object(#{varname})"
end
when T::Types::Union
non_nil_type = T::Utils.unwrap_nilable(type)
if non_nil_type
inner = generate(non_nil_type, mode, varname)
if inner.nil?
nil
else
"#{varname}.nil? ? nil : #{inner}"
end
elsif type.types.all? {|t| generate(t, mode, varname).nil?}
# Handle, e.g., T::Boolean
nil
else
# We currently deep_clone_object if the type was T.any(Integer, Float).
# When we get better support for union types (maybe this specific
# union type, because it would be a replacement for
# Chalk::ODM::DeprecatedNumemric), we could opt to special case
# this union to have no specific serde transform (the only reason
# why Float has a special case is because round tripping through
# JSON might normalize Floats to Integers)
"T::Props::Utils.deep_clone_object(#{varname})"
end
when T::Types::Intersection
dynamic_fallback = "T::Props::Utils.deep_clone_object(#{varname})"
# Transformations for any members of the intersection type where we
# know what we need to do and did not have to fall back to the
# dynamic deep clone method.
#
# NB: This deliberately does include `nil`, which means we know we
# don't need to do any transforming.
inner_known = type.types
.map {|t| generate(t, mode, varname)}
.reject {|t| t == dynamic_fallback}
.uniq
if inner_known.size != 1
# If there were no cases where we could tell what we need to do,
# e.g. if this is `T.all(SomethingWeird, WhoKnows)`, just use the
# dynamic fallback.
#
# If there were multiple cases and they weren't consistent, e.g.
# if this is `T.all(String, T::Array[Integer])`, the type is probably
# bogus/uninhabited, but use the dynamic fallback because we still
# don't have a better option, and this isn't the place to raise that
# error.
dynamic_fallback
else
# This is probably something like `T.all(String, SomeMarker)` or
# `T.all(SomeEnum, T.deprecated_enum(SomeEnum::FOO))` and we should
# treat it like String or SomeEnum even if we don't know what to do
# with the rest of the type.
inner_known.first
end
when T::Types::Enum
generate(T::Utils.lift_enum(type), mode, varname)
else
"T::Props::Utils.deep_clone_object(#{varname})"
end
end
sig {params(varname: String, type: Module, mode: ModeType).returns(String).checked(:never)}
private_class_method def self.handle_serializable_subtype(varname, type, mode)
case mode
when Serialize
"#{varname}.serialize(strict)"
when Deserialize
type_name = T.must(module_name(type))
"#{type_name}.from_hash(#{varname})"
else
T.absurd(mode)
end
end
sig {params(varname: String, type: Module, mode: ModeType).returns(String).checked(:never)}
private_class_method def self.handle_custom_type(varname, type, mode)
case mode
when Serialize
"T::Props::CustomType.checked_serialize(#{varname})"
when Deserialize
type_name = T.must(module_name(type))
"#{type_name}.deserialize(#{varname})"
else
T.absurd(mode)
end
end
sig {params(type: Module).returns(T.nilable(String)).checked(:never)}
private_class_method def self.module_name(type)
T::Configuration.module_name_mangler.call(type)
end
end
end
end

View File

@ -0,0 +1,76 @@
# frozen_string_literal: true
# typed: strict
module T::Props
module Private
# Generates a specialized `serialize` implementation for a subclass of
# T::Props::Serializable.
#
# The basic idea is that we analyze the props and for each prop, generate
# the simplest possible logic as a block of Ruby source, so that we don't
# pay the cost of supporting types like T:::Hash[CustomType, SubstructType]
# when serializing a simple Integer. Then we join those together,
# with a little shared logic to be able to detect when we get input keys
# that don't match any prop.
module SerializerGenerator
extend T::Sig
sig do
params(
props: T::Hash[Symbol, T::Hash[Symbol, T.untyped]],
)
.returns(String)
.checked(:never)
end
def self.generate(props)
stored_props = props.reject {|_, rules| rules[:dont_store]}
parts = stored_props.map do |prop, rules|
# All of these strings should already be validated (directly or
# indirectly) in `validate_prop_name`, so we don't bother with a nice
# error message, but we double check here to prevent a refactoring
# from introducing a security vulnerability.
raise unless T::Props::Decorator::SAFE_NAME.match?(prop.to_s)
hash_key = rules.fetch(:serialized_form)
raise unless T::Props::Decorator::SAFE_NAME.match?(hash_key)
ivar_name = rules.fetch(:accessor_key).to_s
raise unless ivar_name.start_with?('@') && T::Props::Decorator::SAFE_NAME.match?(ivar_name[1..-1])
transformed_val = SerdeTransform.generate(
T::Utils::Nilable.get_underlying_type_object(rules.fetch(:type_object)),
SerdeTransform::Mode::SERIALIZE,
ivar_name
) || ivar_name
nil_asserter =
if rules[:fully_optional]
''
else
"required_prop_missing_from_serialize(#{prop.inspect}) if strict"
end
# Don't serialize values that are nil to save space (both the
# nil value itself and the field name in the serialized BSON
# document)
<<~RUBY
if #{ivar_name}.nil?
#{nil_asserter}
else
h[#{hash_key.inspect}] = #{transformed_val}
end
RUBY
end
<<~RUBY
def __t_props_generated_serialize(strict)
h = {}
#{parts.join("\n\n")}
h
end
RUBY
end
end
end
end

View File

@ -0,0 +1,197 @@
# frozen_string_literal: true
# typed: strict
module T::Props
module Private
module SetterFactory
extend T::Sig
SetterProc = T.type_alias {T.proc.params(val: T.untyped).void}
ValidateProc = T.type_alias {T.proc.params(prop: Symbol, value: T.untyped).void}
sig do
params(
klass: T.all(Module, T::Props::ClassMethods),
prop: Symbol,
rules: T::Hash[Symbol, T.untyped]
)
.returns(SetterProc)
.checked(:never)
end
def self.build_setter_proc(klass, prop, rules)
# Our nil check works differently than a simple T.nilable for various
# reasons (including the `raise_on_nil_write` setting and the existence
# of defaults & factories), so unwrap any T.nilable and do a check
# manually.
non_nil_type = T::Utils::Nilable.get_underlying_type_object(rules.fetch(:type_object))
accessor_key = rules.fetch(:accessor_key)
validate = rules[:setter_validate]
# It seems like a bug that this affects the behavior of setters, but
# some existing code relies on this behavior
has_explicit_nil_default = rules.key?(:default) && rules.fetch(:default).nil?
# Use separate methods in order to ensure that we only close over necessary
# variables
if !T::Props::Utils.need_nil_write_check?(rules) || has_explicit_nil_default
if validate.nil? && non_nil_type.is_a?(T::Types::Simple)
simple_nilable_proc(prop, accessor_key, non_nil_type.raw_type, klass)
else
nilable_proc(prop, accessor_key, non_nil_type, klass, validate)
end
else
if validate.nil? && non_nil_type.is_a?(T::Types::Simple)
simple_non_nil_proc(prop, accessor_key, non_nil_type.raw_type, klass)
else
non_nil_proc(prop, accessor_key, non_nil_type, klass, validate)
end
end
end
sig do
params(
prop: Symbol,
accessor_key: Symbol,
non_nil_type: Module,
klass: T.all(Module, T::Props::ClassMethods),
)
.returns(SetterProc)
end
private_class_method def self.simple_non_nil_proc(prop, accessor_key, non_nil_type, klass)
proc do |val|
unless val.is_a?(non_nil_type)
T::Props::Private::SetterFactory.raise_pretty_error(
klass,
prop,
T::Utils.coerce(non_nil_type),
val,
)
end
instance_variable_set(accessor_key, val)
end
end
sig do
params(
prop: Symbol,
accessor_key: Symbol,
non_nil_type: T::Types::Base,
klass: T.all(Module, T::Props::ClassMethods),
validate: T.nilable(ValidateProc)
)
.returns(SetterProc)
end
private_class_method def self.non_nil_proc(prop, accessor_key, non_nil_type, klass, validate)
proc do |val|
# this use of recursively_valid? is intentional: unlike for
# methods, we want to make sure data at the 'edge'
# (e.g. models that go into databases or structs serialized
# from disk) are correct, so we use more thorough runtime
# checks there
if non_nil_type.recursively_valid?(val)
validate&.call(prop, val)
else
T::Props::Private::SetterFactory.raise_pretty_error(
klass,
prop,
non_nil_type,
val,
)
end
instance_variable_set(accessor_key, val)
end
end
sig do
params(
prop: Symbol,
accessor_key: Symbol,
non_nil_type: Module,
klass: T.all(Module, T::Props::ClassMethods),
)
.returns(SetterProc)
end
private_class_method def self.simple_nilable_proc(prop, accessor_key, non_nil_type, klass)
proc do |val|
if val.nil?
instance_variable_set(accessor_key, nil)
elsif val.is_a?(non_nil_type)
instance_variable_set(accessor_key, val)
else
T::Props::Private::SetterFactory.raise_pretty_error(
klass,
prop,
T::Utils.coerce(non_nil_type),
val,
)
instance_variable_set(accessor_key, val)
end
end
end
sig do
params(
prop: Symbol,
accessor_key: Symbol,
non_nil_type: T::Types::Base,
klass: T.all(Module, T::Props::ClassMethods),
validate: T.nilable(ValidateProc),
)
.returns(SetterProc)
end
private_class_method def self.nilable_proc(prop, accessor_key, non_nil_type, klass, validate)
proc do |val|
if val.nil?
instance_variable_set(accessor_key, nil)
# this use of recursively_valid? is intentional: unlike for
# methods, we want to make sure data at the 'edge'
# (e.g. models that go into databases or structs serialized
# from disk) are correct, so we use more thorough runtime
# checks there
elsif non_nil_type.recursively_valid?(val)
validate&.call(prop, val)
instance_variable_set(accessor_key, val)
else
T::Props::Private::SetterFactory.raise_pretty_error(
klass,
prop,
non_nil_type,
val,
)
instance_variable_set(accessor_key, val)
end
end
end
sig do
params(
klass: T.all(Module, T::Props::ClassMethods),
prop: Symbol,
type: T.any(T::Types::Base, Module),
val: T.untyped,
)
.void
end
def self.raise_pretty_error(klass, prop, type, val)
base_message = "Can't set #{klass.name}.#{prop} to #{val.inspect} (instance of #{val.class}) - need a #{type}"
pretty_message = "Parameter '#{prop}': #{base_message}\n"
caller_loc = caller_locations&.find {|l| !l.to_s.include?('sorbet-runtime/lib/types/props')}
if caller_loc
pretty_message += "Caller: #{caller_loc.path}:#{caller_loc.lineno}\n"
end
T::Configuration.call_validation_error_handler(
nil,
message: base_message,
pretty_message: pretty_message,
kind: 'Parameter',
name: prop,
type: type,
value: val,
location: caller_loc,
)
end
end
end
end

View File

@ -0,0 +1,374 @@
# frozen_string_literal: true
# typed: false
module T::Props::Serializable
include T::Props::Plugin
# Required because we have special handling for `optional: false`
include T::Props::Optional
# Required because we have special handling for extra_props
include T::Props::PrettyPrintable
# Serializes this object to a hash, suitable for conversion to
# JSON/BSON.
#
# @param strict [T::Boolean] (true) If false, do not raise an
# exception if this object has mandatory props with missing
# values.
# @return [Hash] A serialization of this object.
def serialize(strict=true)
begin
h = __t_props_generated_serialize(strict)
rescue => e
msg = self.class.decorator.message_with_generated_source_context(
e,
:__t_props_generated_serialize,
:generate_serialize_source
)
if msg
begin
raise e.class.new(msg)
rescue ArgumentError
raise TypeError.new(msg)
end
else
raise
end
end
h.merge!(@_extra_props) if defined?(@_extra_props)
h
end
private def __t_props_generated_serialize(strict)
# No-op; will be overridden if there are any props.
#
# To see the definition for class `Foo`, run `Foo.decorator.send(:generate_serialize_source)`
{}
end
# Populates the property values on this object with the values
# from a hash. In general, prefer to use {.from_hash} to construct
# a new instance, instead of loading into an existing instance.
#
# @param hash [Hash<String, Object>] The hash to take property
# values from.
# @param strict [T::Boolean] (false) If true, raise an exception if
# the hash contains keys that do not correspond to any known
# props on this instance.
# @return [void]
def deserialize(hash, strict=false)
begin
hash_keys_matching_props = __t_props_generated_deserialize(hash)
rescue => e
msg = self.class.decorator.message_with_generated_source_context(
e,
:__t_props_generated_deserialize,
:generate_deserialize_source
)
if msg
begin
raise e.class.new(msg)
rescue ArgumentError
raise TypeError.new(msg)
end
else
raise
end
end
if hash.size > hash_keys_matching_props
serialized_forms = self.class.decorator.prop_by_serialized_forms
extra = hash.reject {|k, _| serialized_forms.key?(k)}
# `extra` could still be empty here if the input matches a `dont_store` prop;
# historically, we just ignore those
if !extra.empty?
if strict
raise "Unknown properties for #{self.class.name}: #{extra.keys.inspect}"
else
@_extra_props = extra
end
end
end
end
private def __t_props_generated_deserialize(hash)
# No-op; will be overridden if there are any props.
#
# To see the definition for class `Foo`, run `Foo.decorator.send(:generate_deserialize_source)`
0
end
# with() will clone the old object to the new object and merge the specified props to the new object.
def with(changed_props)
with_existing_hash(changed_props, existing_hash: self.serialize)
end
private def recursive_stringify_keys(obj)
if obj.is_a?(Hash)
new_obj = obj.class.new
obj.each do |k, v|
new_obj[k.to_s] = recursive_stringify_keys(v)
end
elsif obj.is_a?(Array)
new_obj = obj.map {|v| recursive_stringify_keys(v)}
else
new_obj = obj
end
new_obj
end
private def with_existing_hash(changed_props, existing_hash:)
serialized = existing_hash
new_val = self.class.from_hash(serialized.merge(recursive_stringify_keys(changed_props)))
old_extra = self.instance_variable_get(:@_extra_props) if self.instance_variable_defined?(:@_extra_props)
new_extra = new_val.instance_variable_get(:@_extra_props) if new_val.instance_variable_defined?(:@_extra_props)
if old_extra != new_extra
difference =
if old_extra
new_extra.reject {|k, v| old_extra[k] == v}
else
new_extra
end
raise ArgumentError.new("Unexpected arguments: input(#{changed_props}), unexpected(#{difference})")
end
new_val
end
# Asserts if this property is missing during strict serialize
private def required_prop_missing_from_serialize(prop)
if defined?(@_required_props_missing_from_deserialize) &&
@_required_props_missing_from_deserialize&.include?(prop)
# If the prop was already missing during deserialization, that means the application
# code already had to deal with a nil value, which means we wouldn't be accomplishing
# much by raising here (other than causing an unnecessary breakage).
T::Configuration.log_info_handler(
"chalk-odm: missing required property in serialize",
prop: prop, class: self.class.name, id: self.class.decorator.get_id(self)
)
else
raise TypeError.new("#{self.class.name}.#{prop} not set for non-optional prop")
end
end
# Marks this property as missing during deserialize
private def required_prop_missing_from_deserialize(prop)
@_required_props_missing_from_deserialize ||= Set[]
@_required_props_missing_from_deserialize << prop
nil
end
private def raise_deserialization_error(prop_name, value, orig_error)
T::Configuration.soft_assert_handler(
'Deserialization error (probably unexpected stored type)',
storytime: {
klass: self.class,
prop: prop_name,
value: value,
error: orig_error.message,
notify: 'djudd'
}
)
end
end
##############################################
# NB: This must stay in the same file where T::Props::Serializable is defined due to
# T::Props::Decorator#apply_plugin; see https://git.corp.stripe.com/stripe-internal/pay-server/blob/fc7f15593b49875f2d0499ffecfd19798bac05b3/chalk/odm/lib/chalk-odm/document_decorator.rb#L716-L717
module T::Props::Serializable::DecoratorMethods
include T::Props::HasLazilySpecializedMethods::DecoratorMethods
# Heads up!
#
# There are already too many ad-hoc options on the prop DSL.
#
# We have already done a lot of work to remove unnecessary and confusing
# options. If you're considering adding a new rule key, please come chat with
# the Sorbet team first, as we'd really like to learn more about how to best
# solve the problem you're encountering.
VALID_RULE_KEYS = {dont_store: true, name: true, raise_on_nil_write: true}.freeze
private_constant :VALID_RULE_KEYS
def valid_rule_key?(key)
super || VALID_RULE_KEYS[key]
end
def required_props
@class.props.select {|_, v| T::Props::Utils.required_prop?(v)}.keys
end
def prop_dont_store?(prop)
prop_rules(prop)[:dont_store]
end
def prop_by_serialized_forms
@class.prop_by_serialized_forms
end
def from_hash(hash, strict=false)
raise ArgumentError.new("#{hash.inspect} provided to from_hash") if !(hash && hash.is_a?(Hash))
i = @class.allocate
i.deserialize(hash, strict)
i
end
def prop_serialized_form(prop)
prop_rules(prop)[:serialized_form]
end
def serialized_form_prop(serialized_form)
prop_by_serialized_forms[serialized_form.to_s] || raise("No such serialized form: #{serialized_form.inspect}")
end
def add_prop_definition(prop, rules)
rules[:serialized_form] = rules.fetch(:name, prop.to_s)
res = super
prop_by_serialized_forms[rules[:serialized_form]] = prop
if T::Configuration.use_vm_prop_serde?
enqueue_lazy_vm_method_definition!(:__t_props_generated_serialize) {generate_serialize2}
enqueue_lazy_vm_method_definition!(:__t_props_generated_deserialize) {generate_deserialize2}
else
enqueue_lazy_method_definition!(:__t_props_generated_serialize) {generate_serialize_source}
enqueue_lazy_method_definition!(:__t_props_generated_deserialize) {generate_deserialize_source}
end
res
end
private def generate_serialize_source
T::Props::Private::SerializerGenerator.generate(props)
end
private def generate_deserialize_source
T::Props::Private::DeserializerGenerator.generate(
props,
props_with_defaults || {},
)
end
private def generate_serialize2
T::Props::Private::SerializerGenerator.generate2(decorated_class, props)
end
private def generate_deserialize2
T::Props::Private::DeserializerGenerator.generate2(
decorated_class,
props,
props_with_defaults || {},
)
end
def message_with_generated_source_context(error, generated_method, generate_source_method)
line_label = error.backtrace.find {|l| l.end_with?("in `#{generated_method}'")}
return unless line_label
line_num = line_label.split(':')[1]&.to_i
return unless line_num
source_lines = self.send(generate_source_method).split("\n")
previous_blank = source_lines[0...line_num].rindex(&:empty?) || 0
next_blank = line_num + (source_lines[line_num..-1]&.find_index(&:empty?) || 0)
context = " #{source_lines[(previous_blank + 1)...next_blank].join("\n ")}"
<<~MSG
Error in #{decorated_class.name}##{generated_method}: #{error.message}
at line #{line_num - previous_blank - 1} in:
#{context}
MSG
end
def raise_nil_deserialize_error(hkey)
msg = "Tried to deserialize a required prop from a nil value. It's "\
"possible that a nil value exists in the database, so you should "\
"provide a `default: or factory:` for this prop (see go/optional "\
"for more details). If this is already the case, you probably "\
"omitted a required prop from the `fields:` option when doing a "\
"partial load."
storytime = {prop: hkey, klass: decorated_class.name}
# Notify the model owner if it exists, and always notify the API owner.
begin
if T::Configuration.class_owner_finder && (owner = T::Configuration.class_owner_finder.call(decorated_class))
T::Configuration.hard_assert_handler(
msg,
storytime: storytime,
project: owner
)
end
ensure
T::Configuration.hard_assert_handler(msg, storytime: storytime)
end
end
def prop_validate_definition!(name, cls, rules, type)
result = super
if (rules_name = rules[:name])
unless rules_name.is_a?(String)
raise ArgumentError.new("Invalid name in prop #{@class.name}.#{name}: #{rules_name.inspect}")
end
validate_prop_name(rules_name)
end
if !rules[:raise_on_nil_write].nil? && rules[:raise_on_nil_write] != true
raise ArgumentError.new("The value of `raise_on_nil_write` if specified must be `true` (given: #{rules[:raise_on_nil_write]}).")
end
result
end
def get_id(instance)
prop = prop_by_serialized_forms['_id']
if prop
get(instance, prop)
else
nil
end
end
EMPTY_EXTRA_PROPS = {}.freeze
private_constant :EMPTY_EXTRA_PROPS
def extra_props(instance)
if instance.instance_variable_defined?(:@_extra_props)
instance.instance_variable_get(:@_extra_props) || EMPTY_EXTRA_PROPS
else
EMPTY_EXTRA_PROPS
end
end
# overrides T::Props::PrettyPrintable
private def inspect_instance_components(instance, multiline:, indent:)
if (extra_props = extra_props(instance)) && !extra_props.empty?
pretty_kvs = extra_props.map {|k, v| [k.to_sym, v.inspect]}
extra = join_props_with_pretty_values(pretty_kvs, multiline: false)
super + ["@_extra_props=<#{extra}>"]
else
super
end
end
end
##############################################
# NB: This must stay in the same file where T::Props::Serializable is defined due to
# T::Props::Decorator#apply_plugin; see https://git.corp.stripe.com/stripe-internal/pay-server/blob/fc7f15593b49875f2d0499ffecfd19798bac05b3/chalk/odm/lib/chalk-odm/document_decorator.rb#L716-L717
module T::Props::Serializable::ClassMethods
def prop_by_serialized_forms
@prop_by_serialized_forms ||= {}
end
# Allocate a new instance and call {#deserialize} to load a new
# object from a hash.
# @return [Serializable]
def from_hash(hash, strict=false)
self.decorator.from_hash(hash, strict)
end
# Equivalent to {.from_hash} with `strict` set to true.
# @return [Serializable]
def from_hash!(hash)
self.decorator.from_hash(hash, true)
end
end

View File

@ -0,0 +1,111 @@
# frozen_string_literal: true
# typed: false
module T::Props::TypeValidation
include T::Props::Plugin
BANNED_TYPES = [Object, BasicObject, Kernel].freeze
class UnderspecifiedType < ArgumentError; end
module DecoratorMethods
extend T::Sig
sig {params(key: Symbol).returns(T::Boolean).checked(:never)}
def valid_rule_key?(key)
super || key == :DEPRECATED_underspecified_type
end
sig do
params(
name: T.any(Symbol, String),
_cls: Module,
rules: T::Hash[Symbol, T.untyped],
type: T.any(T::Types::Base, Module)
)
.void
end
def prop_validate_definition!(name, _cls, rules, type)
super
if !rules[:DEPRECATED_underspecified_type]
validate_type(type, field_name: name)
elsif rules[:DEPRECATED_underspecified_type] && find_invalid_subtype(type).nil?
raise ArgumentError.new("DEPRECATED_underspecified_type set unnecessarily for #{@class.name}.#{name} - #{type} is a valid type")
end
end
sig do
params(
type: T::Types::Base,
field_name: T.any(Symbol, String),
)
.void
end
private def validate_type(type, field_name:)
if (invalid_subtype = find_invalid_subtype(type))
raise UnderspecifiedType.new(type_error_message(invalid_subtype, field_name, type))
end
end
# Returns an invalid type, if any, found in the given top-level type.
# This might be the type itself, if it is e.g. "Object", or might be
# a subtype like the type of the values of a typed hash.
#
# If the type is fully valid, returns nil.
#
# checked(:never) - called potentially many times recursively
sig {params(type: T::Types::Base).returns(T.nilable(T::Types::Base)).checked(:never)}
private def find_invalid_subtype(type)
case type
when T::Types::TypedEnumerable
find_invalid_subtype(type.type)
when T::Types::FixedHash
type.types.values.map {|subtype| find_invalid_subtype(subtype)}.compact.first
when T::Types::Union, T::Types::FixedArray
# `T.any` is valid if all of the members are valid
type.types.map {|subtype| find_invalid_subtype(subtype)}.compact.first
when T::Types::Intersection
# `T.all` is valid if at least one of the members is valid
invalid = type.types.map {|subtype| find_invalid_subtype(subtype)}.compact
if invalid.length == type.types.length
invalid.first
else
nil
end
when T::Types::Enum, T::Types::ClassOf
nil
when T::Private::Types::TypeAlias
find_invalid_subtype(type.aliased_type)
when T::Types::Simple
# TODO Could we manage to define a whitelist, consisting of something
# like primitives, subdocs, DataInterfaces, and collections/enums/unions
# thereof?
if BANNED_TYPES.include?(type.raw_type)
type
else
nil
end
else
type
end
end
sig do
params(
type: T::Types::Base,
field_name: T.any(Symbol, String),
orig_type: T::Types::Base,
)
.returns(String)
end
private def type_error_message(type, field_name, orig_type)
msg_prefix = "#{@class.name}.#{field_name}: #{orig_type} is invalid in prop definition"
if type == orig_type
"#{msg_prefix}. Please choose a more specific type (T.untyped and ~equivalents like Object are banned)."
else
"#{msg_prefix}. Please choose a subtype more specific than #{type} (T.untyped and ~equivalents like Object are banned)."
end
end
end
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
# typed: true
module T::Props::Utils
# Deep copy an object. The object must consist of Ruby primitive
# types and Hashes and Arrays.
def self.deep_clone_object(what, freeze: false)
result = case what
when true
true
when false
false
when Symbol, NilClass, Numeric
what
when Array
what.map {|v| deep_clone_object(v, freeze: freeze)}
when Hash
h = what.class.new
what.each do |k, v|
k.freeze if freeze
h[k] = deep_clone_object(v, freeze: freeze)
end
h
when Regexp
what.dup
when T::Enum
what
else
what.clone
end
freeze ? result.freeze : result
end
# The prop_rules indicate whether we should check for reading a nil value for the prop/field.
# This is mostly for the compatibility check that we allow existing documents carry some nil prop/field.
def self.need_nil_read_check?(prop_rules)
# . :on_load allows nil read, but we need to check for the read for future writes
prop_rules[:optional] == :on_load || prop_rules[:raise_on_nil_write]
end
# The prop_rules indicate whether we should check for writing a nil value for the prop/field.
def self.need_nil_write_check?(prop_rules)
need_nil_read_check?(prop_rules) || T::Props::Utils.required_prop?(prop_rules)
end
def self.required_prop?(prop_rules)
# Clients should never reference :_tnilable as the implementation can change.
!prop_rules[:_tnilable]
end
def self.optional_prop?(prop_rules)
# Clients should never reference :_tnilable as the implementation can change.
!!prop_rules[:_tnilable]
end
def self.merge_serialized_optional_rule(prop_rules)
{'_tnilable' => true}.merge(prop_rules.merge('_tnilable' => true))
end
end

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
# typed: false
module T::Props::WeakConstructor
include T::Props::Optional
extend T::Sig
# checked(:never) - O(runtime object construction)
sig {params(hash: T::Hash[Symbol, T.untyped]).void.checked(:never)}
def initialize(hash={})
decorator = self.class.decorator
hash_keys_matching_props = decorator.construct_props_with_defaults(self, hash) +
decorator.construct_props_without_defaults(self, hash)
if hash_keys_matching_props < hash.size
raise ArgumentError.new("#{self.class}: Unrecognized properties: #{(hash.keys - decorator.props.keys).join(', ')}")
end
end
end
module T::Props::WeakConstructor::DecoratorMethods
extend T::Sig
# Set values for all props that have no defaults. Ignore any not present.
#
# @return [Integer] A count of props that we successfully initialized (which
# we'll use to check for any unrecognized input.)
#
# checked(:never) - O(runtime object construction)
sig {params(instance: T::Props::WeakConstructor, hash: T::Hash[Symbol, T.untyped]).returns(Integer).checked(:never)}
def construct_props_without_defaults(instance, hash)
# Use `each_pair` rather than `count` because, as of Ruby 2.6, the latter delegates to Enumerator
# and therefore allocates for each entry.
result = 0
props_without_defaults&.each_pair do |p, setter_proc|
if hash.key?(p)
instance.instance_exec(hash[p], &setter_proc)
result += 1
end
end
result
end
# Set values for all props that have defaults. Use the default if and only if
# the prop key isn't in the input.
#
# @return [Integer] A count of props that we successfully initialized (which
# we'll use to check for any unrecognized input.)
#
# checked(:never) - O(runtime object construction)
sig {params(instance: T::Props::WeakConstructor, hash: T::Hash[Symbol, T.untyped]).returns(Integer).checked(:never)}
def construct_props_with_defaults(instance, hash)
# Use `each_pair` rather than `count` because, as of Ruby 2.6, the latter delegates to Enumerator
# and therefore allocates for each entry.
result = 0
props_with_defaults&.each_pair do |p, default_struct|
if hash.key?(p)
instance.instance_exec(hash[p], &default_struct.setter_proc)
result += 1
else
default_struct.set_default(instance)
end
end
result
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
# typed: strict
# Used as a mixin to any class so that you can call `sig`.
# Docs at https://sorbet.org/docs/sigs
module T::Sig
module WithoutRuntime
# At runtime, does nothing, but statically it is treated exactly the same
# as T::Sig#sig. Only use it in cases where you can't use T::Sig#sig.
def self.sig(arg0=nil, &blk); end
original_verbose = $VERBOSE
$VERBOSE = false
# At runtime, does nothing, but statically it is treated exactly the same
# as T::Sig#sig. Only use it in cases where you can't use T::Sig#sig.
T::Sig::WithoutRuntime.sig {params(arg0: T.nilable(Symbol), blk: T.proc.bind(T::Private::Methods::DeclBuilder).void).void}
def self.sig(arg0=nil, &blk); end # rubocop:disable Lint/DuplicateMethods
$VERBOSE = original_verbose
end
# Declares a method with type signatures and/or
# abstract/override/... helpers. See the documentation URL on
# {T::Helpers}
T::Sig::WithoutRuntime.sig {params(arg0: T.nilable(Symbol), blk: T.proc.bind(T::Private::Methods::DeclBuilder).void).void}
def sig(arg0=nil, &blk)
T::Private::Methods.declare_sig(self, Kernel.caller_locations(1, 1)&.first, arg0, &blk)
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
# typed: true
class T::InexactStruct
include T::Props
include T::Props::Serializable
include T::Props::Constructor
end
class T::Struct < T::InexactStruct
def self.inherited(subclass)
super(subclass)
T::Private::ClassUtils.replace_method(subclass.singleton_class, :inherited) do |s|
super(s)
raise "#{self.name} is a subclass of T::Struct and cannot be subclassed"
end
end
end
class T::ImmutableStruct < T::InexactStruct
extend T::Sig
def self.inherited(subclass)
super(subclass)
T::Private::ClassUtils.replace_method(subclass.singleton_class, :inherited) do |s|
super(s)
raise "#{self.name} is a subclass of T::ImmutableStruct and cannot be subclassed"
end
end
# Matches the one in WeakConstructor, but freezes the object
sig {params(hash: T::Hash[Symbol, T.untyped]).void.checked(:never)}
def initialize(hash={})
super
freeze
end
# Matches the signature in Props, but raises since this is an immutable struct and only const is allowed
sig {params(name: Symbol, cls: T.untyped, rules: T.untyped).void}
def self.prop(name, cls, rules={})
return super if (cls.is_a?(Hash) && cls[:immutable]) || rules[:immutable]
raise "Cannot use `prop` in #{self.name} because it is an immutable struct. Use `const` instead"
end
def with(changed_props)
raise "Cannot use `with` in #{self.class.name} because it is an immutable struct"
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Modeling AttachedClass properly at runtime would require additional
# tracking, so at runtime we permit all values and rely on the static checker.
# As AttachedClass is modeled statically as a type member on every singleton
# class, this is consistent with the runtime behavior for all type members.
class AttachedClassType < Base
def initialize(); end
# overrides Base
def name
"T.attached_class"
end
# overrides Base
def valid?(obj)
true
end
# overrides Base
private def subtype_of_single?(other)
case other
when AttachedClassType
true
else
false
end
end
module Private
INSTANCE = AttachedClassType.new.freeze
end
end
end

View File

@ -0,0 +1,172 @@
# frozen_string_literal: true
# typed: true
module T::Types
class Base
def self.method_added(method_name)
super(method_name)
# What is now `subtype_of_single?` used to be named `subtype_of?`. Make sure people don't
# override the wrong thing.
#
# NB: Outside of T::Types, we would enforce this by using `sig` and not declaring the method
# as overridable, but doing so here would result in a dependency cycle.
if method_name == :subtype_of? && self != T::Types::Base
raise "`subtype_of?` should not be overridden. You probably want to override " \
"`subtype_of_single?` instead."
end
end
# this will be redefined in certain subclasses
def recursively_valid?(obj)
valid?(obj)
end
def valid?(obj)
raise NotImplementedError
end
# @return [T::Boolean] This method must be implemented to return whether the subclass is a subtype
# of `type`. This should only be called by `subtype_of?`, which guarantees that `type` will be
# a "single" type, by which we mean it won't be a Union or an Intersection (c.f.
# `isSubTypeSingle` in sorbet).
private def subtype_of_single?(type)
raise NotImplementedError
end
# Equality is based on name, so be sure the name reflects all relevant state when implementing.
def name
raise NotImplementedError
end
# Mirrors ruby_typer::core::Types::isSubType
# See https://git.corp.stripe.com/stripe-internal/ruby-typer/blob/9fc8ed998c04ac0b96592ae6bb3493b8a925c5c1/core/types/subtyping.cc#L912-L950
#
# This method cannot be overridden (see `method_added` above).
# Subclasses only need to implement `subtype_of_single?`).
def subtype_of?(t2)
t1 = self
if t2.is_a?(T::Private::Types::TypeAlias)
t2 = t2.aliased_type
end
if t1.is_a?(T::Private::Types::TypeAlias)
return t1.aliased_type.subtype_of?(t2)
end
# pairs to cover: 1 (_, _)
# 2 (_, And)
# 3 (_, Or)
# 4 (And, _)
# 5 (And, And)
# 6 (And, Or)
# 7 (Or, _)
# 8 (Or, And)
# 9 (Or, Or)
# Note: order of cases here matters!
if t1.is_a?(T::Types::Union) # 7, 8, 9
# this will be incorrect if/when we have Type members
return t1.types.all? {|t1_member| t1_member.subtype_of?(t2)}
end
if t2.is_a?(T::Types::Intersection) # 2, 5
# this will be incorrect if/when we have Type members
return t2.types.all? {|t2_member| t1.subtype_of?(t2_member)}
end
if t2.is_a?(T::Types::Union)
if t1.is_a?(T::Types::Intersection) # 6
# dropping either of parts eagerly make subtype test be too strict.
# we have to try both cases, when we normally try only one
return t2.types.any? {|t2_member| t1.subtype_of?(t2_member)} ||
t1.types.any? {|t1_member| t1_member.subtype_of?(t2)}
end
return t2.types.any? {|t2_member| t1.subtype_of?(t2_member)} # 3
end
if t1.is_a?(T::Types::Intersection) # 4
# this will be incorrect if/when we have Type members
return t1.types.any? {|t1_member| t1_member.subtype_of?(t2)}
end
# 1; Start with some special cases
if t1.is_a?(T::Private::Types::Void)
return t2.is_a?(T::Private::Types::Void)
end
if t1.is_a?(T::Types::Untyped) || t2.is_a?(T::Types::Untyped)
return true
end
# Rest of (1)
subtype_of_single?(t2)
end
def to_s
name
end
def describe_obj(obj)
# Would be redundant to print class and value in these common cases.
case obj
when nil, true, false
return "type #{obj.class}"
end
# In rare cases, obj.inspect may fail, or be undefined, so rescue.
begin
# Default inspect behavior of, eg; `#<Object:0x0...>` is ugly; just print the hash instead, which is more concise/readable.
if obj.method(:inspect).owner == Kernel
"type #{obj.class} with hash #{obj.hash}"
elsif T::Configuration.include_value_in_type_errors?
"type #{obj.class} with value #{T::Utils.string_truncate_middle(obj.inspect, 30, 30)}"
else
"type #{obj.class}"
end
rescue StandardError, SystemStackError
"type #{obj.class} with unprintable value"
end
end
def error_message_for_obj(obj)
if valid?(obj)
nil
else
error_message(obj)
end
end
def error_message_for_obj_recursive(obj)
if recursively_valid?(obj)
nil
else
error_message(obj)
end
end
private def error_message(obj)
"Expected type #{self.name}, got #{describe_obj(obj)}"
end
def validate!(obj)
err = error_message_for_obj(obj)
raise TypeError.new(err) if err
end
### Equality methods (necessary for deduping types with `uniq`)
def hash
name.hash
end
# Type equivalence, defined by serializing the type to a string (with
# `#name`) and comparing the resulting strings for equality.
def ==(other)
(T::Utils.resolve_alias(other).class == T::Utils.resolve_alias(self).class) &&
other.name == self.name
end
alias_method :eql?, :==
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Validates that an object belongs to the specified class.
class ClassOf < Base
attr_reader :type
def initialize(type)
@type = type
end
# overrides Base
def name
"T.class_of(#{@type})"
end
# overrides Base
def valid?(obj)
obj.is_a?(Module) && obj <= @type
end
# overrides Base
def subtype_of_single?(other)
case other
when ClassOf
@type <= other.type
when Simple
@type.is_a?(other.raw_type)
else
false
end
end
# overrides Base
def describe_obj(obj)
obj.inspect
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
# typed: true
module T::Types
# validates that the provided value is within a given set/enum
class Enum < Base
extend T::Sig
attr_reader :values
def initialize(values)
@values = values
end
# overrides Base
def valid?(obj)
@values.member?(obj)
end
# overrides Base
private def subtype_of_single?(other)
case other
when Enum
(other.values - @values).empty?
else
false
end
end
# overrides Base
def name
"T.deprecated_enum([#{@values.map(&:inspect).join(', ')}])"
end
# overrides Base
def describe_obj(obj)
obj.inspect
end
end
end

View File

@ -0,0 +1,86 @@
# frozen_string_literal: true
# https://jira.corp.stripe.com/browse/RUBYPLAT-1107
# typed: false
module T::Types
# Takes a list of types. Validates each item in an array using the type in the same position
# in the list.
class FixedArray < Base
attr_reader :types
def initialize(types)
@types = types.map {|type| T::Utils.coerce(type)}
end
# overrides Base
def name
"[#{@types.join(', ')}]"
end
# overrides Base
def recursively_valid?(obj)
if obj.is_a?(Array) && obj.length == @types.length
i = 0
while i < @types.length
if !@types[i].recursively_valid?(obj[i])
return false
end
i += 1
end
true
else
false
end
end
# overrides Base
def valid?(obj)
if obj.is_a?(Array) && obj.length == @types.length
i = 0
while i < @types.length
if !@types[i].valid?(obj[i])
return false
end
i += 1
end
true
else
false
end
end
# overrides Base
private def subtype_of_single?(other)
case other
when FixedArray
# Properly speaking, covariance here is unsound since arrays
# can be mutated, but sorbet implements covariant tuples for
# ease of adoption.
@types.size == other.types.size && @types.zip(other.types).all? do |t1, t2|
t1.subtype_of?(t2)
end
else
false
end
end
# This gives us better errors, e.g.:
# "Expected [String, Symbol], got [String, String]"
# instead of
# "Expected [String, Symbol], got Array".
#
# overrides Base
def describe_obj(obj)
if obj.is_a?(Array)
if obj.length == @types.length
item_classes = obj.map(&:class).join(', ')
"type [#{item_classes}]"
else
"array of size #{obj.length}"
end
else
super
end
end
end
end

View File

@ -0,0 +1,74 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Takes a hash of types. Validates each item in a hash using the type in the same position
# in the list.
class FixedHash < Base
attr_reader :types
def initialize(types)
@types = types.transform_values {|v| T::Utils.coerce(v)}
end
# overrides Base
def name
serialize_hash(@types)
end
# overrides Base
def recursively_valid?(obj)
return false unless obj.is_a?(Hash)
return false if @types.any? {|key, type| !type.recursively_valid?(obj[key])}
return false if obj.any? {|key, _| !@types[key]}
true
end
# overrides Base
def valid?(obj)
return false unless obj.is_a?(Hash)
return false if @types.any? {|key, type| !type.valid?(obj[key])}
return false if obj.any? {|key, _| !@types[key]}
true
end
# overrides Base
private def subtype_of_single?(other)
case other
when FixedHash
# Using `subtype_of?` here instead of == would be unsound
@types == other.types
else
false
end
end
# This gives us better errors, e.g.:
# `Expected {a: String}, got {a: TrueClass}`
# instead of
# `Expected {a: String}, got Hash`.
#
# overrides Base
def describe_obj(obj)
if obj.is_a?(Hash)
"type #{serialize_hash(obj.transform_values(&:class))}"
else
super
end
end
private
def serialize_hash(hash)
entries = hash.map do |(k, v)|
if Symbol === k && ":#{k}" == k.inspect
"#{k}: #{v}"
else
"#{k.inspect} => #{v}"
end
end
"{#{entries.join(', ')}}"
end
end
end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Takes a list of types. Validates that an object matches all of the types.
class Intersection < Base
attr_reader :types
def initialize(types)
@types = types.flat_map do |type|
type = T::Utils.resolve_alias(type)
if type.is_a?(Intersection)
# Simplify nested intersections (mostly so `name` returns a nicer value)
type.types
else
T::Utils.coerce(type)
end
end.uniq
end
# overrides Base
def name
"T.all(#{@types.map(&:name).sort.join(', ')})"
end
# overrides Base
def recursively_valid?(obj)
@types.all? {|type| type.recursively_valid?(obj)}
end
# overrides Base
def valid?(obj)
@types.all? {|type| type.valid?(obj)}
end
# overrides Base
private def subtype_of_single?(other)
raise "This should never be reached if you're going through `subtype_of?` (and you should be)"
end
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
# typed: true
module T::Types
# The bottom type
class NoReturn < Base
def initialize; end
# overrides Base
def name
"T.noreturn"
end
# overrides Base
def valid?(obj)
false
end
# overrides Base
private def subtype_of_single?(other)
true
end
module Private
INSTANCE = NoReturn.new.freeze
end
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Defines the type of a proc (a ruby callable). At runtime, only
# validates that the value is a `::Proc`.
#
# At present, we only support fixed-arity procs with no optional or
# keyword arguments.
class Proc < Base
attr_reader :arg_types
attr_reader :returns
def initialize(arg_types, returns)
@arg_types = {}
arg_types.each do |key, raw_type|
@arg_types[key] = T::Utils.coerce(raw_type)
end
@returns = T::Utils.coerce(returns)
end
# overrides Base
def name
args = []
@arg_types.each do |k, v|
args << "#{k}: #{v.name}"
end
"T.proc.params(#{args.join(', ')}).returns(#{returns})"
end
# overrides Base
def valid?(obj)
obj.is_a?(::Proc)
end
# overrides Base
private def subtype_of_single?(other)
case other
when self.class
if arg_types.size != other.arg_types.size
return false
end
arg_types.values.zip(other.arg_types.values).all? do |a, b|
b.subtype_of?(a)
end && returns.subtype_of?(other.returns)
else
false
end
end
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Modeling self-types properly at runtime would require additional tracking,
# so at runtime we permit all values and rely on the static checker.
class SelfType < Base
def initialize(); end
# overrides Base
def name
"T.self_type"
end
# overrides Base
def valid?(obj)
true
end
# overrides Base
private def subtype_of_single?(other)
case other
when SelfType
true
else
false
end
end
module Private
INSTANCE = SelfType.new.freeze
end
end
end

View File

@ -0,0 +1,94 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Validates that an object belongs to the specified class.
class Simple < Base
attr_reader :raw_type
def initialize(raw_type)
@raw_type = raw_type
end
# overrides Base
def name
# Memoize to mitigate pathological performance with anonymous modules (https://bugs.ruby-lang.org/issues/11119)
#
# `name` isn't normally a hot path for types, but it is used in initializing a T::Types::Union,
# and so in `T.nilable`, and so in runtime constructions like `x = T.let(nil, T.nilable(Integer))`.
@name ||= @raw_type.name.freeze
end
# overrides Base
def valid?(obj)
obj.is_a?(@raw_type)
end
# overrides Base
private def subtype_of_single?(other)
case other
when Simple
@raw_type <= other.raw_type
else
false
end
end
# overrides Base
private def error_message(obj)
error_message = super(obj)
actual_name = obj.class.name
return error_message unless name == actual_name
<<~MSG.strip
#{error_message}
The expected type and received object type have the same name but refer to different constants.
Expected type is #{name} with object id #{@raw_type.__id__}, but received type is #{actual_name} with object id #{obj.class.__id__}.
There might be a constant reloading problem in your application.
MSG
end
def to_nilable
@nilable ||= T::Types::Union.new([self, T::Utils::Nilable::NIL_TYPE])
end
module Private
module Pool
@cache = ObjectSpace::WeakMap.new
def self.type_for_module(mod)
cached = @cache[mod]
return cached if cached
type = if mod == ::Array
T::Array[T.untyped]
elsif mod == ::Hash
T::Hash[T.untyped, T.untyped]
elsif mod == ::Enumerable
T::Enumerable[T.untyped]
elsif mod == ::Enumerator
T::Enumerator[T.untyped]
elsif mod == ::Range
T::Range[T.untyped]
elsif !Object.autoload?(:Set) && Object.const_defined?(:Set) && mod == ::Set
T::Set[T.untyped]
else
Simple.new(mod)
end
# Unfortunately, we still need to check if the module is frozen,
# since WeakMap adds a finalizer to the key that is added
# to the map, so that it can clear the map entry when the key is
# garbage collected.
# For a frozen object, though, adding a finalizer is not a valid
# operation, so this still raises if `mod` is frozen.
@cache[mod] = type unless mod.frozen?
type
end
end
end
end
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Validates that an object is equal to another T::Enum singleton value.
class TEnum < Base
attr_reader :val
def initialize(val)
@val = val
end
# overrides Base
def name
# Strips the #<...> off, just leaving the ...
# Reasoning: the user will have written something like
# T.any(MyEnum::A, MyEnum::B)
# in the type, so we should print what they wrote in errors, not:
# T.any(#<MyEnum::A>, #<MyEnum::B>)
@val.inspect[2..-2]
end
# overrides Base
def valid?(obj)
@val == obj
end
# overrides Base
private def subtype_of_single?(other)
case other
when TEnum
@val == other.val
else
false
end
end
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# typed: strict
module T::Types
class TypeMember < TypeVariable
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
# typed: true
module T::Types
class TypeParameter < Base
def initialize(name)
raise ArgumentError.new("not a symbol: #{name}") unless name.is_a?(Symbol)
@name = name
end
def valid?(obj)
true
end
def subtype_of_single?(type)
true
end
def name
"T.type_parameter(:#{@name})"
end
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# typed: strict
module T::Types
class TypeTemplate < TypeVariable
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Since we do type erasure at runtime, this just validates the variance and
# provides some syntax for the static type checker
class TypeVariable < Base
attr_reader :variance
VALID_VARIANCES = %i[in out invariant].freeze
def initialize(variance)
case variance
when Hash then raise ArgumentError.new("Pass bounds using a block. Got: #{variance}")
when *VALID_VARIANCES then nil
else
raise TypeError.new("invalid variance #{variance}")
end
@variance = variance
end
def valid?(obj)
true
end
def subtype_of_single?(type)
true
end
def name
Untyped.new.name
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
# typed: true
module T::Types
class TypedArray < TypedEnumerable
# overrides Base
def name
"T::Array[#{@type.name}]"
end
def underlying_class
Array
end
# overrides Base
def recursively_valid?(obj)
obj.is_a?(Array) && super
end
# overrides Base
def valid?(obj)
obj.is_a?(Array)
end
def new(*args)
Array.new(*T.unsafe(args))
end
class Untyped < TypedArray
def initialize
super(T.untyped)
end
def valid?(obj)
obj.is_a?(Array)
end
end
end
end

View File

@ -0,0 +1,176 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Note: All subclasses of Enumerable should add themselves to the
# `case` statement below in `describe_obj` in order to get better
# error messages.
class TypedEnumerable < Base
attr_reader :type
def initialize(type)
@type = T::Utils.coerce(type)
end
def underlying_class
Enumerable
end
# overrides Base
def name
"T::Enumerable[#{@type.name}]"
end
# overrides Base
def valid?(obj)
obj.is_a?(Enumerable)
end
# overrides Base
def recursively_valid?(obj)
return false unless obj.is_a?(Enumerable)
case obj
when Array
begin
it = 0
while it < obj.count
return false unless @type.recursively_valid?(obj[it])
it += 1
end
true
end
when Hash
return false unless @type.is_a?(FixedArray)
types = @type.types
return false if types.count != 2
key_type = types[0]
value_type = types[1]
obj.each_pair do |key, val|
# Some objects (I'm looking at you Rack::Utils::HeaderHash) don't
# iterate over a [key, value] array, so we can't juse use the @type.recursively_valid?(v)
return false if !key_type.recursively_valid?(key) || !value_type.recursively_valid?(val)
end
true
when Enumerator::Lazy
# Enumerators can be unbounded: see `[:foo, :bar].cycle`
true
when Enumerator
# Enumerators can be unbounded: see `[:foo, :bar].cycle`
true
when Range
# A nil beginning or a nil end does not provide any type information. That is, nil in a range represents
# boundlessness, it does not express a type. For example `(nil...nil)` is not a T::Range[NilClass], its a range
# of unknown types (T::Range[T.untyped]).
# Similarly, `(nil...1)` is not a `T::Range[T.nilable(Integer)]`, it's a boundless range of Integer.
(obj.begin.nil? || @type.recursively_valid?(obj.begin)) && (obj.end.nil? || @type.recursively_valid?(obj.end))
when Set
obj.each do |item|
return false unless @type.recursively_valid?(item)
end
true
else
# We don't check the enumerable since it isn't guaranteed to be
# rewindable (e.g. STDIN) and it may be expensive to enumerate
# (e.g. an enumerator that makes an HTTP request)"
true
end
end
# overrides Base
private def subtype_of_single?(other)
if other.class <= TypedEnumerable &&
underlying_class <= other.underlying_class
# Enumerables are covariant because they are read only
#
# Properly speaking, many Enumerable subtypes (e.g. Set)
# should be invariant because they are mutable and support
# both reading and writing. However, Sorbet treats *all*
# Enumerable subclasses as covariant for ease of adoption.
@type.subtype_of?(other.type)
else
false
end
end
# overrides Base
def describe_obj(obj)
return super unless obj.is_a?(Enumerable)
type_from_instance(obj).name
end
private def type_from_instances(objs)
return objs.class unless objs.is_a?(Enumerable)
obtained_types = []
begin
objs.each do |x|
obtained_types << type_from_instance(x)
end
rescue
return T.untyped # all we can do is go with the types we have so far
end
if obtained_types.count > 1
# Multiple kinds of bad types showed up, we'll suggest a union
# type you might want.
Union.new(obtained_types)
elsif obtained_types.empty?
T.noreturn
else
# Everything was the same bad type, lets just show that
obtained_types.first
end
end
private def type_from_instance(obj)
if [true, false].include?(obj)
return T::Boolean
elsif !obj.is_a?(Enumerable)
return obj.class
end
case obj
when Array
T::Array[type_from_instances(obj)]
when Hash
inferred_key = type_from_instances(obj.keys)
inferred_val = type_from_instances(obj.values)
T::Hash[inferred_key, inferred_val]
when Range
# We can't get any information from `NilClass` in ranges (since nil is used to represent boundlessness).
typeable_objects = [obj.begin, obj.end].compact
if typeable_objects.empty?
T::Range[T.untyped]
else
T::Range[type_from_instances(typeable_objects)]
end
when Enumerator::Lazy
T::Enumerator::Lazy[type_from_instances(obj)]
when Enumerator
T::Enumerator[type_from_instances(obj)]
when Set
T::Set[type_from_instances(obj)]
when IO
# Short circuit for anything IO-like (File, etc.). In these cases,
# enumerating the object is a destructive operation and might hang.
obj.class
else
# This is a specialized enumerable type, just return the class.
if T::Configuration::AT_LEAST_RUBY_2_7
Object.instance_method(:class).bind_call(obj)
else
Object.instance_method(:class).bind(obj).call
end
end
end
class Untyped < TypedEnumerable
def initialize
super(T.untyped)
end
def valid?(obj)
obj.is_a?(Enumerable)
end
end
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
# typed: true
module T::Types
class TypedEnumerator < TypedEnumerable
attr_reader :type
def underlying_class
Enumerator
end
# overrides Base
def name
"T::Enumerator[#{@type.name}]"
end
# overrides Base
def recursively_valid?(obj)
obj.is_a?(Enumerator) && super
end
# overrides Base
def valid?(obj)
obj.is_a?(Enumerator)
end
def new(*args, &blk)
T.unsafe(Enumerator).new(*args, &blk)
end
class Untyped < TypedEnumerator
def initialize
super(T.untyped)
end
def valid?(obj)
obj.is_a?(Enumerator)
end
end
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
# typed: true
module T::Types
class TypedEnumeratorLazy < TypedEnumerable
attr_reader :type
def underlying_class
Enumerator::Lazy
end
# overrides Base
def name
"T::Enumerator::Lazy[#{@type.name}]"
end
# overrides Base
def recursively_valid?(obj)
obj.is_a?(Enumerator::Lazy) && super
end
# overrides Base
def valid?(obj)
obj.is_a?(Enumerator::Lazy)
end
def new(*args, &blk)
T.unsafe(Enumerator::Lazy).new(*args, &blk)
end
class Untyped < TypedEnumeratorLazy
def initialize
super(T.untyped)
end
def valid?(obj)
obj.is_a?(Enumerator::Lazy)
end
end
end
end

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
# typed: true
module T::Types
class TypedHash < TypedEnumerable
# Technically we don't need these, but they are a nice api
attr_reader :keys, :values
def underlying_class
Hash
end
def initialize(keys:, values:)
@keys = T::Utils.coerce(keys)
@values = T::Utils.coerce(values)
@type = T::Utils.coerce([keys, values])
end
# overrides Base
def name
"T::Hash[#{@keys.name}, #{@values.name}]"
end
# overrides Base
def recursively_valid?(obj)
obj.is_a?(Hash) && super
end
# overrides Base
def valid?(obj)
obj.is_a?(Hash)
end
def new(*args, &blk)
Hash.new(*T.unsafe(args), &blk)
end
class Untyped < TypedHash
def initialize
super(keys: T.untyped, values: T.untyped)
end
def valid?(obj)
obj.is_a?(Hash)
end
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# typed: true
module T::Types
class TypedRange < TypedEnumerable
attr_reader :type
def underlying_class
Hash
end
# overrides Base
def name
"T::Range[#{@type.name}]"
end
# overrides Base
def recursively_valid?(obj)
obj.is_a?(Range) && super
end
# overrides Base
def valid?(obj)
obj.is_a?(Range)
end
def new(*args)
T.unsafe(Range).new(*args)
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
# typed: true
module T::Types
class TypedSet < TypedEnumerable
attr_reader :type
def underlying_class
Hash
end
# overrides Base
def name
"T::Set[#{@type.name}]"
end
# overrides Base
def recursively_valid?(obj)
# Re-implements non_forcing_is_a?
return false if Object.autoload?(:Set) # Set is meant to be autoloaded but not yet loaded, this value can't be a Set
return false unless Object.const_defined?(:Set) # Set is not loaded yet
obj.is_a?(Set) && super
end
# overrides Base
def valid?(obj)
# Re-implements non_forcing_is_a?
return false if Object.autoload?(:Set) # Set is meant to be autoloaded but not yet loaded, this value can't be a Set
return false unless Object.const_defined?(:Set) # Set is not loaded yet
obj.is_a?(Set)
end
def new(*args)
# Fine for this to blow up, because hopefully if they're trying to make a
# Set, they don't mind putting (or already have put) a `require 'set'` in
# their program directly.
Set.new(*T.unsafe(args))
end
class Untyped < TypedSet
def initialize
super(T.untyped)
end
def valid?(obj)
# Re-implements non_forcing_is_a?
return false if Object.autoload?(:Set) # Set is meant to be autoloaded but not yet loaded, this value can't be a Set
return false unless Object.const_defined?(:Set) # Set is not loaded yet
obj.is_a?(Set)
end
end
end
end

View File

@ -0,0 +1,91 @@
# frozen_string_literal: true
# typed: true
module T::Types
# Takes a list of types. Validates that an object matches at least one of the types.
class Union < Base
attr_reader :types
def initialize(types)
@types = types.flat_map do |type|
type = T::Utils.coerce(type)
if type.is_a?(Union)
# Simplify nested unions (mostly so `name` returns a nicer value)
type.types
else
type
end
end.uniq
end
# overrides Base
def name
type_shortcuts(@types)
end
private def type_shortcuts(types)
if types.size == 1
return types[0].name
end
nilable = T::Utils.coerce(NilClass)
trueclass = T::Utils.coerce(TrueClass)
falseclass = T::Utils.coerce(FalseClass)
if types.any? {|t| t == nilable}
remaining_types = types.reject {|t| t == nilable}
"T.nilable(#{type_shortcuts(remaining_types)})"
elsif types.any? {|t| t == trueclass} && types.any? {|t| t == falseclass}
remaining_types = types.reject {|t| t == trueclass || t == falseclass}
type_shortcuts([T::Private::Types::StringHolder.new("T::Boolean")] + remaining_types)
else
names = types.map(&:name).compact.sort
"T.any(#{names.join(', ')})"
end
end
# overrides Base
def recursively_valid?(obj)
@types.any? {|type| type.recursively_valid?(obj)}
end
# overrides Base
def valid?(obj)
@types.any? {|type| type.valid?(obj)}
end
# overrides Base
private def subtype_of_single?(other)
raise "This should never be reached if you're going through `subtype_of?` (and you should be)"
end
module Private
module Pool
EMPTY_ARRAY = [].freeze
private_constant :EMPTY_ARRAY
# @param type_a [T::Types::Base]
# @param type_b [T::Types::Base]
# @param types [Array] optional array of additional T::Types::Base instances
def self.union_of_types(type_a, type_b, types=EMPTY_ARRAY)
if types.empty?
# We aren't guaranteed to detect a simple `T.nilable(<Module>)` type here
# in cases where there are duplicate types, nested unions, etc.
#
# That's ok, because this is an optimization which isn't necessary for
# correctness.
if type_b == T::Utils::Nilable::NIL_TYPE && type_a.is_a?(T::Types::Simple)
type_a.to_nilable
elsif type_a == T::Utils::Nilable::NIL_TYPE && type_b.is_a?(T::Types::Simple)
type_b.to_nilable
else
Union.new([type_a, type_b])
end
else
# This can't be a `T.nilable(<Module>)` case unless there are duplicates,
# which is possible but unexpected.
Union.new([type_a, type_b] + types)
end
end
end
end
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
# typed: true
module T::Types
# A dynamic type, which permits whatever
class Untyped < Base
def initialize; end
# overrides Base
def name
"T.untyped"
end
# overrides Base
def valid?(obj)
true
end
# overrides Base
private def subtype_of_single?(other)
true
end
module Private
INSTANCE = Untyped.new.freeze
end
end
end

View File

@ -0,0 +1,209 @@
# frozen_string_literal: true
# typed: true
module T::Utils
# Used to convert from a type specification to a `T::Types::Base`.
def self.coerce(val)
if val.is_a?(T::Private::Types::TypeAlias)
val.aliased_type
elsif val.is_a?(T::Types::Base)
val
elsif val.is_a?(Module)
T::Types::Simple::Private::Pool.type_for_module(val)
elsif val.is_a?(::Array)
T::Types::FixedArray.new(val)
elsif val.is_a?(::Hash)
T::Types::FixedHash.new(val)
elsif val.is_a?(T::Private::Methods::DeclBuilder)
T::Private::Methods.finalize_proc(val.decl)
elsif val.is_a?(::T::Enum)
T::Types::TEnum.new(val)
elsif val.is_a?(::String)
raise "Invalid String literal for type constraint. Must be an #{T::Types::Base}, a " \
"class/module, or an array. Got a String with value `#{val}`."
else
raise "Invalid value for type constraint. Must be an #{T::Types::Base}, a " \
"class/module, or an array. Got a `#{val.class}`."
end
end
# Dynamically confirm that `value` is recursively a valid value of
# type `type`, including recursively through collections. Note that
# in some cases this runtime check can be very expensive, especially
# with large collections of objects.
def self.check_type_recursive!(value, type)
T::Private::Casts.cast_recursive(value, type, cast_method: "T.check_type_recursive!")
end
# Returns the set of all methods (public, protected, private) defined on a module or its
# ancestors, excluding Object and its ancestors. Overrides of methods from Object (and its
# ancestors) are included.
def self.methods_excluding_object(mod)
# We can't just do mod.instance_methods - Object.instance_methods, because that would leave out
# any methods from Object that are overridden in mod.
mod.ancestors.flat_map do |ancestor|
# equivalent to checking Object.ancestors.include?(ancestor)
next [] if Object <= ancestor
ancestor.instance_methods(false) + ancestor.private_instance_methods(false)
end.uniq
end
# Returns the signature for the `UnboundMethod`, or nil if it's not sig'd
#
# @example T::Utils.signature_for_method(x.method(:foo))
def self.signature_for_method(method)
T::Private::Methods.signature_for_method(method)
end
# Returns the signature for the instance method on the supplied module, or nil if it's not found or not typed.
#
# @example T::Utils.signature_for_instance_method(MyClass, :my_method)
def self.signature_for_instance_method(mod, method_name)
T::Private::Methods.signature_for_method(mod.instance_method(method_name))
end
def self.wrap_method_with_call_validation_if_needed(mod, method_sig, original_method)
T::Private::Methods::CallValidation.wrap_method_if_needed(mod, method_sig, original_method)
end
# Unwraps all the sigs.
def self.run_all_sig_blocks
T::Private::Methods.run_all_sig_blocks
end
# Return the underlying type for a type alias. Otherwise returns type.
def self.resolve_alias(type)
case type
when T::Private::Types::TypeAlias
type.aliased_type
else
type
end
end
# Give a type which is a subclass of T::Types::Base, determines if the type is a simple nilable type (union of NilClass and something else).
# If so, returns the T::Types::Base of the something else. Otherwise, returns nil.
def self.unwrap_nilable(type)
case type
when T::Types::Union
non_nil_types = type.types.reject {|t| t == Nilable::NIL_TYPE}
return nil if type.types.length == non_nil_types.length
case non_nil_types.length
when 0 then nil
when 1 then non_nil_types.first
else
T::Types::Union::Private::Pool.union_of_types(non_nil_types[0], non_nil_types[1], non_nil_types[2..-1])
end
else
nil
end
end
# Returns the arity of a method, unwrapping the sig if needed
def self.arity(method)
arity = method.arity
return arity if arity != -1 || method.is_a?(Proc)
sig = T::Private::Methods.signature_for_method(method)
sig ? sig.method.arity : arity
end
# Elide the middle of a string as needed and replace it with an ellipsis.
# Keep the given number of characters at the start and end of the string.
#
# This method operates on string length, not byte length.
#
# If the string is shorter than the requested truncation length, return it
# without adding an ellipsis. This method may return a longer string than
# the original if the characters removed are shorter than the ellipsis.
#
# @param [String] str
#
# @param [Fixnum] start_len The length of string before the ellipsis
# @param [Fixnum] end_len The length of string after the ellipsis
#
# @param [String] ellipsis The string to add in place of the elided text
#
# @return [String]
#
def self.string_truncate_middle(str, start_len, end_len, ellipsis='...')
return unless str
raise ArgumentError.new('must provide start_len') unless start_len
raise ArgumentError.new('must provide end_len') unless end_len
raise ArgumentError.new('start_len must be >= 0') if start_len < 0
raise ArgumentError.new('end_len must be >= 0') if end_len < 0
str = str.to_s
return str if str.length <= start_len + end_len
start_part = str[0...start_len - ellipsis.length]
end_part = end_len == 0 ? '' : str[-end_len..-1]
"#{start_part}#{ellipsis}#{end_part}"
end
def self.lift_enum(enum)
unless enum.is_a?(T::Types::Enum)
raise ArgumentError.new("#{enum.inspect} is not a T.deprecated_enum")
end
classes = enum.values.map(&:class).uniq
if classes.empty?
T.untyped
elsif classes.length > 1
T::Types::Union.new(classes)
else
T::Types::Simple::Private::Pool.type_for_module(classes.first)
end
end
module Nilable
# :is_union_type, T::Boolean: whether the type is an T::Types::Union type
# :non_nilable_type, Class: if it is an T.nilable type, the corresponding underlying type; otherwise, nil.
TypeInfo = Struct.new(:is_union_type, :non_nilable_type)
NIL_TYPE = T::Utils.coerce(NilClass)
def self.get_type_info(prop_type)
if prop_type.is_a?(T::Types::Union)
non_nilable_type = T::Utils.unwrap_nilable(prop_type)
if non_nilable_type&.is_a?(T::Types::Simple)
non_nilable_type = non_nilable_type.raw_type
end
TypeInfo.new(true, non_nilable_type)
else
TypeInfo.new(false, nil)
end
end
# Get the underlying type inside prop_type:
# - if the type is A, the function returns A
# - if the type is T.nilable(A), the function returns A
def self.get_underlying_type(prop_type)
type_info = get_type_info(prop_type)
if type_info.is_union_type
type_info.non_nilable_type || prop_type
elsif prop_type.is_a?(T::Types::Simple)
prop_type.raw_type
else
prop_type
end
end
# The difference between this function and the above function is that the Sorbet type, like T::Types::Simple
# is preserved.
def self.get_underlying_type_object(prop_type)
T::Utils.unwrap_nilable(prop_type) || prop_type
end
def self.is_union_with_nilclass(prop_type)
case prop_type
when T::Types::Union
prop_type.types.include?(NIL_TYPE)
else
false
end
end
end
end

View File

@ -1,146 +0,0 @@
# typed: ignore
begin
gem "sorbet-runtime"
return
rescue Gem::LoadError
end
module T
class << self
def absurd(value); end
def all(type_a, type_b, *types); end
def any(type_a, type_b, *types); end
def attached_class; end
def class_of(klass); end
def enum(values); end
def nilable(type); end
def noreturn; end
def self_type; end
def type_alias(type=nil, &_blk); end
def type_parameter(name); end
def untyped; end
def assert_type!(value, _type, _checked: true)
value
end
def cast(value, _type, _checked: true)
value
end
def let(value, _type, _checked: true)
value
end
def must(arg, _msg = nil)
arg
end
def proc
T::Proc.new
end
def reveal_type(value)
value
end
def unsafe(value)
value
end
end
module Sig
def sig(arg0=nil, &blk); end
end
module Helpers
def abstract!; end
def interface!; end
def final!; end
def sealed!; end
def mixes_in_class_methods(mod); end
end
module Generic
include T::Helpers
def type_parameters(*params); end
def type_member(variance=:invariant, fixed: nil, lower: nil, upper: BasicObject); end
def type_template(variance=:invariant, fixed: nil, lower: nil, upper: BasicObject); end
def [](*types)
self
end
end
module Array
def self.[](type); end
end
Boolean = Object.new.freeze
module Configuration
def self.call_validation_error_handler(signature, opts); end
def self.call_validation_error_handler=(value); end
def self.default_checked_level=(default_checked_level); end
def self.enable_checking_for_sigs_marked_checked_tests; end
def self.enable_final_checks_on_hooks; end
def self.enable_legacy_t_enum_migration_mode; end
def self.reset_final_checks_on_hooks; end
def self.hard_assert_handler(str, extra); end
def self.hard_assert_handler=(value); end
def self.inline_type_error_handler(error); end
def self.inline_type_error_handler=(value); end
def self.log_info_handler(str, extra); end
def self.log_info_handler=(value); end
def self.scalar_types; end
def self.scalar_types=(values); end
def self.sealed_violation_whitelist; end
def self.sealed_violation_whitelist=(sealed_violation_whitelist); end
def self.sig_builder_error_handler(error, location); end
def self.sig_builder_error_handler=(value); end
def self.sig_validation_error_handler(error, opts); end
def self.sig_validation_error_handler=(value); end
def self.soft_assert_handler(str, extra); end
def self.soft_assert_handler=(value); end
end
module Enumerable
def self.[](type); end
end
module Enumerator
def self.[](type); end
end
module Hash
def self.[](keys, values); end
end
class Proc
def bind(*_)
self
end
def params(*_param)
self
end
def void
self
end
def returns(_type)
self
end
end
module Range
def self.[](type); end
end
module Set
def self.[](type); end
end
end