# typed: true # frozen_string_literal: true require 'pathname' module Tapioca module Compilers module SymbolTable class SymbolGenerator extend(T::Sig) IGNORED_SYMBOLS = %w{ YAML MiniTest Mutex } attr_reader(:gem, :indent) sig { params(gem: Gemfile::Gem, indent: Integer).void } def initialize(gem, indent = 0) @gem = gem @indent = indent @seen = Set.new @alias_namespace ||= Set.new end sig { returns(String) } def generate symbols .sort .map { |symbol| generate_from_symbol(symbol) } .compact .join("\n\n") .concat("\n") end private sig { returns(T::Set[String]) } def symbols symbols = Tapioca::Compilers::SymbolTable::SymbolLoader.list_from_paths(gem.files) symbols.union(engine_symbols(symbols)) end sig { params(symbols: T::Set[String]).returns(T::Set[String]) } def engine_symbols(symbols) return Set.new unless Object.const_defined?("Rails::Engine") engine = Object.const_get("Rails::Engine") .descendants.reject(&:abstract_railtie?) .find do |klass| name = name_of(klass) !name.nil? && symbols.include?(name) end return Set.new unless engine paths = engine.config.eager_load_paths.flat_map do |load_path| Pathname.glob("#{load_path}/**/*.rb") end Tapioca::Compilers::SymbolTable::SymbolLoader.list_from_paths(paths) rescue Set.new end sig { params(symbol: String).returns(T.nilable(String)) } def generate_from_symbol(symbol) constant = resolve_constant(symbol) return unless constant compile(symbol, constant) end sig { params(symbol: String, inherit: T::Boolean).returns(BasicObject).checked(:never) } def resolve_constant(symbol, inherit: false) Object.const_get(symbol, inherit) rescue NameError, LoadError, RuntimeError, ArgumentError, TypeError nil end sig do params(name: T.nilable(String), constant: BasicObject) .returns(T.nilable(String)) .checked(:never) end def compile(name, constant) return unless constant return unless name return if name.strip.empty? return if name.start_with?('#<') return if name.downcase == name return if alias_namespaced?(name) return if seen?(name) return unless parent_declares_constant?(name) return if T::Enum === constant # T::Enum instances are defined via `compile_enums` mark_seen(name) compile_constant(name, constant) end sig do params(name: String, constant: BasicObject) .returns(T.nilable(String)) .checked(:never) end def compile_constant(name, constant) case constant when Module if name_of(constant) != name compile_alias(name, constant) else compile_module(name, constant) end else compile_object(name, constant) end end sig { params(name: String, constant: Module).returns(T.nilable(String)) } def compile_alias(name, constant) return if symbol_ignored?(name) target = name_of(constant) # If target has no name, let's make it an anonymous class or module with `Class.new` or `Module.new` target = "#{constant.class}.new" unless target add_to_alias_namespace(name) return if IGNORED_SYMBOLS.include?(name) indented("#{name} = #{target}") end sig do params(name: String, value: BasicObject) .returns(T.nilable(String)) .checked(:never) end def compile_object(name, value) return if symbol_ignored?(name) klass = class_of(value) return if name_of(klass)&.start_with?("T::Types::", "T::Private::") type_name = public_module?(klass) && name_of(klass) || "T.untyped" indented("#{name} = T.let(T.unsafe(nil), #{type_name})") end sig { params(name: String, constant: Module).returns(T.nilable(String)) } def compile_module(name, constant) return unless public_module?(constant) return unless defined_in_gem?(constant, strict: false) header = if constant.is_a?(Class) indented("class #{name}#{compile_superclass(constant)}") else indented("module #{name}") end body = compile_body(name, constant) return if symbol_ignored?(name) && body.nil? [ header, body, indented("end"), compile_subconstants(name, constant), ].select { |b| !b.nil? && b.strip != "" }.join("\n") end sig { params(name: String, constant: Module).returns(T.nilable(String)) } def compile_body(name, constant) with_indentation do methods = compile_methods(name, constant) return if symbol_ignored?(name) && methods.nil? [ compile_module_helpers(constant), compile_mixins(constant), compile_mixes_in_class_methods(constant), compile_props(constant), compile_enums(constant), methods, ].select { |b| b != "" }.join("\n\n") end end sig { params(constant: Module).returns(String) } def compile_module_helpers(constant) abstract_type = T::Private::Abstract::Data.get(constant, :abstract_type) helpers = [] helpers << indented("#{abstract_type}!") if abstract_type helpers << indented("final!") if T::Private::Final.final_module?(constant) helpers << indented("sealed!") if T::Private::Sealed.sealed_module?(constant) helpers.join("\n") end sig { params(constant: Module).returns(String) } def compile_props(constant) return "" unless T::Props::ClassMethods === constant constant.props.map do |name, prop| method = "prop" method = "const" if prop.fetch(:immutable, false) type = prop.fetch(:type_object, "T.untyped").to_s.gsub(".returns()", ".void") if prop.key?(:default) indented("#{method} :#{name}, #{type}, default: T.unsafe(nil)") else indented("#{method} :#{name}, #{type}") end end.join("\n") end sig { params(constant: Module).returns(String) } def compile_enums(constant) return "" unless T::Enum > constant enums = T.cast(constant, T::Enum).values.map do |enum_type| enum_type.instance_variable_get(:@const_name).to_s end content = [ indented('enums do'), *enums.map { |e| indented(" #{e} = new") }.join("\n"), indented('end'), ] content.join("\n") end sig { params(name: String, constant: Module).returns(T.nilable(String)) } def compile_subconstants(name, constant) output = constants_of(constant).sort.uniq.map do |constant_name| symbol = (name == "Object" ? "" : name) + "::#{constant_name}" subconstant = resolve_constant(symbol) # Don't compile modules of Object because Object::Foo == Foo # Don't compile modules of BasicObject because BasicObject::BasicObject == BasicObject next if (Object == constant || BasicObject == constant) && Module === subconstant next unless subconstant compile(symbol, subconstant) end.compact return "" if output.empty? "\n" + output.join("\n\n") end sig { params(constant: Class).returns(String) } def compile_superclass(constant) superclass = T.let(nil, T.nilable(Class)) # rubocop:disable Lint/UselessAssignment while (superclass = superclass_of(constant)) constant_name = name_of(constant) constant = superclass # Some classes have superclasses that are private constants # so if we generate code with that superclass, the output # will not be compilable (since private constants are not # publicly visible). # # So we skip superclasses that are not public and walk up the # chain. next unless public_module?(superclass) # Some types have "themselves" as their superclass # which can happen via: # # class A < Numeric; end # A = Class.new(A) # A.superclass #=> A # # We compare names here to make sure we skip those # superclass instances and walk up the chain. # # The name comparison is against the name of the constant # resolved from the name of the superclass, since # this is also possible: # # B = Class.new # class A < B; end # B = A # A.superclass.name #=> "B" # B #=> A superclass_name = T.must(name_of(superclass)) resolved_superclass = resolve_constant(superclass_name) next unless Module === resolved_superclass next if name_of(resolved_superclass) == constant_name # We found a suitable superclass break end return "" if superclass == ::Object || superclass == ::Delegator return "" if superclass.nil? name = name_of(superclass) return "" if name.nil? || name.empty? " < ::#{name}" end sig { params(constant: Module).returns(String) } def compile_mixins(constant) singleton_class = singleton_class_of(constant) interesting_ancestors = interesting_ancestors_of(constant) interesting_singleton_class_ancestors = interesting_ancestors_of(singleton_class) prepend = interesting_ancestors.take_while { |c| !are_equal?(constant, c) } include = interesting_ancestors.drop(prepend.size + 1) extend = interesting_singleton_class_ancestors.reject do |mod| !public_module?(mod) || Module != class_of(mod) || are_equal?(mod, singleton_class) end prepends = prepend .reverse .select { |mod| (name = name_of(mod)) && !name.start_with?("T::") } .select(&method(:public_module?)) .map do |mod| # TODO: Sorbet currently does not handle prepend # properly for method resolution, so we generate an # include statement instead indented("include(#{qualified_name_of(mod)})") end includes = include .reverse .select { |mod| (name = name_of(mod)) && !name.start_with?("T::") } .select(&method(:public_module?)) .map do |mod| indented("include(#{qualified_name_of(mod)})") end extends = extend .reverse .select { |mod| (name = name_of(mod)) && !name.start_with?("T::") } .select(&method(:public_module?)) .map do |mod| indented("extend(#{qualified_name_of(mod)})") end (prepends + includes + extends).join("\n") end sig { params(constant: Module).returns(String) } def compile_mixes_in_class_methods(constant) return "" if constant.is_a?(Class) mixins_from_modules = {} Class.new do # rubocop:disable Style/MethodMissingSuper, Style/MissingRespondToMissing def method_missing(symbol, *args) end define_singleton_method(:include) do |mod| begin before = singleton_class.ancestors super(mod).tap do mixins_from_modules[mod] = singleton_class.ancestors - before end rescue Exception # rubocop:disable Lint/RescueException # this is a best effort, bail if we can't perform this end end class << self def method_missing(symbol, *args) end end # rubocop:enable Style/MethodMissingSuper, Style/MissingRespondToMissing end.include(constant) all_dynamic_extends = mixins_from_modules.delete(constant) all_dynamic_includes = mixins_from_modules.keys dynamic_extends_from_dynamic_includes = mixins_from_modules.values.flatten dynamic_extends = all_dynamic_extends - dynamic_extends_from_dynamic_includes result = all_dynamic_includes .select { |mod| (name = name_of(mod)) && !name.start_with?("T::") } .select(&method(:public_module?)) .map do |mod| indented("include(#{qualified_name_of(mod)})") end.join("\n") ancestors = singleton_class_of(constant).ancestors extends_as_concern = ancestors.any? do |mod| qualified_name_of(mod) == "::ActiveSupport::Concern" end class_methods_module = resolve_constant("#{name_of(constant)}::ClassMethods") mixed_in_module = if extends_as_concern && Module === class_methods_module class_methods_module else dynamic_extends.find do |mod| mod != constant && public_module?(mod) end end return result if mixed_in_module.nil? qualified_name = qualified_name_of(mixed_in_module) return result if qualified_name == "" [ result, indented("mixes_in_class_methods(#{qualified_name})"), ].select { |b| b != "" }.join("\n\n") rescue "" end sig { params(name: String, constant: Module).returns(T.nilable(String)) } def compile_methods(name, constant) initialize_method = compile_method( name, constant, initialize_method_for(constant) ) instance_methods = compile_directly_owned_methods(name, constant) singleton_methods = compile_directly_owned_methods(name, singleton_class_of(constant)) return if symbol_ignored?(name) && instance_methods.empty? && singleton_methods.empty? [ initialize_method || "", instance_methods, singleton_methods, ].select { |b| b.strip != "" }.join("\n\n") end sig { params(module_name: String, mod: Module, for_visibility: T::Array[Symbol]).returns(String) } def compile_directly_owned_methods(module_name, mod, for_visibility = [:public, :protected, :private]) indent_step = 0 preamble = nil postamble = nil if mod.singleton_class? indent_step = 1 preamble = indented("class << self") postamble = indented("end") end methods = with_indentation(indent_step) do method_names_by_visibility(mod) .delete_if { |visibility, _method_list| !for_visibility.include?(visibility) } .flat_map do |visibility, method_list| compiled = method_list.sort!.map do |name| next if name == :initialize compile_method(module_name, mod, mod.instance_method(name)) end compiled.compact! unless compiled.empty? || visibility == :public # add visibility badge compiled.unshift('', indented(visibility.to_s), '') end compiled end .compact .join("\n") end return "" if methods.strip == "" [ preamble, methods, postamble, ].compact.join("\n") end sig { params(mod: Module).returns(T::Hash[Symbol, T::Array[Symbol]]) } def method_names_by_visibility(mod) { public: Module.instance_method(:public_instance_methods).bind(mod).call, protected: Module.instance_method(:protected_instance_methods).bind(mod).call, private: Module.instance_method(:private_instance_methods).bind(mod).call, } end sig { params(constant: Module, method_name: String).returns(T::Boolean) } def struct_method?(constant, method_name) return false unless T::Props::ClassMethods === constant constant .props .keys .include?(method_name.gsub(/=$/, '').to_sym) end sig do params( symbol_name: String, constant: Module, method: T.nilable(UnboundMethod) ).returns(T.nilable(String)) end def compile_method(symbol_name, constant, method) return unless method return unless method.owner == constant return if symbol_ignored?(symbol_name) && !method_in_gem?(method) signature = signature_of(method) method = T.let(signature.method, UnboundMethod) if signature method_name = method.name.to_s return unless valid_method_name?(method_name) return if struct_method?(constant, method_name) return if method_name.start_with?("__t_props_generated_") parameters = T.let(method.parameters, T::Array[[Symbol, T.nilable(Symbol)]]) sanitized_parameters = parameters.each_with_index.map do |(type, name), index| fallback_arg_name = "_arg#{index}" unless name # For attr_writer methods, Sorbet signatures have the name # of the method (without the trailing = sign) as the name of # the only parameter. So, if the parameter does not have a name # then the replacement name should be the name of the method # (minus trailing =) if and only if there is a signature for the # method and the parameter is required and there is a single # parameter and the signature also defines a single parameter and # the name of the method ends with a = character. writer_method_with_sig = ( signature && type == :req && parameters.size == 1 && signature.arg_types.size == 1 && method_name[-1] == "=" ) name = if writer_method_with_sig T.must(method_name[0...-1]).to_sym else fallback_arg_name end end # Sanitize param names name = name.to_s.gsub(/[^a-zA-Z0-9_]/, fallback_arg_name) [type, name] end parameter_list = sanitized_parameters.map do |type, name| case type when :req name when :opt "#{name} = T.unsafe(nil)" when :rest "*#{name}" when :keyreq "#{name}:" when :key "#{name}: T.unsafe(nil)" when :keyrest "**#{name}" when :block "&#{name}" end end.join(', ') parameter_list = "(#{parameter_list})" if parameter_list != "" signature_str = indented(compile_signature(signature, sanitized_parameters)) if signature [ signature_str, indented("def #{method_name}#{parameter_list}; end"), ].compact.join("\n") end TYPE_PARAMETER_MATCHER = /T\.type_parameter\(:?([[:word:]]+)\)/ sig { params(signature: T.untyped, parameters: T::Array[[Symbol, String]]).returns(String) } def compile_signature(signature, parameters) parameter_types = T.let(signature.arg_types.to_h, T::Hash[Symbol, T::Types::Base]) parameter_types.merge!(signature.kwarg_types) parameter_types[signature.rest_name] = signature.rest_type if signature.has_rest parameter_types[signature.keyrest_name] = signature.keyrest_type if signature.has_keyrest parameter_types[signature.block_name] = signature.block_type if signature.block_name params = parameters.map do |_, name| type = parameter_types[name.to_sym] "#{name}: #{type}" end.join(", ") returns = type_of(signature.return_type) type_parameters = (params + returns).scan(TYPE_PARAMETER_MATCHER).flatten.uniq.map { |p| ":#{p}" }.join(", ") type_parameters = ".type_parameters(#{type_parameters})" unless type_parameters.empty? mode = case signature.mode when "abstract" ".abstract" when "override" ".override" when "overridable_override" ".overridable.override" when "overridable" ".overridable" else "" end signature_body = +"" signature_body << mode signature_body << type_parameters signature_body << ".params(#{params})" unless params.empty? signature_body << ".returns(#{returns})" signature_body = signature_body .gsub(".returns()", ".void") .gsub("", "T.untyped") .gsub(".params()", "") .gsub(TYPE_PARAMETER_MATCHER, "T.type_parameter(:\\1)")[1..-1] "sig { #{signature_body} }" end sig { params(symbol_name: String).returns(T::Boolean) } def symbol_ignored?(symbol_name) SymbolLoader.ignore_symbol?(symbol_name) end SPECIAL_METHOD_NAMES = %w[! ~ +@ ** -@ * / % + - << >> & | ^ < <= => > >= == === != =~ !~ <=> [] []= `] sig { params(name: String).returns(T::Boolean) } def valid_method_name?(name) return true if SPECIAL_METHOD_NAMES.include?(name) !!name.match(/^[[:word:]]+[?!=]?$/) end sig do type_parameters(:U) .params( step: Integer, _blk: T.proc .returns(T.type_parameter(:U)) ) .returns(T.type_parameter(:U)) end def with_indentation(step = 1, &_blk) @indent += 2 * step yield ensure @indent -= 2 * step end sig { params(str: String).returns(String) } def indented(str) " " * @indent + str end sig { params(method: UnboundMethod).returns(T::Boolean) } def method_in_gem?(method) source_location = method.source_location&.first return false if source_location.nil? gem.contains_path?(source_location) end sig { params(constant: Module, strict: T::Boolean).returns(T::Boolean) } def defined_in_gem?(constant, strict: true) files = Set.new(get_file_candidates(constant)) .merge(Tapioca::ConstantLocator.files_for(constant)) return !strict if files.empty? files.any? do |file| gem.contains_path?(file) end end sig { params(constant: Module).returns(T::Array[String]) } def get_file_candidates(constant) wrapped_module = Pry::WrappedModule.new(constant) wrapped_module.candidates.map(&:file).to_a.compact rescue ArgumentError, NameError [] end sig { params(name: String).void } def add_to_alias_namespace(name) @alias_namespace.add("#{name}::") end sig { params(name: String).returns(T::Boolean) } def alias_namespaced?(name) @alias_namespace.any? do |namespace| name.start_with?(namespace) end end sig { params(name: String).void } def mark_seen(name) @seen.add(name) end sig { params(name: String).returns(T::Boolean) } def seen?(name) @seen.include?(name) end def initialize_method_for(constant) constant.instance_method(:initialize) rescue nil end def parent_declares_constant?(name) name_parts = name.split("::") parent_name = name_parts[0...-1].join("::") parent_name = parent_name[2..-1] if parent_name.start_with?("::") parent_name = 'Object' if parent_name == "" parent = T.cast(resolve_constant(parent_name), T.nilable(Module)) return false unless parent constants_of(parent).include?(name_parts.last.to_sym) end sig { params(constant: Module).returns(T::Boolean) } def public_module?(constant) constant_name = name_of(constant) return false unless constant_name return false if constant_name.start_with?('T::Private') begin # can't use !! here because the constant might override ! and mess with us Module === eval(constant_name) # rubocop:disable Security/Eval rescue NameError false end end sig { params(constant: BasicObject).returns(Class).checked(:never) } def class_of(constant) Kernel.instance_method(:class).bind(constant).call end sig { params(constant: Module).returns(T::Array[Symbol]) } def constants_of(constant) Module.instance_method(:constants).bind(constant).call(false) end sig { params(constant: Module).returns(T.nilable(String)) } def raw_name_of(constant) Module.instance_method(:name).bind(constant).call end sig { params(constant: Module).returns(Class) } def singleton_class_of(constant) Object.instance_method(:singleton_class).bind(constant).call end sig { params(constant: Module).returns(T::Array[Module]) } def ancestors_of(constant) Module.instance_method(:ancestors).bind(constant).call end sig { params(constant: Module).returns(T::Array[Module]) } def inherited_ancestors_of(constant) if Class === constant ancestors_of(superclass_of(constant) || Object) else Module.ancestors end end sig { params(constant: Module).returns(T::Array[Module]) } def interesting_ancestors_of(constant) inherited_ancestors_ids = Set.new( inherited_ancestors_of(constant).map { |mod| object_id_of(mod) } ) # TODO: There is actually a bug here where this will drop modules that # may be included twice. For example: # # ```ruby # class Foo # prepend Kernel # end # ```` # would give: # ```ruby # Foo.ancestors #=> [Kernel, Foo, Object, Kernel, BasicObject] # ```` # but since we drop `Kernel` whenever we match it, we would miss # the `prepend Kernel` in the output. # # Instead, we should only drop the tail matches of the ancestors and # inherited ancestors, past the location of the constant itself. constant.ancestors.reject do |mod| inherited_ancestors_ids.include?(object_id_of(mod)) end end sig { params(constant: Module).returns(T.nilable(String)) } def name_of(constant) name = name_of_proxy_target(constant) return name if name name = raw_name_of(constant) return if name.nil? return unless are_equal?(constant, resolve_constant(name, inherit: true)) name = "Struct" if name =~ /^(::)?Struct::[^:]+$/ name end sig { params(constant: Module).returns(T.nilable(String)) } def name_of_proxy_target(constant) klass = class_of(constant) return unless raw_name_of(klass) == "ActiveSupport::Deprecation::DeprecatedConstantProxy" # We are dealing with a ActiveSupport::Deprecation::DeprecatedConstantProxy # so try to get the name of the target class begin target = Kernel.instance_method(:send).bind(constant).call(:target) rescue NoMethodError return nil end raw_name_of(target) end sig { params(constant: Module).returns(T.nilable(String)) } def qualified_name_of(constant) name = name_of(constant) return if name.nil? if name.start_with?("::") name else "::#{name}" end end sig { params(constant: Class).returns(T.nilable(Class)) } def superclass_of(constant) Class.instance_method(:superclass).bind(constant).call end sig { params(method: T.any(UnboundMethod, Method)).returns(T.untyped) } def signature_of(method) T::Private::Methods.signature_for_method(method) rescue LoadError, StandardError nil end sig { params(constant: Module).returns(String) } def type_of(constant) constant.to_s.gsub(/\bAttachedClass\b/, "T.attached_class") end sig { params(object: Object).returns(T::Boolean).checked(:never) } def object_id_of(object) Object.instance_method(:object_id).bind(object).call end sig { params(constant: Module, other: BasicObject).returns(T::Boolean).checked(:never) } def are_equal?(constant, other) BasicObject.instance_method(:equal?).bind(constant).call(other) end end end end end