lib/solargraph/language_server/host.rb



# frozen_string_literal: true


require 'diff/lcs'
require 'observer'
require 'securerandom'

module Solargraph
  module LanguageServer
    # The language server protocol's data provider. Hosts are responsible for

    # querying the library and processing messages. They also provide thread

    # safety for multi-threaded transports.

    #

    class Host
      autoload :Diagnoser,     'solargraph/language_server/host/diagnoser'
      autoload :Sources,       'solargraph/language_server/host/sources'
      autoload :Dispatch,      'solargraph/language_server/host/dispatch'
      autoload :MessageWorker, 'solargraph/language_server/host/message_worker'

      include UriHelpers
      include Logging
      include Dispatch
      include Observable

      attr_writer :client_capabilities

      def initialize
        @buffer_semaphore = Mutex.new
        @request_mutex = Mutex.new
        @buffer = String.new
        @stopped = true
        @next_request_id = 1
        @dynamic_capabilities = Set.new
        @registered_capabilities = Set.new
      end

      # Start asynchronous process handling.

      #

      # @return [void]

      def start
        return unless stopped?
        @stopped = false
        diagnoser.start
        message_worker.start
      end

      # Update the configuration options with the provided hash.

      #

      # @param update [Hash]

      # @return [void]

      def configure update
        return if update.nil?
        options.merge! update
        logger.level = LOG_LEVELS[options['logLevel']] || DEFAULT_LOG_LEVEL
      end

      # @return [Hash{String => [Boolean, String]}]

      def options
        @options ||= default_configuration
      end

      # Cancel the method with the specified ID.

      #

      # @param id [Integer]

      # @return [void]

      def cancel id
        cancelled.push id
      end

      # True if the host received a request to cancel the method with the

      # specified ID.

      #

      # @param id [Integer]

      # @return [Boolean]

      def cancel? id
        cancelled.include? id
      end

      # Delete the specified ID from the list of cancelled IDs if it exists.

      #

      # @param id [Integer]

      # @return [void]

      def clear id
        cancelled.delete id
      end

      # Called by adapter, to handle the request

      # @param request [Hash]

      # @return [void]

      def process request
        message_worker.queue(request)
      end

      # Start processing a request from the client. After the message is

      # processed, caller is responsible for sending the response.

      #

      # @param request [Hash{String => unspecified}] The contents of the message.

      # @return [Solargraph::LanguageServer::Message::Base, nil] The message handler.

      def receive request
        if request['method']
          logger.info "Host received ##{request['id']} #{request['method']}"
          logger.debug request
          message = Message.select(request['method']).new(self, request)
          begin
            message.process unless cancel?(request['id'])
          rescue StandardError => e
            logger.warn "Error processing request: [#{e.class}] #{e.message}"
            logger.warn e.backtrace.join("\n")
            message.set_error Solargraph::LanguageServer::ErrorCodes::INTERNAL_ERROR, "[#{e.class}] #{e.message}"
          end
          message
        elsif request['id']
          if requests[request['id']]
            requests[request['id']].process(request['result'])
            requests.delete request['id']
          else
            logger.warn "Discarding client response to unrecognized message #{request['id']}"
            nil
          end
        else
          logger.warn "Invalid message received."
          logger.debug request
          nil
        end
      end

      # Respond to a notification that files were created in the workspace.

      # The libraries will determine whether the files should be merged; see

      # Solargraph::Library#create_from_disk.

      #

      # @param uris [Array<String>] The URIs of the files.

      # @return [Boolean] True if at least one library accepted at least one file.

      def create *uris
        filenames = uris.map { |uri| uri_to_file(uri) }
        result = false
        libraries.each do |lib|
          result = true if lib.create_from_disk(*filenames)
        end
        uris.each do |uri|
          diagnoser.schedule uri if open?(uri)
        end
        result
      end

      # Delete the specified files from the library.

      #

      # @param uris [Array<String>] The file uris.

      # @return [void]

      def delete *uris
        filenames = uris.map { |uri| uri_to_file(uri) }
        libraries.each do |lib|
          lib.delete_observer self
          lib.delete(*filenames)
        end
        uris.each do |uri|
          send_notification "textDocument/publishDiagnostics", {
            uri: uri,
            diagnostics: []
          }
        end
      end

      # Open the specified file in the library.

      #

      # @param uri [String] The file uri.

      # @param text [String] The contents of the file.

      # @param version [Integer] A version number.

      # @return [void]

      def open uri, text, version
        src = sources.open(uri, text, version)
        libraries.each do |lib|
          lib.merge src
        end
        diagnoser.schedule uri
      end

      # @param uri [String]

      # @return [void]

      def open_from_disk uri
        sources.open_from_disk(uri)
        diagnoser.schedule uri
      end

      # True if the specified file is currently open in the library.

      #

      # @param uri [String]

      # @return [Boolean]

      def open? uri
        sources.include? uri
      end

      # Close the file specified by the URI.

      #

      # @param uri [String]

      # @return [void]

      def close uri
        logger.info "Closing #{uri}"
        sources.close uri
        diagnoser.schedule uri
      end

      # @param uri [String]

      # @return [void]

      def diagnose uri
        if sources.include?(uri)
          library = library_for(uri)
          if library.mapped? && library.synchronized?
            logger.info "Diagnosing #{uri}"
            begin
              results = library.diagnose uri_to_file(uri)
              send_notification "textDocument/publishDiagnostics", {
                uri: uri,
                diagnostics: results
              }
            rescue DiagnosticsError => e
              logger.warn "Error in diagnostics: #{e.message}"
              options['diagnostics'] = false
              send_notification 'window/showMessage', {
                type: LanguageServer::MessageTypes::ERROR,
                message: "Error in diagnostics: #{e.message}"
              }
            rescue FileNotFoundError => e
              # @todo This appears to happen when an external file is open and

              #   scheduled for diagnosis, but the file was closed (i.e., the

              #   editor moved to a different file) before diagnosis started

              logger.warn "Unable to diagnose #{uri} : #{e.message}"
              send_notification 'textDocument/publishDiagnostics', {
                uri: uri,
                diagnostics: []
              }
            end
          else
            logger.info "Deferring diagnosis of #{uri}"
            diagnoser.schedule uri
          end
        else
          send_notification 'textDocument/publishDiagnostics', {
            uri: uri,
            diagnostics: []
          }
        end
      end

      # Update a document from the parameters of a textDocument/didChange

      # method.

      #

      # @param params [Hash]

      # @return [void]

      def change params
        updater = generate_updater(params)
        sources.update params['textDocument']['uri'], updater
        diagnoser.schedule params['textDocument']['uri']
      end

      # Queue a message to be sent to the client.

      #

      # @param message [String] The message to send.

      # @return [void]

      def queue message
        @buffer_semaphore.synchronize { @buffer += message }
        changed
        notify_observers
      end

      # Clear the message buffer and return the most recent data.

      #

      # @return [String] The most recent data or an empty string.

      def flush
        tmp = ''
        @buffer_semaphore.synchronize do
          tmp = @buffer.clone
          @buffer.clear
        end
        tmp
      end

      # Prepare a library for the specified directory.

      #

      # @param directory [String]

      # @param name [String, nil]

      # @return [void]

      def prepare directory, name = nil
        # No need to create a library without a directory. The generic library

        # will handle it.

        return if directory.nil?
        logger.info "Preparing library for #{directory}"
        path = ''
        path = normalize_separators(directory) unless directory.nil?
        begin
          workspace = Solargraph::Workspace.new(path, nil, options)
          lib = Solargraph::Library.new(workspace, name)
          lib.add_observer self
          libraries.push lib
          library_map lib
        rescue WorkspaceTooLargeError => e
          send_notification 'window/showMessage', {
            'type' => Solargraph::LanguageServer::MessageTypes::WARNING,
            'message' => e.message
          }
        end
      end

      # Prepare multiple folders.

      #

      # @param array [Array<Hash{String => String}>]

      # @return [void]

      def prepare_folders array
        return if array.nil?
        array.each do |folder|
          prepare uri_to_file(folder['uri']), folder['name']
        end
      end

      # Remove a directory.

      #

      # @param directory [String]

      # @return [void]

      def remove directory
        logger.info "Removing library for #{directory}"
        # @param lib [Library]

        libraries.delete_if do |lib|
          next false if lib.workspace.directory != directory
          lib.delete_observer self
          true
        end
      end

      # @param array [Array<Hash>]

      # @return [void]

      def remove_folders array
        array.each do |folder|
          remove uri_to_file(folder['uri'])
        end
      end

      # @return [Array<String>]

      def folders
        libraries.map { |lib| lib.workspace.directory }
      end

      # Send a notification to the client.

      #

      # @param method [String] The message method

      # @param params [Hash] The method parameters

      # @return [void]

      def send_notification method, params
        response = {
          jsonrpc: "2.0",
          method: method,
          params: params
        }
        json = response.to_json
        envelope = "Content-Length: #{json.bytesize}\r\n\r\n#{json}"
        queue envelope
        logger.info "Server sent #{method}"
        logger.debug params
      end

      # Send a request to the client and execute the provided block to process

      # the response. If an ID is not provided, the host will use an auto-

      # incrementing integer.

      #

      # @param method [String] The message method

      # @param params [Hash] The method parameters

      # @param block [Proc] The block that processes the response

      # @yieldparam [Hash] The result sent by the client

      # @return [void]

      def send_request method, params, &block
        @request_mutex.synchronize do
          message = {
            jsonrpc: "2.0",
            method: method,
            params: params,
            id: @next_request_id
          }
          json = message.to_json
          requests[@next_request_id] = Request.new(@next_request_id, &block)
          envelope = "Content-Length: #{json.bytesize}\r\n\r\n#{json}"
          queue envelope
          @next_request_id += 1
          logger.debug params
        end
      end

      # Register the methods as capabilities with the client.

      # This method will avoid duplicating registrations and ignore methods

      # that were not flagged for dynamic registration by the client.

      #

      # @param methods [Array<String>] The methods to register

      # @return [void]

      def register_capabilities methods
        logger.debug "Registering capabilities: #{methods}"
        registrations = methods.select { |m| can_register?(m) and !registered?(m) }.map do |m|
          @registered_capabilities.add m
          {
            id: m,
            method: m,
            registerOptions: dynamic_capability_options[m]
          }
        end
        return if registrations.empty?
        send_request 'client/registerCapability', { registrations: registrations }
      end

      # Unregister the methods with the client.

      # This method will avoid duplicating unregistrations and ignore methods

      # that were not flagged for dynamic registration by the client.

      #

      # @param methods [Array<String>] The methods to unregister

      # @return [void]

      def unregister_capabilities methods
        logger.debug "Unregistering capabilities: #{methods}"
        unregisterations = methods.select{|m| registered?(m)}.map{ |m|
          @registered_capabilities.delete m
          {
            id: m,
            method: m
          }
        }
        return if unregisterations.empty?
        send_request 'client/unregisterCapability', { unregisterations: unregisterations }
      end

      # Flag a method as available for dynamic registration.

      #

      # @param method [String] The method name, e.g., 'textDocument/completion'

      # @return [void]

      def allow_registration method
        @dynamic_capabilities.add method
      end

      # True if the specified LSP method can be dynamically registered.

      #

      # @param method [String]

      # @return [Boolean]

      def can_register? method
        @dynamic_capabilities.include?(method)
      end

      # True if the specified method has been registered.

      #

      # @param method [String] The method name, e.g., 'textDocument/completion'

      # @return [Boolean]

      def registered? method
        @registered_capabilities.include?(method)
      end

      def synchronizing?
        !libraries.all?(&:synchronized?)
      end

      # @return [void]

      def stop
        return if @stopped
        @stopped = true
        message_worker.stop
        diagnoser.stop
        changed
        notify_observers
      end

      def stopped?
        @stopped
      end

      # Locate multiple pins that match a completion item. The first match is

      # based on the corresponding location in a library source if available.

      # Subsequent matches are based on path.

      #

      # @param params [Hash] A hash representation of a completion item

      # @return [Array<Pin::Base>]

      def locate_pins params
        return [] unless params['data'] && params['data']['uri']
        library = library_for(params['data']['uri'])
        result = []
        if params['data']['location']
          location = Location.new(
            params['data']['location']['filename'],
            Range.from_to(
              params['data']['location']['range']['start']['line'],
              params['data']['location']['range']['start']['character'],
              params['data']['location']['range']['end']['line'],
              params['data']['location']['range']['end']['character']
            )
          )
          result.concat library.locate_pins(location).select{ |pin| pin.name == params['label'] }
        end
        if params['data']['path']
          result.concat library.path_pins(params['data']['path'])
          # @todo This exception is necessary because `Library#path_pins` does

          #   not perform a namespace method query, so the implicit `.new` pin

          #   might not exist.

          if result.empty? && params['data']['path'] =~ /\.new$/
            result.concat(library.path_pins(params['data']['path'].sub(/\.new$/, '#initialize')).map do |pin|
              next pin unless pin.name == 'initialize'

              Pin::Method.new(
                name: 'new',
                scope: :class,
                location: pin.location,
                parameters: pin.parameters,
                return_type: ComplexType.try_parse(params['data']['path']),
                comments: pin.comments,
                closure: pin.closure
              )
            end)
          end
        end
        # Selecting by both location and path can result in duplicate pins

        result.uniq { |p| [p.path, p.location] }
      end

      # @param uri [String]

      # @return [String]

      def read_text uri
        library = library_for(uri)
        filename = uri_to_file(uri)
        library.read_text(filename)
      end

      # @param uri [String]

      # @return [Hash]

      def formatter_config uri
        library = library_for(uri)
        library.workspace.config.formatter
      end

      # @param uri [String]

      # @param line [Integer]

      # @param column [Integer]

      # @return [Solargraph::SourceMap::Completion]

      def completions_at uri, line, column
        library = library_for(uri)
        library.completions_at uri_to_file(uri), line, column
      end

      # @return [Bool] if has pending completion request

      def has_pending_completions?
        message_worker.messages.reverse_each.any? { |req| req['method'] == 'textDocument/completion' }
      end

      # @param uri [String]

      # @param line [Integer]

      # @param column [Integer]

      # @return [Array<Solargraph::Pin::Base>]

      def definitions_at uri, line, column
        library = library_for(uri)
        library.definitions_at(uri_to_file(uri), line, column)
      end

      # @param uri [String]

      # @param line [Integer]

      # @param column [Integer]

      # @return [Array<Solargraph::Pin::Base>]

      def type_definitions_at uri, line, column
        library = library_for(uri)
        library.type_definitions_at(uri_to_file(uri), line, column)
      end

      # @param uri [String]

      # @param line [Integer]

      # @param column [Integer]

      # @return [Array<Solargraph::Pin::Base>]

      def signatures_at uri, line, column
        library = library_for(uri)
        library.signatures_at(uri_to_file(uri), line, column)
      end

      # @param uri [String]

      # @param line [Integer]

      # @param column [Integer]

      # @param strip [Boolean] Strip special characters from variable names

      # @param only [Boolean] If true, search current file only

      # @return [Array<Solargraph::Range>]

      def references_from uri, line, column, strip: true, only: false
        library = library_for(uri)
        library.references_from(uri_to_file(uri), line, column, strip: strip, only: only)
      end

      # @param query [String]

      # @return [Array<Solargraph::Pin::Base>]

      def query_symbols query
        result = []
        (libraries + [generic_library]).each { |lib| result.concat lib.query_symbols(query) }
        result.uniq
      end

      # @param query [String]

      # @return [Array<String>]

      def search query
        result = []
        libraries.each { |lib| result.concat lib.search(query) }
        result
      end

      # @param query [String]

      # @return [Array]

      def document query
        result = []
        libraries.each { |lib| result.concat lib.document(query) }
        result
      end

      # @param uri [String]

      # @return [Array<Solargraph::Pin::Base>]

      def document_symbols uri
        library = library_for(uri)
        # At this level, document symbols should be unique; e.g., a

        # module_function method should return the location for Module.method

        # or Module#method, but not both.

        library.document_symbols(uri_to_file(uri)).uniq(&:location)
      end

      # Send a notification to the client.

      #

      # @param text [String]

      # @param type [Integer] A MessageType constant

      # @return [void]

      def show_message text, type = LanguageServer::MessageTypes::INFO
        send_notification 'window/showMessage', {
          type: type,
          message: text
        }
      end

      # Send a notification with optional responses.

      #

      # @param text [String]

      # @param type [Integer] A MessageType constant

      # @param actions [Array<String>] Response options for the client

      # @param block The block that processes the response

      # @yieldparam [String] The action received from the client

      # @return [void]

      def show_message_request text, type, actions, &block
        send_request 'window/showMessageRequest', {
          type: type,
          message: text,
          actions: actions
        }, &block
      end

      # Get a list of IDs for server requests that are waiting for responses

      # from the client.

      #

      # @return [Array<Integer>]

      def pending_requests
        requests.keys
      end

      # @return [Hash{String => [Boolean,String]}]

      def default_configuration
        {
          'completion' => true,
          'hover' => true,
          'symbols' => true,
          'definitions' => true,
          'typeDefinitions' => true,
          'rename' => true,
          'references' => true,
          'autoformat' => false,
          'diagnostics' => true,
          'formatting' => false,
          'folding' => true,
          'highlights' => true,
          'logLevel' => 'warn'
        }
      end

      # @param uri [String]

      # @return [Array<Range>]

      def folding_ranges uri
        sources.find(uri).folding_ranges
      end

      # @return [void]

      def catalog
        return unless libraries.all?(&:mapped?)
        libraries.each(&:catalog)
      end

      # @return [Hash{String => Hash{String => Boolean}}]

      def client_capabilities
        @client_capabilities ||= {}
      end

      def client_supports_progress?
        client_capabilities['window'] && client_capabilities['window']['workDoneProgress']
      end

      private

      # @return [Array<Integer>]

      def cancelled
        @cancelled ||= []
      end

      # @return [MessageWorker]

      def message_worker
        @message_worker ||= MessageWorker.new(self)
      end

      # @return [Diagnoser]

      def diagnoser
        @diagnoser ||= Diagnoser.new(self)
      end

      # A hash of client requests by ID. The host uses this to keep track of

      # pending responses.

      #

      # @return [Hash{Integer => Solargraph::LanguageServer::Host}]

      def requests
        @requests ||= {}
      end

      # @param path [String]

      # @return [String]

      def normalize_separators path
        return path if File::ALT_SEPARATOR.nil?
        path.gsub(File::ALT_SEPARATOR, File::SEPARATOR)
      end

      # @param params [Hash]

      # @return [Source::Updater]

      def generate_updater params
        changes = []
        params['contentChanges'].each do |recvd|
          chng = check_diff(params['textDocument']['uri'], recvd)
          changes.push Solargraph::Source::Change.new(
            (chng['range'].nil? ?
              nil :
              Solargraph::Range.from_to(chng['range']['start']['line'], chng['range']['start']['character'], chng['range']['end']['line'], chng['range']['end']['character'])
            ),
            chng['text']
          )
        end
        Solargraph::Source::Updater.new(
          uri_to_file(params['textDocument']['uri']),
          params['textDocument']['version'],
          changes
        )
      end

      # @param uri [String]

      # @param change [Hash]

      # @return [Hash]

      def check_diff uri, change
        return change if change['range']
        source = sources.find(uri)
        return change if source.code.length + 1 != change['text'].length
        diffs = Diff::LCS.diff(source.code, change['text'])
        return change if diffs.length.zero? || diffs.length > 1 || diffs.first.length > 1
        # @type [Diff::LCS::Change]

        diff = diffs.first.first
        return change unless diff.adding? && ['.', ':', '(', ',', ' '].include?(diff.element)
        position = Solargraph::Position.from_offset(source.code, diff.position)
        {
          'range' => {
            'start' => {
              'line' => position.line,
              'character' => position.character
            },
            'end' => {
              'line' => position.line,
              'character' => position.character
            }
          },
          'text' => diff.element
        }
      rescue Solargraph::FileNotFoundError
        change
      end

      # @return [Hash]

      def dynamic_capability_options
        @dynamic_capability_options ||= {
          # textDocumentSync: 2, # @todo What should this be?

          'textDocument/completion' => {
            resolveProvider: true,
            triggerCharacters: ['.', ':', '@']
          },
          # hoverProvider: true,

          # definitionProvider: true,

          'textDocument/signatureHelp' => {
            triggerCharacters: ['(', ',', ' ']
          },
          # documentFormattingProvider: true,

          'textDocument/onTypeFormatting' => {
            firstTriggerCharacter: '{',
            moreTriggerCharacter: ['(']
          },
          # documentSymbolProvider: true,

          # workspaceSymbolProvider: true,

          # workspace: {

            # workspaceFolders: {

              # supported: true,

              # changeNotifications: true

            # }

          # }

          'textDocument/definition' => {
            definitionProvider: true
          },
          'textDocument/typeDefinition' => {
            typeDefinitionProvider: true
          },
          'textDocument/references' => {
            referencesProvider: true
          },
          'textDocument/rename' => {
            renameProvider: prepare_rename? ? { prepareProvider: true } : true
          },
          'textDocument/documentSymbol' => {
            documentSymbolProvider: true
          },
          'workspace/symbol' => {
            workspaceSymbolProvider: true
          },
          'textDocument/formatting' => {
            formattingProvider: true
          },
          'textDocument/foldingRange' => {
            foldingRangeProvider: true
          },
          'textDocument/codeAction' => {
            codeActionProvider: true
          },
          'textDocument/documentHighlight' => {
            documentHighlightProvider: true
          }
        }
      end

      def prepare_rename?
        client_capabilities['rename'] && client_capabilities['rename']['prepareSupport']
      end

      # @param library [Library]

      # @return [void]

      def library_map library
        return if library.mapped?
        Thread.new { sync_library_map library }
      end

      # @param library [Library]

      # @param uuid [String, nil]

      # @return [void]

      def sync_library_map library
        total = library.workspace.sources.length
        progress = Progress.new('Mapping workspace')
        progress.begin "0/#{total} files", 0
        progress.send self
        while library.next_map
          pct = ((library.source_map_hash.keys.length.to_f / total) * 100).to_i
          progress.report "#{library.source_map_hash.keys.length}/#{total} files", pct
          progress.send self
        end
        progress.finish 'done'
        progress.send self
      end
    end
  end
end