lib/ruby_lsp/requests/semantic_highlighting.rb



# typed: strict
# frozen_string_literal: true

require "ruby_lsp/listeners/semantic_highlighting"

module RubyLsp
  module Requests
    # The [semantic
    # highlighting](https://microsoft.github.io/language-server-protocol/specification#textDocument_semanticTokens)
    # request informs the editor of the correct token types to provide consistent and accurate highlighting for themes.
    class SemanticHighlighting < Request
      class << self
        #: -> Interface::SemanticTokensRegistrationOptions
        def provider
          Interface::SemanticTokensRegistrationOptions.new(
            document_selector: nil,
            legend: Interface::SemanticTokensLegend.new(
              token_types: ResponseBuilders::SemanticHighlighting::TOKEN_TYPES.keys,
              token_modifiers: ResponseBuilders::SemanticHighlighting::TOKEN_MODIFIERS.keys,
            ),
            range: true,
            full: { delta: true },
          )
        end

        # The compute_delta method receives the current semantic tokens and the previous semantic tokens and then tries
        # to compute the smallest possible semantic token edit that will turn previous into current
        #: (Array[Integer] current_tokens, Array[Integer] previous_tokens, String result_id) -> Interface::SemanticTokensDelta
        def compute_delta(current_tokens, previous_tokens, result_id)
          # Find the index of the first token that is different between the two sets of tokens
          first_different_position = current_tokens.zip(previous_tokens).find_index { |new, old| new != old }

          # When deleting a token from the end, the first_different_position will be nil, but since we're removing at
          # the end, then we have to initialize it to the length of the current tokens after the deletion
          if !first_different_position && current_tokens.length < previous_tokens.length
            first_different_position = current_tokens.length
          end

          unless first_different_position
            return Interface::SemanticTokensDelta.new(result_id: result_id, edits: [])
          end

          # Filter the tokens based on the first different position. This must happen at this stage, before we try to
          # find the next position from the end or else we risk confusing sets of token that may have different lengths,
          # but end with the exact same token
          old_tokens = previous_tokens[first_different_position...] #: as !nil
          new_tokens = current_tokens[first_different_position...] #: as !nil

          # Then search from the end to find the first token that doesn't match. Since the user is normally editing the
          # middle of the file, this will minimize the number of edits since the end of the token array has not changed
          first_different_token_from_end = new_tokens.reverse.zip(old_tokens.reverse).find_index do |new, old|
            new != old
          end || 0

          # Filter the old and new tokens to only the section that will be replaced/inserted/deleted
          old_tokens = old_tokens[...old_tokens.length - first_different_token_from_end] #: as !nil
          new_tokens = new_tokens[...new_tokens.length - first_different_token_from_end] #: as !nil

          # And we send back a single edit, replacing an entire section for the new tokens
          Interface::SemanticTokensDelta.new(
            result_id: result_id,
            edits: [{ start: first_different_position, deleteCount: old_tokens.length, data: new_tokens }],
          )
        end

        #: -> Integer
        def next_result_id
          @mutex.synchronize do
            @result_id += 1
          end
        end
      end

      @result_id = 0 #: Integer
      @mutex = Mutex.new #: Mutex

      #: (GlobalState global_state, Prism::Dispatcher dispatcher, (RubyDocument | ERBDocument) document, String? previous_result_id, ?range: Range[Integer]?) -> void
      def initialize(global_state, dispatcher, document, previous_result_id, range: nil)
        super()

        @document = document
        @previous_result_id = previous_result_id
        @range = range
        @result_id = SemanticHighlighting.next_result_id.to_s #: String
        @response_builder = ResponseBuilders::SemanticHighlighting
          .new(document.code_units_cache) #: ResponseBuilders::SemanticHighlighting
        Listeners::SemanticHighlighting.new(dispatcher, @response_builder)

        Addon.addons.each do |addon|
          addon.create_semantic_highlighting_listener(@response_builder, dispatcher)
        end
      end

      # @override
      #: -> (Interface::SemanticTokens | Interface::SemanticTokensDelta)
      def perform
        previous_tokens = @document.semantic_tokens
        tokens = @response_builder.response
        encoded_tokens = ResponseBuilders::SemanticHighlighting::SemanticTokenEncoder.new.encode(tokens)
        full_response = Interface::SemanticTokens.new(result_id: @result_id, data: encoded_tokens)
        @document.semantic_tokens = full_response

        if @range
          tokens_within_range = tokens.select { |token| @range.cover?(token.start_line - 1) }

          return Interface::SemanticTokens.new(
            result_id: @result_id,
            data: ResponseBuilders::SemanticHighlighting::SemanticTokenEncoder.new.encode(tokens_within_range),
          )
        end

        # Semantic tokens full delta
        if @previous_result_id
          response = if previous_tokens.is_a?(Interface::SemanticTokens) &&
              previous_tokens.result_id == @previous_result_id
            Requests::SemanticHighlighting.compute_delta(encoded_tokens, previous_tokens.data, @result_id)
          else
            full_response
          end

          return response
        end

        # Semantic tokens full
        full_response
      end
    end
  end
end