class Solargraph::TypeChecker

A static analysis tool for validating data types.

Attributes

api_map[R]

@return [ApiMap]

filename[R]

@return [String]

rules[R]

@return [Rules]

Public Class Methods

load(filename, level = :normal) click to toggle source

@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
load_string(code, filename = nil, level = :normal) click to toggle source

@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
new(filename, api_map: nil, level: :normal) click to toggle source

@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

problems() click to toggle source

@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
source_map() click to toggle source

@return [SourceMap]

# File lib/solargraph/type_checker.rb, line 36
def source_map
  @source_map ||= api_map.source_map(filename)
end

Private Instance Methods

abstract?(pin) click to toggle source
# File lib/solargraph/type_checker.rb, line 478
def abstract? pin
  pin.docstring.has_tag?(:abstract) ||
    (pin.closure && pin.closure.docstring.has_tag?(:abstract))
end
all_variables() click to toggle source

@return [Array<Pin::BaseVariable>]

# File lib/solargraph/type_checker.rb, line 183
def all_variables
  source_map.pins_by_class(Pin::BaseVariable) + source_map.locals.select { |pin| pin.is_a?(Pin::LocalVariable) }
end
argument_problems_for(chain, api_map, block_pin, locals, location) click to toggle source
# File lib/solargraph/type_checker.rb, line 239
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
arity_problems_for(pin, arguments, location) click to toggle source

@param pin [Pin::Method]

# File lib/solargraph/type_checker.rb, line 403
def 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 Parser.is_ast_node?(unchecked.last.node) && splatted_call?(unchecked.last.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 [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 && splatted_call?(unchecked.last.node) && (pin.parameters.map(&:decl) & [:kwarg, :kwoptarg, :kwrestarg]).any?
      return []
    end
    return [Problem.new(location, "Too many arguments to #{pin.path}")]
  elsif unchecked.length < req - settled_kwargs && (arguments.empty? || !arguments.last.splat?)
    return [Problem.new(location, "Not enough arguments to #{pin.path}")]
  end
  []
end
call_problems() click to toggle source
# File lib/solargraph/type_checker.rb, line 205
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
const_problems() click to toggle source
# File lib/solargraph/type_checker.rb, line 187
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
declared_externally?(pin) click to toggle source
# File lib/solargraph/type_checker.rb, line 375
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
external?(pin) click to toggle source

@param pin [Pin::Base]

# File lib/solargraph/type_checker.rb, line 371
def external? pin
  !internal? pin
end
fake_args_for(pin) click to toggle source
# File lib/solargraph/type_checker.rb, line 483
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
first_param_hash(pins) click to toggle source

@param [Array<Pin::Method>] @return [Hash]

# File lib/solargraph/type_checker.rb, line 352
def first_param_hash(pins)
  pins.each do |pin|
    result = param_hash(pin)
    return result unless result.empty?
  end
  {}
end
ignored_pins() click to toggle source
# File lib/solargraph/type_checker.rb, line 141
def ignored_pins
  @ignored_pins ||= []
end
internal?(pin) click to toggle source

@param pin [Pin::Base]

# File lib/solargraph/type_checker.rb, line 361
def internal? pin
  return false if pin.nil?
  pin.location && api_map.bundled?(pin.location.filename)
end
internal_or_core?(pin) click to toggle source
# File lib/solargraph/type_checker.rb, line 366
def internal_or_core? pin
  internal?(pin) || api_map.yard_map.core_pins.include?(pin) || api_map.yard_map.stdlib_pins.include?(pin)
end
kwarg_problems_for(argchain, api_map, block_pin, locals, location, pin, params, first) click to toggle source
# File lib/solargraph/type_checker.rb, line 292
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
kwrestarg_problems_for(api_map, block_pin, locals, location, pin, params, kwargs) click to toggle source
# File lib/solargraph/type_checker.rb, line 321
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
method_param_type_problems_for(pin) click to toggle source

@param pin [Pin::Method] @return [Array<Problem>]

# File lib/solargraph/type_checker.rb, line 120
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
method_return_type_problems_for(pin) click to toggle source

@param pin [Pin::Method] @return [Array<Problem>]

# File lib/solargraph/type_checker.rb, line 86
def method_return_type_problems_for pin
  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?
      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
method_tag_problems() click to toggle source

@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
optional_param_count(pin) click to toggle source

@param pin [Pin::Method]

# File lib/solargraph/type_checker.rb, line 469
def optional_param_count(pin)
  count = 0
  pin.parameters.each do |param|
    next unless param.decl == :optarg
    count += 1
  end
  count
end
param_hash(pin) click to toggle source

@param [Pin::Method] @return [Hash]

# File lib/solargraph/type_checker.rb, line 336
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
required_param_count(pin) click to toggle source

@param pin [Pin::Method]

# File lib/solargraph/type_checker.rb, line 464
def required_param_count(pin)
  pin.parameters.sum { |param| %i[arg kwarg].include?(param.decl) ? 1 : 0 }
end
variable_type_tag_problems() click to toggle source

@return [Array<Problem>]

# File lib/solargraph/type_checker.rb, line 146
def variable_type_tag_problems
  result = []
  all_variables.each do |pin|
    if pin.return_type.defined?
      declared = pin.typify(api_map)
      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
virtual_pin?(pin) click to toggle source
# File lib/solargraph/type_checker.rb, line 114
def virtual_pin? pin
  pin.location && source_map.source.comment_at?(pin.location.range.ending)
end