lib/solargraph/shell.rb



# frozen_string_literal: true


require 'benchmark'
require 'thor'
require 'yard'

module Solargraph
  class Shell < Thor
    include Solargraph::ServerMethods

    # Tell Thor to ensure the process exits with status 1 if any error happens.

    def self.exit_on_failure?
      true
    end

    map %w[--version -v] => :version

    desc "--version, -v", "Print the version"
    # @return [void]

    def version
      puts Solargraph::VERSION
    end

    desc 'socket', 'Run a Solargraph socket server'
    option :host, type: :string, aliases: :h, desc: 'The server host', default: '127.0.0.1'
    option :port, type: :numeric, aliases: :p, desc: 'The server port', default: 7658
    # @return [void]

    def socket
      require 'backport'
      port = options[:port]
      port = available_port if port.zero?
      Backport.run do
        Signal.trap("INT") do
          Backport.stop
        end
        Signal.trap("TERM") do
          Backport.stop
        end
        Backport.prepare_tcp_server host: options[:host], port: port, adapter: Solargraph::LanguageServer::Transport::Adapter
        STDERR.puts "Solargraph is listening PORT=#{port} PID=#{Process.pid}"
      end
    end

    desc 'stdio', 'Run a Solargraph stdio server'
    # @return [void]

    def stdio
      require 'backport'
      Backport.run do
        Signal.trap("INT") do
          Backport.stop
        end
        Signal.trap("TERM") do
          Backport.stop
        end
        Backport.prepare_stdio_server adapter: Solargraph::LanguageServer::Transport::Adapter
        STDERR.puts "Solargraph is listening on stdio PID=#{Process.pid}"
      end
    end

    desc 'config [DIRECTORY]', 'Create or overwrite a default configuration file'
    option :extensions, type: :boolean, aliases: :e, desc: 'Add installed extensions', default: true
    # @param directory [String]

    # @return [void]

    def config(directory = '.')
      matches = []
      if options[:extensions]
        Gem::Specification.each do |g|
          if g.name.match(/^solargraph\-[A-Za-z0-9_\-]*?\-ext/)
            require g.name
            matches.push g.name
          end
        end
      end
      conf = Solargraph::Workspace::Config.new.raw_data
      unless matches.empty?
        matches.each do |m|
          conf['extensions'].push m
        end
      end
      File.open(File.join(directory, '.solargraph.yml'), 'w') do |file|
        file.puts conf.to_yaml
      end
      STDOUT.puts "Configuration file initialized."
    end

    desc 'clear', 'Delete all cached documentation'
    long_desc %(
      This command will delete all core and gem documentation from the cache.
    )
    # @return [void]

    def clear
      puts "Deleting all cached documentation (gems, core and stdlib)"
      Solargraph::Cache.clear
    end
    map 'clear-cache' => :clear
    map 'clear-cores' => :clear

    desc 'cache', 'Cache a gem', hide: true
    # @return [void]

    # @param gem [String]

    # @param version [String, nil]

    def cache gem, version = nil
      spec = Gem::Specification.find_by_name(gem, version)
      pins = GemPins.build(spec)
      Cache.save('gems', "#{spec.name}-#{spec.version}.ser", pins)
    end

    desc 'uncache GEM [...GEM]', "Delete specific cached gem documentation"
    long_desc %(
      Specify one or more gem names to clear. 'core' or 'stdlib' may
      also be specified to clear cached system documentation.
      Documentation will be regenerated as needed.
    )
    # @param gems [Array<String>]

    # @return [void]

    def uncache *gems
      raise ArgumentError, 'No gems specified.' if gems.empty?
      gems.each do |gem|
        if gem == 'core'
          Cache.uncache("core.ser")
          next
        end

        if gem == 'stdlib'
          Cache.uncache("stdlib")
          next
        end

        spec = Gem::Specification.find_by_name(gem)
        Cache.uncache('gems', "#{spec.name}-#{spec.version}.ser")
        Cache.uncache('gems', "#{spec.name}-#{spec.version}.yardoc")
      end
    end

    desc 'gems [GEM[=VERSION]]', 'Cache documentation for installed gems'
    option :rebuild, type: :boolean, desc: 'Rebuild existing documentation', default: false
    # @param names [Array<String>]

    # @return [void]

    def gems *names
      if names.empty?
        Gem::Specification.to_a.each { |spec| do_cache spec }
      else
        names.each do |name|
          spec = Gem::Specification.find_by_name(*name.split('='))
          do_cache spec
        rescue Gem::MissingSpecError
          warn "Gem '#{name}' not found"
        end
      end
    end

    desc 'reporters', 'Get a list of diagnostics reporters'
    # @return [void]

    def reporters
      puts Solargraph::Diagnostics.reporters
    end

    desc 'typecheck [FILE(s)]', 'Run the type checker'
    long_desc %(
      Perform type checking on one or more files in a workspace. Check the
      entire workspace if no files are specified.

      Type checking levels are normal, typed, strict, and strong.
    )
    option :level, type: :string, aliases: [:mode, :m, :l], desc: 'Type checking level', default: 'normal'
    option :directory, type: :string, aliases: :d, desc: 'The workspace directory', default: '.'
    # @return [void]

    def typecheck *files
      directory = File.realpath(options[:directory])
      api_map = Solargraph::ApiMap.load_with_cache(directory, $stdout)
      probcount = 0
      if files.empty?
        files = api_map.source_maps.map(&:filename)
      else
        files.map! { |file| File.realpath(file) }
      end
      filecount = 0

      time = Benchmark.measure {
        files.each do |file|
          checker = TypeChecker.new(file, api_map: api_map, level: options[:level].to_sym)
          problems = checker.problems
          next if problems.empty?
          problems.sort! { |a, b| a.location.range.start.line <=> b.location.range.start.line }
          puts problems.map { |prob| "#{prob.location.filename}:#{prob.location.range.start.line + 1} - #{prob.message}" }.join("\n")
          filecount += 1
          probcount += problems.length
        end
        # "

      }
      puts "Typecheck finished in #{time.real} seconds."
      puts "#{probcount} problem#{probcount != 1 ? 's' : ''} found#{files.length != 1 ? " in #{filecount} of #{files.length} files" : ''}."
      # "

      exit 1 if probcount > 0
    end

    desc 'scan', 'Test the workspace for problems'
    long_desc %(
      A scan loads the entire workspace to make sure that the ASTs and
      maps do not raise errors during analysis. It does not perform any type
      checking or validation; it only confirms that the analysis itself is
      error-free.
    )
    option :directory, type: :string, aliases: :d, desc: 'The workspace directory', default: '.'
    option :verbose, type: :boolean, aliases: :v, desc: 'Verbose output', default: false
    # @return [void]

    def scan
      directory = File.realpath(options[:directory])
      api_map = nil
      time = Benchmark.measure {
        api_map = Solargraph::ApiMap.load_with_cache(directory, $stdout)
        api_map.pins.each do |pin|
          begin
            puts pin_description(pin) if options[:verbose]
            pin.typify api_map
            pin.probe api_map
          rescue StandardError => e
            STDERR.puts "Error testing #{pin_description(pin)} #{pin.location ? "at #{pin.location.filename}:#{pin.location.range.start.line + 1}" : ''}"
            STDERR.puts "[#{e.class}]: #{e.message}"
            STDERR.puts e.backtrace.join("\n")
            exit 1
          end
        end
      }
      puts "Scanned #{directory} (#{api_map.pins.length} pins) in #{time.real} seconds."
    end

    desc 'list', 'List the files in the workspace and the total count'
    option :count, type: :boolean, aliases: :c, desc: 'Display the file count only', default: false
    option :directory, type: :string, aliases: :d, desc: 'The directory to read', default: '.'
    # @return [void]

    def list
      workspace = Solargraph::Workspace.new(options[:directory])
      puts workspace.filenames unless options[:count]
      puts "#{workspace.filenames.length} files total."
    end

    private

    # @param pin [Solargraph::Pin::Base]

    # @return [String]

    def pin_description pin
      desc = if pin.path.nil? || pin.path.empty?
        if pin.closure
          "#{pin.closure.path} | #{pin.name}"
        else
          "#{pin.context.namespace} | #{pin.name}"
        end
      else
        pin.path
      end
      desc += " (#{pin.location.filename} #{pin.location.range.start.line})" if pin.location
      desc
    end

    # @param gemspec [Gem::Specification]

    # @return [void]

    def do_cache gemspec
      cached = Yardoc.cached?(gemspec)
      if cached && !options.rebuild
        puts "Cache already exists for #{gemspec.name} #{gemspec.version}"
      else
        puts "#{cached ? 'Rebuilding' : 'Caching'} gem documentation for #{gemspec.name} #{gemspec.version}"
        pins = GemPins.build(gemspec)
        Cache.save('gems', "#{gemspec.name}-#{gemspec.version}.ser", pins)
      end
    end
  end
end