class Solargraph::TypeChecker
A static analysis tool for validating data types.
Attributes
@return [ApiMap]
@return [String]
@return [Rules]
Public Class Methods
@param filename [String] @return [self]
# File lib/solargraph/type_checker.rb, line 53 def load filename, level = :normal source = Solargraph::Source.load(filename) api_map = Solargraph::ApiMap.new api_map.map(source) new(filename, api_map: api_map, level: level) end
@param code [String] @param filename [String, nil] @return [self]
# File lib/solargraph/type_checker.rb, line 63 def load_string code, filename = nil, level = :normal source = Solargraph::Source.load_string(code, filename) api_map = Solargraph::ApiMap.new api_map.map(source) new(filename, api_map: api_map, level: level) end
@param filename [String] @param api_map
[ApiMap] @param level [Symbol]
# File lib/solargraph/type_checker.rb, line 27 def initialize filename, api_map: nil, level: :normal @filename = filename # @todo Smarter directory resolution @api_map = api_map || Solargraph::ApiMap.load(File.dirname(filename)) @rules = Rules.new(level) @marked_ranges = [] end
Public Instance Methods
@return [Array<Problem>]
# File lib/solargraph/type_checker.rb, line 41 def problems @problems ||= begin method_tag_problems .concat variable_type_tag_problems .concat const_problems .concat call_problems end end
@return [SourceMap]
# File lib/solargraph/type_checker.rb, line 36 def source_map @source_map ||= api_map.source_map(filename) end
Private Instance Methods
# File lib/solargraph/type_checker.rb, line 504 def abstract? pin pin.docstring.has_tag?(:abstract) || (pin.closure && pin.closure.docstring.has_tag?(:abstract)) end
@return [Array<Pin::BaseVariable>]
# File lib/solargraph/type_checker.rb, line 194 def all_variables source_map.pins_by_class(Pin::BaseVariable) + source_map.locals.select { |pin| pin.is_a?(Pin::LocalVariable) } end
# File lib/solargraph/type_checker.rb, line 250 def argument_problems_for chain, api_map, block_pin, locals, location result = [] base = chain until base.links.length == 1 && base.undefined? pins = base.define(api_map, block_pin, locals) if pins.first.is_a?(Pin::Method) # @type [Pin::Method] pin = pins.first ap = if base.links.last.is_a?(Solargraph::Source::Chain::ZSuper) arity_problems_for(pin, fake_args_for(block_pin), location) else arity_problems_for(pin, base.links.last.arguments, location) end unless ap.empty? result.concat ap break end break unless rules.validate_calls? params = first_param_hash(pins) pin.parameters.each_with_index do |par, idx| argchain = base.links.last.arguments[idx] if argchain.nil? && par.decl == :arg result.push Problem.new(location, "Not enough arguments to #{pin.path}") break end if argchain if par.decl != :arg result.concat kwarg_problems_for argchain, api_map, block_pin, locals, location, pin, params, idx break else ptype = params.key?(par.name) ? params[par.name][:qualified] : ComplexType::UNDEFINED if ptype.nil? # @todo Some level (strong, I guess) should require the param here else argtype = argchain.infer(api_map, block_pin, locals) if argtype.defined? && ptype.defined? && !any_types_match?(api_map, ptype, argtype) result.push Problem.new(location, "Wrong argument type for #{pin.path}: #{par.name} expected #{ptype}, received #{argtype}") end end end elsif par.rest? next elsif par.decl == :kwarg result.push Problem.new(location, "Call to #{pin.path} is missing keyword argument #{par.name}") break end end end base = base.base end result end
@param pin [Pin::Method]
# File lib/solargraph/type_checker.rb, line 414 def arity_problems_for(pin, arguments, location) ([pin] + pin.overloads).map do |p| result = pin_arity_problems_for(p, arguments, location) return [] if result.empty? result end.flatten.uniq(&:message) end
# File lib/solargraph/type_checker.rb, line 216 def call_problems result = [] Solargraph::Parser::NodeMethods.call_nodes_from(source_map.source.node).each do |call| rng = Solargraph::Range.from_node(call) next if @marked_ranges.any? { |d| d.contain?(rng.start) } chain = Solargraph::Parser.chain(call, filename) block_pin = source_map.locate_block_pin(rng.start.line, rng.start.column) location = Location.new(filename, rng) locals = source_map.locals_at(location) type = chain.infer(api_map, block_pin, locals) if type.undefined? && !rules.ignore_all_undefined? base = chain missing = chain found = nil closest = ComplexType::UNDEFINED until base.links.first.undefined? found = base.define(api_map, block_pin, locals).first break if found missing = base base = base.base end closest = found.typify(api_map) if found if !found || (closest.defined? && internal_or_core?(found)) unless ignored_pins.include?(found) result.push Problem.new(location, "Unresolved call to #{missing.links.last.word}") @marked_ranges.push rng end end end result.concat argument_problems_for(chain, api_map, block_pin, locals, location) end result end
# File lib/solargraph/type_checker.rb, line 198 def const_problems return [] unless rules.validate_consts? result = [] Solargraph::Parser::NodeMethods.const_nodes_from(source_map.source.node).each do |const| rng = Solargraph::Range.from_node(const) chain = Solargraph::Parser.chain(const, filename) block_pin = source_map.locate_block_pin(rng.start.line, rng.start.column) location = Location.new(filename, rng) locals = source_map.locals_at(location) pins = chain.define(api_map, block_pin, locals) if pins.empty? result.push Problem.new(location, "Unresolved constant #{Solargraph::Parser::NodeMethods.unpack_name(const)}") @marked_ranges.push location.range end end result end
# File lib/solargraph/type_checker.rb, line 386 def declared_externally? pin return true if pin.assignment.nil? chain = Solargraph::Parser.chain(pin.assignment, filename) rng = Solargraph::Range.from_node(pin.assignment) block_pin = source_map.locate_block_pin(rng.start.line, rng.start.column) location = Location.new(filename, Range.from_node(pin.assignment)) locals = source_map.locals_at(location) type = chain.infer(api_map, block_pin, locals) if type.undefined? && !rules.ignore_all_undefined? base = chain missing = chain found = nil closest = ComplexType::UNDEFINED until base.links.first.undefined? found = base.define(api_map, block_pin, locals).first break if found missing = base base = base.base end closest = found.typify(api_map) if found if !found || closest.defined? || internal?(found) return false end end true end
@param pin [Pin::Base]
# File lib/solargraph/type_checker.rb, line 382 def external? pin !internal? pin end
# File lib/solargraph/type_checker.rb, line 509 def fake_args_for(pin) args = [] with_opts = false with_block = false pin.parameters.each do |pin| if [:kwarg, :kwoptarg, :kwrestarg].include?(pin.decl) with_opts = true elsif pin.decl == :block with_block = true elsif pin.decl == :restarg args.push Solargraph::Source::Chain.new([Solargraph::Source::Chain::Variable.new(pin.name)], nil, true) else args.push Solargraph::Source::Chain.new([Solargraph::Source::Chain::Variable.new(pin.name)]) end end args.push Solargraph::Parser.chain_string('{}') if with_opts args.push Solargraph::Parser.chain_string('&') if with_block args end
@param [Array<Pin::Method>] @return [Hash]
# File lib/solargraph/type_checker.rb, line 363 def first_param_hash(pins) pins.each do |pin| result = param_hash(pin) return result unless result.empty? end {} end
# File lib/solargraph/type_checker.rb, line 151 def ignored_pins @ignored_pins ||= [] end
@param pin [Pin::Base]
# File lib/solargraph/type_checker.rb, line 372 def internal? pin return false if pin.nil? pin.location && api_map.bundled?(pin.location.filename) end
# File lib/solargraph/type_checker.rb, line 377 def internal_or_core? pin internal?(pin) || api_map.yard_map.core_pins.include?(pin) || api_map.yard_map.stdlib_pins.include?(pin) end
# File lib/solargraph/type_checker.rb, line 303 def kwarg_problems_for argchain, api_map, block_pin, locals, location, pin, params, first result = [] kwargs = convert_hash(argchain.node) pin.parameters[first..-1].each_with_index do |par, cur| idx = first + cur argchain = kwargs[par.name.to_sym] if par.decl == :kwrestarg || (par.decl == :optarg && idx == pin.parameters.length - 1 && par.asgn_code == '{}') result.concat kwrestarg_problems_for(api_map, block_pin, locals, location, pin, params, kwargs) else if argchain data = params[par.name] if data.nil? # @todo Some level (strong, I guess) should require the param here else ptype = data[:qualified] next if ptype.undefined? argtype = argchain.infer(api_map, block_pin, locals) if argtype.defined? && ptype && !any_types_match?(api_map, ptype, argtype) result.push Problem.new(location, "Wrong argument type for #{pin.path}: #{par.name} expected #{ptype}, received #{argtype}") end end elsif par.decl == :kwarg result.push Problem.new(location, "Call to #{pin.path} is missing keyword argument #{par.name}") end end end result end
# File lib/solargraph/type_checker.rb, line 332 def kwrestarg_problems_for(api_map, block_pin, locals, location, pin, params, kwargs) result = [] kwargs.each_pair do |pname, argchain| next unless params.key?(pname.to_s) ptype = params[pname.to_s][:qualified] argtype = argchain.infer(api_map, block_pin, locals) if argtype.defined? && ptype && !any_types_match?(api_map, ptype, argtype) result.push Problem.new(location, "Wrong argument type for #{pin.path}: #{pname} expected #{ptype}, received #{argtype}") end end result end
@param pin [Pin::Method] @return [Array<Problem>]
# File lib/solargraph/type_checker.rb, line 130 def method_param_type_problems_for pin stack = api_map.get_method_stack(pin.namespace, pin.name, scope: pin.scope) params = first_param_hash(stack) result = [] if rules.require_type_tags? pin.parameters.each do |par| break if par.decl == :restarg || par.decl == :kwrestarg || par.decl == :blockarg unless params[par.name] result.push Problem.new(pin.location, "Missing @param tag for #{par.name} on #{pin.path}", pin: pin) end end end params.each_pair do |name, data| type = data[:qualified] if type.undefined? result.push Problem.new(pin.location, "Unresolved type #{data[:tagged]} for #{name} param on #{pin.path}", pin: pin) end end result end
@param pin [Pin::Method] @return [Array<Problem>]
# File lib/solargraph/type_checker.rb, line 86 def method_return_type_problems_for pin return [] if pin.is_a?(Pin::MethodAlias) result = [] declared = pin.typify(api_map).self_to(pin.full_context.namespace) if declared.undefined? if pin.return_type.undefined? && rules.require_type_tags? result.push Problem.new(pin.location, "Missing @return tag for #{pin.path}", pin: pin) elsif pin.return_type.defined? && !resolved_constant?(pin) result.push Problem.new(pin.location, "Unresolved return type #{pin.return_type} for #{pin.path}", pin: pin) elsif rules.must_tag_or_infer? && pin.probe(api_map).undefined? result.push Problem.new(pin.location, "Untyped method #{pin.path} could not be inferred") end elsif rules.validate_tags? unless pin.node.nil? || declared.void? || virtual_pin?(pin) || abstract?(pin) inferred = pin.probe(api_map).self_to(pin.full_context.namespace) if inferred.undefined? unless rules.ignore_all_undefined? || external?(pin) result.push Problem.new(pin.location, "#{pin.path} return type could not be inferred", pin: pin) end else unless (rules.rank > 1 ? types_match?(api_map, declared, inferred) : any_types_match?(api_map, declared, inferred)) result.push Problem.new(pin.location, "Declared return type #{declared} does not match inferred type #{inferred} for #{pin.path}", pin: pin) end end end end result end
@return [Array<Problem>]
# File lib/solargraph/type_checker.rb, line 74 def method_tag_problems result = [] # @param pin [Pin::Method] source_map.pins_by_class(Pin::Method).each do |pin| result.concat method_return_type_problems_for(pin) result.concat method_param_type_problems_for(pin) end result end
@param pin [Pin::Method]
# File lib/solargraph/type_checker.rb, line 495 def optional_param_count(pin) count = 0 pin.parameters.each do |param| next unless param.decl == :optarg count += 1 end count end
@param [Pin::Method] @return [Hash]
# File lib/solargraph/type_checker.rb, line 347 def param_hash(pin) tags = pin.docstring.tags(:param) return {} if tags.empty? result = {} tags.each do |tag| next if tag.types.nil? || tag.types.empty? result[tag.name.to_s] = { tagged: tag.types.join(', '), qualified: Solargraph::ComplexType.try_parse(*tag.types).qualify(api_map, pin.full_context.namespace) } end result end
@param pin [Pin::Method]
# File lib/solargraph/type_checker.rb, line 423 def pin_arity_problems_for(pin, arguments, location) return [] unless pin.explicit? return [] if pin.parameters.empty? && arguments.empty? if pin.parameters.empty? # Functions tagged param_tuple accepts two arguments (e.g., Hash#[]=) return [] if pin.docstring.tag(:param_tuple) && arguments.length == 2 return [] if arguments.length == 1 && arguments.last.links.last.is_a?(Source::Chain::BlockVariable) return [Problem.new(location, "Too many arguments to #{pin.path}")] end unchecked = arguments.clone add_params = 0 if unchecked.empty? && pin.parameters.any? { |param| param.decl == :kwarg } return [Problem.new(location, "Missing keyword arguments to #{pin.path}")] end settled_kwargs = 0 unless unchecked.empty? if any_splatted_call?(unchecked.map(&:node)) settled_kwargs = pin.parameters.count(&:keyword?) else kwargs = convert_hash(unchecked.last.node) if pin.parameters.any? { |param| [:kwarg, :kwoptarg].include?(param.decl) || param.kwrestarg? } if kwargs.empty? add_params += 1 else unchecked.pop pin.parameters.each do |param| next unless param.keyword? if kwargs.key?(param.name.to_sym) kwargs.delete param.name.to_sym settled_kwargs += 1 elsif param.decl == :kwarg return [] if arguments.last.links.last.is_a?(Solargraph::Source::Chain::Hash) && arguments.last.links.last.splatted? return [Problem.new(location, "Missing keyword argument #{param.name} to #{pin.path}")] end end kwargs.clear if pin.parameters.any?(&:kwrestarg?) unless kwargs.empty? return [Problem.new(location, "Unrecognized keyword argument #{kwargs.keys.first} to #{pin.path}")] end end end end end req = required_param_count(pin) if req + add_params < unchecked.length return [] if pin.parameters.any?(&:rest?) opt = optional_param_count(pin) return [] if unchecked.length <= req + opt if unchecked.length == req + opt + 1 && unchecked.last.links.last.is_a?(Source::Chain::BlockVariable) return [] end if req + add_params + 1 == unchecked.length && any_splatted_call?(unchecked.map(&:node)) && (pin.parameters.map(&:decl) & [:kwarg, :kwoptarg, :kwrestarg]).any? return [] end return [] if arguments.length - req == pin.parameters.select { |p| [:optarg, :kwoptarg].include?(p.decl) }.length return [Problem.new(location, "Too many arguments to #{pin.path}")] elsif unchecked.length < req - settled_kwargs && (arguments.empty? || (!arguments.last.splat? && !arguments.last.links.last.is_a?(Solargraph::Source::Chain::Hash))) # HACK: Kernel#raise signature is incorrect in Ruby 2.7 core docs. # See https://github.com/castwide/solargraph/issues/418 unless arguments.empty? && pin.path == 'Kernel#raise' return [Problem.new(location, "Not enough arguments to #{pin.path}")] end end [] end
@param pin [Pin::Method]
# File lib/solargraph/type_checker.rb, line 490 def required_param_count(pin) pin.parameters.sum { |param| %i[arg kwarg].include?(param.decl) ? 1 : 0 } end
@todo This is not optimal. A better solution would probably be to mix
namespace alias into types at the ApiMap level.
@param pin [Pin::Base] @return [Boolean]
# File lib/solargraph/type_checker.rb, line 120 def resolved_constant? pin api_map.get_constants('', pin.binder.tag).any? { |pin| pin.name == pin.return_type.namespace && ['Class', 'Module'].include?(pin.return_type.name) } end
@return [Array<Problem>]
# File lib/solargraph/type_checker.rb, line 156 def variable_type_tag_problems result = [] all_variables.each do |pin| if pin.return_type.defined? declared = pin.typify(api_map) next if declared.duck_type? if declared.defined? if rules.validate_tags? inferred = pin.probe(api_map) if inferred.undefined? next if rules.ignore_all_undefined? if declared_externally?(pin) ignored_pins.push pin else result.push Problem.new(pin.location, "Variable type could not be inferred for #{pin.name}", pin: pin) end else unless (rules.rank > 1 ? types_match?(api_map, declared, inferred) : any_types_match?(api_map, declared, inferred)) result.push Problem.new(pin.location, "Declared type #{declared} does not match inferred type #{inferred} for variable #{pin.name}", pin: pin) end end elsif declared_externally?(pin) ignored_pins.push pin end elsif !pin.is_a?(Pin::Parameter) result.push Problem.new(pin.location, "Unresolved type #{pin.return_type} for variable #{pin.name}", pin: pin) end else inferred = pin.probe(api_map) if inferred.undefined? && declared_externally?(pin) ignored_pins.push pin end end end result end
# File lib/solargraph/type_checker.rb, line 124 def virtual_pin? pin pin.location && source_map.source.comment_at?(pin.location.range.ending) end