lib/steep/server/interaction_worker.rb



module Steep
  module Server
    class InteractionWorker < BaseWorker
      include ChangeBuffer

      ApplyChangeJob = _ = Class.new()
      HoverJob = _ = Struct.new(:id, :path, :line, :column, keyword_init: true)
      CompletionJob = _ = Struct.new(:id, :path, :line, :column, :trigger, keyword_init: true)
      SignatureHelpJob = _ = Struct.new(:id, :path, :line, :column, keyword_init: true)

      LSP = LanguageServer::Protocol

      attr_reader :service

      def initialize(project:, reader:, writer:, queue: Queue.new)
        super(project: project, reader: reader, writer: writer)
        @queue = queue
        @service = Services::TypeCheckService.new(project: project)
        @mutex = Mutex.new
        @buffered_changes = {}
      end

      def handle_job(job)
        Steep.logger.tagged "#handle_job" do
          changes = pop_buffer()

          unless changes.empty?
            Steep.logger.debug { "Applying changes for #{changes.size} files..." }
            service.update(changes: changes)
          end

          case job
          when ApplyChangeJob
            # nop
          when HoverJob
            writer.write({ id: job.id, result: process_hover(job) })
          when CompletionJob
            writer.write({ id: job.id, result: process_completion(job) })
          when SignatureHelpJob
            writer.write({ id: job.id, result: process_signature_help(job) })
          end
        end
      end

      def handle_request(request)
        case request[:method]
        when "initialize"
          load_files(project: project, commandline_args: [])
          queue << ApplyChangeJob.new
          writer.write({ id: request[:id], result: nil })

        when "textDocument/didChange"
          collect_changes(request)
          queue << ApplyChangeJob.new

        when "textDocument/hover"
          id = request[:id]

          path = project.relative_path(PathHelper.to_pathname!(request[:params][:textDocument][:uri]))
          line = request[:params][:position][:line]+1
          column = request[:params][:position][:character]

          queue << HoverJob.new(id: id, path: path, line: line, column: column)

        when "textDocument/completion"
          id = request[:id]

          params = request[:params]

          path = project.relative_path(PathHelper.to_pathname!(params[:textDocument][:uri]))
          line, column = params[:position].yield_self {|hash| [hash[:line]+1, hash[:character]] }
          trigger = params.dig(:context, :triggerCharacter)

          queue << CompletionJob.new(id: id, path: path, line: line, column: column, trigger: trigger)
        when "textDocument/signatureHelp"
          id = request[:id]
          params = request[:params]
          path = project.relative_path(PathHelper.to_pathname!(params[:textDocument][:uri]))
          line, column = params[:position].yield_self {|hash| [hash[:line]+1, hash[:character]] }

          queue << SignatureHelpJob.new(id: id, path: path, line: line, column: column)
        end
      end

      def process_hover(job)
        Steep.logger.tagged "#process_hover" do
          Steep.measure "Generating hover response" do
            Steep.logger.info { "path=#{job.path}, line=#{job.line}, column=#{job.column}" }

            content = Services::HoverProvider.content_for(service: service, path: job.path, line: job.line, column: job.column)
            if content
              range = content.location.yield_self do |location|
                lsp_range = location.as_lsp_range
                start_position = LSP::Interface::Position.new(line: lsp_range[:start][:line], character: lsp_range[:start][:character])
                end_position = LSP::Interface::Position.new(line: lsp_range[:end][:line], character: lsp_range[:end][:character])
                LSP::Interface::Range.new(start: start_position, end: end_position)
              end

              LSP::Interface::Hover.new(
                contents:  LSP::Interface::MarkupContent.new(kind: "markdown", value: LSPFormatter.format_hover_content(content).to_s),
                range: range
              )
            end
          rescue Typing::UnknownNodeError => exn
            Steep.log_error exn, message: "Failed to compute hover: #{exn.inspect}"
            nil
          end
        end
      end

      def process_completion(job)
        Steep.logger.tagged("#response_to_completion") do
          Steep.measure "Generating response" do
            Steep.logger.info "path: #{job.path}, line: #{job.line}, column: #{job.column}, trigger: #{job.trigger}"

            case
            when target = project.target_for_source_path(job.path)
              file = service.source_files[job.path] or return
              subtyping = service.signature_services[target.name].current_subtyping or return

              provider = Services::CompletionProvider.new(source_text: file.content, path: job.path, subtyping: subtyping)
              items = begin
                        provider.run(line: job.line, column: job.column)
                      rescue Parser::SyntaxError
                        []
                      end

              completion_items = items.map do |item|
                format_completion_item(item)
              end

              Steep.logger.debug "items = #{completion_items.inspect}"

              LSP::Interface::CompletionList.new(
                is_incomplete: false,
                items: completion_items
              )
            when (targets = project.targets_for_path(job.path)).is_a?(Array)
              target = targets[0] or raise
              sig_service = service.signature_services[target.name] or raise
              relative_path = job.path

              context = nil #: RBS::Resolver::context

              case sig_service.status
              when Services::SignatureService::SyntaxErrorStatus, Services::SignatureService::AncestorErrorStatus

                if buffer = sig_service.latest_env.buffers.find {|buf| Pathname(buf.name) == Pathname(relative_path) }
                  dirs = sig_service.latest_env.signatures[buffer][0]
                else
                  dirs = [] #: Array[RBS::AST::Directives::t]
                end
              else
                signature = sig_service.files[relative_path].signature
                signature.is_a?(Array) or raise
                buffer, dirs, decls = signature

                locator = RBS::Locator.new(buffer: buffer, dirs: dirs, decls: decls)

                _hd, tail = locator.find2(line: job.line, column: job.column)
                tail ||= []

                tail.reverse_each do |t|
                  case t
                  when RBS::AST::Declarations::Module, RBS::AST::Declarations::Class
                    if (last_type_name = context&.[](1)).is_a?(RBS::TypeName)
                      context = [context, last_type_name + t.name]
                    else
                      context = [context, t.name.absolute!]
                    end
                  end
                end
              end

              buffer = RBS::Buffer.new(name: relative_path, content: sig_service.files[relative_path].content)
              prefix = Services::TypeNameCompletion::Prefix.parse(buffer, line: job.line, column: job.column)

              completion = Services::TypeNameCompletion.new(env: sig_service.latest_env, context: context, dirs: dirs)
              type_names = completion.find_type_names(prefix)
              prefix_size = prefix ? prefix.size : 0

              completion_items = type_names.map do |type_name|
                absolute_name, relative_name = completion.resolve_name_in_context(type_name)
                format_completion_item_for_rbs(sig_service, absolute_name, job, relative_name.to_s, prefix_size)
              end

              ["untyped", "void", "bool", "class", "module", "instance", "nil"].each do |name|
                completion_items << LSP::Interface::CompletionItem.new(
                  label: name,
                  detail: "(builtin type)",
                  text_edit: LSP::Interface::TextEdit.new(
                    range: LSP::Interface::Range.new(
                      start: LSP::Interface::Position.new(
                        line: job.line - 1,
                        character: job.column - prefix_size
                      ),
                      end: LSP::Interface::Position.new(
                        line: job.line - 1,
                        character: job.column
                      )
                    ),
                    new_text: name
                  ),
                  kind: LSP::Constant::CompletionItemKind::KEYWORD,
                  filter_text: name,
                  sort_text: "zz__#{name}"
                )
              end

              LSP::Interface::CompletionList.new(
                is_incomplete: false,
                items: completion_items
              )
            end
          end
        end
      end

      def format_completion_item_for_rbs(sig_service, type_name, job, complete_text, prefix_size)
        range = LSP::Interface::Range.new(
          start: LSP::Interface::Position.new(
            line: job.line - 1,
            character: job.column - prefix_size
          ),
          end: LSP::Interface::Position.new(
            line: job.line - 1,
            character: job.column
          )
        )

        case type_name.kind
        when :class
          env = sig_service.latest_env #: RBS::Environment
          class_entry = env.module_class_entry(type_name) or raise

          case class_entry
          when RBS::Environment::ClassEntry, RBS::Environment::ModuleEntry
            comments = class_entry.decls.map {|decl| decl.decl.comment }.compact
            decl = class_entry.primary.decl
          when RBS::Environment::ClassAliasEntry, RBS::Environment::ModuleAliasEntry
            comments = [class_entry.decl.comment].compact
            decl = class_entry.decl
          end

          LSP::Interface::CompletionItem.new(
            label: complete_text,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: LSPFormatter.declaration_summary(decl)),
            documentation: LSPFormatter.markup_content { LSPFormatter.format_rbs_completion_docs(type_name, decl, comments) },
            text_edit: LSP::Interface::TextEdit.new(
              range: range,
              new_text: complete_text
            ),
            kind: LSP::Constant::CompletionItemKind::CLASS
          )
        when :alias
          alias_decl = sig_service.latest_env.type_alias_decls[type_name]&.decl or raise

          LSP::Interface::CompletionItem.new(
            label: complete_text,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: LSPFormatter.declaration_summary(alias_decl)),
            text_edit: LSP::Interface::TextEdit.new(
              range: range,
              new_text: complete_text
            ),
            documentation: LSPFormatter.markup_content { LSPFormatter.format_rbs_completion_docs(type_name, alias_decl, [alias_decl.comment].compact) },
            kind: LSP::Constant::CompletionItemKind::FIELD
          )
        when :interface
          interface_decl = sig_service.latest_env.interface_decls[type_name]&.decl or raise

          LSP::Interface::CompletionItem.new(
            label: complete_text,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: LSPFormatter.declaration_summary(interface_decl)),
            text_edit: LSP::Interface::TextEdit.new(
              range: range,
              new_text: complete_text
            ),
            documentation: LSPFormatter.markup_content { LSPFormatter.format_rbs_completion_docs(type_name, interface_decl, [interface_decl.comment].compact) },
            kind: LSP::Constant::CompletionItemKind::INTERFACE
          )
        else
          raise
        end
      end

      def format_completion_item(item)
        range = LSP::Interface::Range.new(
          start: LSP::Interface::Position.new(
            line: item.range.start.line-1,
            character: item.range.start.column
          ),
          end: LSP::Interface::Position.new(
            line: item.range.end.line-1,
            character: item.range.end.column
          )
        )

        case item
        when Services::CompletionProvider::LocalVariableItem
          LSP::Interface::CompletionItem.new(
            label: item.identifier.to_s,
            kind: LSP::Constant::CompletionItemKind::VARIABLE,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: item.type.to_s),
            documentation: LSPFormatter.markup_content { LSPFormatter.format_completion_docs(item) },
            insert_text: item.identifier.to_s,
            sort_text: item.identifier.to_s
          )
        when Services::CompletionProvider::ConstantItem
          case
          when item.class? || item.module?
            kind = LSP::Constant::CompletionItemKind::CLASS
          else
            kind = LSP::Constant::CompletionItemKind::CONSTANT
          end

          detail = LSPFormatter.declaration_summary(item.decl)

          LSP::Interface::CompletionItem.new(
            label: item.identifier.to_s,
            kind: kind,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: detail),
            documentation: LSPFormatter.markup_content { LSPFormatter.format_completion_docs(item) },
            text_edit: LSP::Interface::TextEdit.new(
              range: range,
              new_text: item.identifier.to_s
            )
          )
        when Services::CompletionProvider::SimpleMethodNameItem
          LSP::Interface::CompletionItem.new(
            label: item.identifier.to_s,
            kind: LSP::Constant::CompletionItemKind::FUNCTION,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: item.method_name.relative.to_s),
            insert_text: item.identifier.to_s,
            documentation: LSPFormatter.markup_content { LSPFormatter.format_completion_docs(item) }
          )
        when Services::CompletionProvider::ComplexMethodNameItem
          method_names = item.method_names.map(&:relative).uniq

          LSP::Interface::CompletionItem.new(
            label: item.identifier.to_s,
            kind: LSP::Constant::CompletionItemKind::FUNCTION,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: method_names.join(", ")),
            insert_text: item.identifier.to_s,
            documentation: LSPFormatter.markup_content { LSPFormatter.format_completion_docs(item) }
          )
        when Services::CompletionProvider::GeneratedMethodNameItem
          LSP::Interface::CompletionItem.new(
            label: item.identifier.to_s,
            kind: LSP::Constant::CompletionItemKind::FUNCTION,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: "(Generated)"),
            insert_text: item.identifier.to_s,
            documentation: LSPFormatter.markup_content { LSPFormatter.format_completion_docs(item) }
          )
        when Services::CompletionProvider::InstanceVariableItem
          LSP::Interface::CompletionItem.new(
            label: item.identifier.to_s,
            kind: LSP::Constant::CompletionItemKind::FIELD,
            label_details: LSP::Interface::CompletionItemLabelDetails.new(description: item.type.to_s),
            documentation: LSPFormatter.markup_content { LSPFormatter.format_completion_docs(item) },
            text_edit: LSP::Interface::TextEdit.new(
              range: range,
              new_text: item.identifier.to_s
            )
          )
        end
      end

      def process_signature_help(job)
        Steep.logger.tagged("##{__method__}") do
          if target = project.target_for_source_path(job.path)
            file = service.source_files[job.path] or return
            subtyping = service.signature_services[target.name].current_subtyping or return
            source = Source.parse(file.content, path: file.path, factory: subtyping.factory)

            provider = Services::SignatureHelpProvider.new(source: source, subtyping: subtyping)

            if (items, index = provider.run(line: job.line, column: job.column))
              signatures = items.map do |item|
                LSP::Interface::SignatureInformation.new(
                  label: "(#{item.method_type.type.param_to_s})",
                  documentation: item.comment&.yield_self do |comment|
                    LSP::Interface::MarkupContent.new(
                      kind: LSP::Constant::MarkupKind::MARKDOWN,
                      value: comment.string.gsub(/<!--(?~-->)-->/, "")
                    )
                  end
                )
              end

              LSP::Interface::SignatureHelp.new(
                signatures: signatures,
                active_signature: index
              )
            end
          end
        end
      end
    end
  end
end