require "json"
require "./protocol"
require "./json_rpc"
require "./text_document"
require "./semantic_tokens"
require "./inlay_hints"
require "./server/code_actions"
require "./server/code_lens"
require "./server/formatting"
require "./server/navigation"
require "../crystal/parser"
require "../crystal/semantic_analyzer"
require "../crystal/index/indexing_manager"

module LSP
  # LSP server implementation
  class Server
    @rpc : JsonRpcHandler
    @document_manager : TextDocumentManager
    @semantic_analyzer : Lattice::SemanticAnalyzer
    @indexing_manager : Lattice::IndexingManager
    @initialized = false
    @workspace_root : String?
    @shutdown_requested = false
    @code_actions_provider : Lattice::CodeActionsProvider
    @code_lens_provider : Lattice::CodeLensProvider
    @formatting_provider : Lattice::FormattingProvider
    @on_type_formatting_provider : Lattice::OnTypeFormattingProvider
    @diagnostics_cache : Hash(String, Array(LSP::Diagnostic))
    @inlay_hints_max_lines : Int32 = 2_000
    # True when the client declared support for pull-model diagnostics
    # (textDocument/diagnostic). In that case we suppress push-model
    # textDocument/publishDiagnostics notifications to avoid duplicates.
    @client_supports_pull_diagnostics : Bool = false

    # Initialize the server
    def initialize(input : IO = STDIN, output : IO = STDOUT)
      @rpc = JsonRpcHandler.new(input, output)
      @document_manager = TextDocumentManager.new
      @semantic_analyzer = Lattice::SemanticAnalyzer.new
      @indexing_manager = Lattice::IndexingManager.new(
        @semantic_analyzer.workspace_analyzer,
        ->(method : String, params : JSON::Any?) { @rpc.send_notification(method, params) },
        ->(id : String | Int32, method : String, params : JSON::Any?) { @rpc.send_request(id, method, params) }
      )

      @code_actions_provider = Lattice::CodeActionsProvider.new
      @code_lens_provider = Lattice::CodeLensProvider.new
      @formatting_provider = Lattice::FormattingProvider.new
      @on_type_formatting_provider = Lattice::OnTypeFormattingProvider.new
      @diagnostics_cache = Hash(String, Array(LSP::Diagnostic)).new

      setup_handlers
    end

    # Enable strict type checking mode
    def enable_strict_mode
      @semantic_analyzer.enable_strict_type_checking = true
    end

    # Run the server
    def run
      STDERR.puts "(Lattice) LSP server starting."
      @indexing_manager.start
      @rpc.listen
    ensure
      @indexing_manager.stop
    end

    # Setup request and notification handlers
    private def setup_handlers
      @rpc.on_request("initialize") { |params| handle_initialize(params) }
      @rpc.on_notification("initialized") { |params| handle_initialized(params) }
      @rpc.on_request("shutdown") { |params| handle_shutdown(params) }
      @rpc.on_notification("exit") { |params| handle_exit(params) }
      @rpc.on_notification("textDocument/didOpen") { |params| handle_did_open(params) }
      @rpc.on_notification("textDocument/didChange") { |params| handle_did_change(params) }
      @rpc.on_notification("textDocument/didClose") { |params| handle_did_close(params) }
      @rpc.on_notification("textDocument/didSave") { |params| handle_did_save(params) }
      @rpc.on_request("textDocument/completion") { |params| handle_completion(params) }
      @rpc.on_request("textDocument/hover") { |params| handle_hover(params) }
      @rpc.on_request("textDocument/signatureHelp") { |params| handle_signature_help(params) }
      @rpc.on_request("textDocument/definition") { |params| handle_definition(params) }
      @rpc.on_request("textDocument/references") { |params| handle_references(params) }
      @rpc.on_request("textDocument/documentSymbol") { |params| handle_document_symbol(params) }
      @rpc.on_request("workspace/symbol") { |params| handle_workspace_symbol(params) }
      @rpc.on_request("textDocument/rename") { |params| handle_rename(params) }
      @rpc.on_request("textDocument/prepareRename") { |params| handle_prepare_rename(params) }
      @rpc.on_request("workspace/workspaceFolders") { |params| handle_workspace_folders(params) }
      @rpc.on_notification("workspace/didChangeWatchedFiles") { |params| handle_did_change_watched_files(params) }
      @rpc.on_request("textDocument/declaration") { |params| handle_definition(params) }
      @rpc.on_request("textDocument/implementation") { |params| handle_implementation(params) }
      @rpc.on_request("textDocument/typeDefinition") { |params| handle_type_definition(params) }
      @rpc.on_request("textDocument/semanticTokens/full") { |params| handle_semantic_tokens_full(params) }
      @rpc.on_request("textDocument/semanticTokens/range") { |params| handle_semantic_tokens_range(params) }
      @rpc.on_request("textDocument/inlayHint") { |params| handle_inlay_hint(params) }
      @rpc.on_request("inlayHint/resolve") { |params| handle_inlay_hint_resolve(params) }
      @rpc.on_request("textDocument/codeAction") { |params| handle_code_action(params) }
      @rpc.on_request("codeAction/resolve") { |params| handle_code_action_resolve(params) }
      @rpc.on_request("textDocument/codeLens") { |params| handle_code_lens(params) }
      @rpc.on_request("codeLens/resolve") { |params| handle_code_lens_resolve(params) }
      @rpc.on_request("textDocument/formatting") { |params| handle_formatting(params) }
      @rpc.on_request("textDocument/rangeFormatting") { |params| handle_range_formatting(params) }
      @rpc.on_request("textDocument/onTypeFormatting") { |params| handle_on_type_formatting(params) }
      @rpc.on_request("textDocument/documentHighlight") { |params| handle_document_highlight(params) }
      @rpc.on_request("textDocument/foldingRange") { |params| handle_folding_range(params) }
      @rpc.on_request("textDocument/selectionRange") { |params| handle_selection_range(params) }
      @rpc.on_request("textDocument/linkedEditingRange") { |params| handle_linked_editing_range(params) }
      @rpc.on_request("textDocument/documentLink") { |params| handle_document_link(params) }
      @rpc.on_request("textDocument/prepareCallHierarchy") { |params| handle_prepare_call_hierarchy(params) }
      @rpc.on_request("callHierarchy/incomingCalls") { |params| handle_incoming_calls(params) }
      @rpc.on_request("callHierarchy/outgoingCalls") { |params| handle_outgoing_calls(params) }
      @rpc.on_request("textDocument/prepareTypeHierarchy") { |params| handle_prepare_type_hierarchy(params) }
      @rpc.on_request("typeHierarchy/supertypes") { |params| handle_type_supertypes(params) }
      @rpc.on_request("typeHierarchy/subtypes") { |params| handle_type_subtypes(params) }
      @rpc.on_request("textDocument/diagnostic") { |params| handle_text_document_diagnostic(params) }
      @rpc.on_request("workspace/diagnostic") { |params| handle_workspace_diagnostic(params) }
      @rpc.on_request("workspace/executeCommand") { |params| handle_execute_command(params) }
      @rpc.on_request("textDocument/documentColor") { |params| handle_document_color(params) }
      @rpc.on_request("textDocument/colorPresentation") { |params| handle_color_presentation(params) }
    end

    # Handle an initialize request
    private def handle_initialize(params : JSON::Any?) : JSON::Any
      STDERR.puts "Handling initialize request"

      if params
        root_uri = params["rootUri"]?.try(&.as_s?)
        @workspace_root = root_uri
        @semantic_analyzer.workspace_root = root_uri

        # Load .lattice.yml from the workspace root.  Applied before
        # initializationOptions so that editor settings take precedence.
        if root_uri
          workspace_path = root_uri.sub(/^file:\/\//, "").gsub("%3A", ":").gsub('/', File::SEPARATOR)
          workspace_path = workspace_path.lstrip(File::SEPARATOR) if workspace_path =~ /^\/[a-zA-Z]:\\/
          yml_path = File.join(workspace_path, ".lattice.yml")
          if File.exists?(yml_path)
            begin
              config = YAML.parse(File.read(yml_path))
              if disable_val = config["disable_missing_return_warning"]?.try(&.as_bool?)
                @semantic_analyzer.disable_missing_return_warning = disable_val
                STDERR.puts "(.lattice.yml) disable_missing_return_warning: #{disable_val}"
              end
              if ameba_val = config["enable_ameba_linting"]?.try(&.as_bool?)
                @semantic_analyzer.enable_ameba_linting = ameba_val
                STDERR.puts "(.lattice.yml) enable_ameba_linting: #{ameba_val}"
              end
              if strict_val = config["strict_mode"]?.try(&.as_bool?)
                @semantic_analyzer.enable_strict_type_checking = strict_val
                STDERR.puts "(.lattice.yml) strict_mode: #{strict_val}"
              end
              if mll_val = config["max_line_length"]?.try(&.as_i?)
                @semantic_analyzer.max_line_length = mll_val
                STDERR.puts "(.lattice.yml) max_line_length: #{mll_val}"
              end
              if debug_val = config["warn_on_debug_calls"]?.try(&.as_bool?)
                @semantic_analyzer.warn_on_debug_calls = debug_val
                STDERR.puts "(.lattice.yml) warn_on_debug_calls: #{debug_val}"
              end
              if ihl_val = config["inlay_hints_max_lines"]?.try(&.as_i?)
                @inlay_hints_max_lines = ihl_val
                STDERR.puts "(.lattice.yml) inlay_hints_max_lines: #{ihl_val}"
              end
              if sv_val = config["disable_shadowed_variable_warning"]?.try(&.as_bool?)
                @semantic_analyzer.disable_shadowed_variable_warning = sv_val
                STDERR.puts "(.lattice.yml) disable_shadowed_variable_warning: #{sv_val}"
              end
              if uv_val = config["disable_unused_variable_warning"]?.try(&.as_bool?)
                @semantic_analyzer.disable_unused_variable_warning = uv_val
                STDERR.puts "(.lattice.yml) disable_unused_variable_warning: #{uv_val}"
              end
              if up_val = config["disable_unused_parameter_warning"]?.try(&.as_bool?)
                @semantic_analyzer.disable_unused_parameter_warning = up_val
                STDERR.puts "(.lattice.yml) disable_unused_parameter_warning: #{up_val}"
              end
            rescue ex
              STDERR.puts "(Lattice) Failed to parse .lattice.yml: #{ex.message}"
            end
          end
        end

        # Detect whether the client supports pull-model diagnostics so we
        # can suppress push notifications and avoid duplicates.
        if diag_cap = params["capabilities"]?
          .try(&.["textDocument"]?)
          .try(&.["diagnostic"]?)
          @client_supports_pull_diagnostics = true
        end

        if init_options = params["initializationOptions"]?
          if strict_checking = init_options["strictTypeChecking"]?
            @semantic_analyzer.enable_strict_type_checking = strict_checking.as_bool? || false
            STDERR.puts "Strict type checking: #{@semantic_analyzer.enable_strict_type_checking?}"
          end
          if disable_missing_return = init_options["disableMissingReturnWarning"]?
            @semantic_analyzer.disable_missing_return_warning = disable_missing_return.as_bool? || false
            STDERR.puts "Disable missing return warning: #{@semantic_analyzer.disable_missing_return_warning?}"
          end
          if enable_ameba = init_options["enableAmebaLinting"]?
            @semantic_analyzer.enable_ameba_linting = enable_ameba.as_bool? != false
            STDERR.puts "Enable ameba linting: #{@semantic_analyzer.enable_ameba_linting?}"
          end
          if disable_sv = init_options["disableShadowedVariableWarning"]?
            @semantic_analyzer.disable_shadowed_variable_warning = disable_sv.as_bool? || false
            STDERR.puts "Disable shadowed variable warning: #{@semantic_analyzer.disable_shadowed_variable_warning?}"
          end
          if disable_uv = init_options["disableUnusedVariableWarning"]?
            @semantic_analyzer.disable_unused_variable_warning = disable_uv.as_bool? || false
            STDERR.puts "Disable unused variable warning: #{@semantic_analyzer.disable_unused_variable_warning?}"
          end
          if disable_up = init_options["disableUnusedParameterWarning"]?
            @semantic_analyzer.disable_unused_parameter_warning = disable_up.as_bool? || false
            STDERR.puts "Disable unused parameter warning: #{@semantic_analyzer.disable_unused_parameter_warning?}"
          end
        end
      end

      capabilities = {
        "textDocumentSync"   => 2,
        "hoverProvider"      => true,
        "completionProvider" => {
          "triggerCharacters" => [".", ":", "@"],
          "resolveProvider"   => false,
          "completionItem"    => {
            "snippetSupport" => true,
          },
        },
        "signatureHelpProvider" => {
          "triggerCharacters" => ["(", ","],
        },
        "definitionProvider"      => true,
        "declarationProvider"      => true,
        "implementationProvider"   => true,
        "typeDefinitionProvider"   => true,
        "referencesProvider"       => true,
        "semanticTokensProvider"   => {
          "legend" => {
            "tokenTypes"     => Lattice::TOKEN_TYPES,
            "tokenModifiers" => Lattice::TOKEN_MODIFIERS,
          },
          "range" => true,
          "full"  => true,
        },
        "inlayHintProvider" => true,
        "codeActionProvider" => {
          "codeActionKinds" => ["quickfix", "refactor.rewrite", "source"],
          "resolveProvider"  => false,
        },
        "codeLensProvider" => {
          "resolveProvider" => false,
        },
        "documentFormattingProvider"      => true,
        "documentRangeFormattingProvider" => true,
        "documentOnTypeFormattingProvider" => {
          "firstTriggerCharacter" => "d",
          "moreTriggerCharacter"  => ["e", "n", "s", "i"],
        },
        "documentHighlightProvider"        => true,
        "foldingRangeProvider"             => true,
        "selectionRangeProvider"           => true,
        "linkedEditingRangeProvider"       => true,
        "documentLinkProvider"             => {
          "resolveProvider" => false,
        },
        "callHierarchyProvider" => true,
        "typeHierarchyProvider" => true,
        "documentSymbolProvider"  => true,
        "workspaceSymbolProvider" => true,
        "renameProvider"          => {
          "prepareProvider" => true,
        },
        "workspace" => {
          "workspaceFolders" => {
            "supported"           => true,
            "changeNotifications" => true,
          },
          "diagnostics" => {
            "refreshSupport" => true,
          },
        },
        "window" => {
          "workDoneProgress" => true,
        },
        "executeCommandProvider" => {
          "commands" => ["lattice.check", "lattice.runSpec", "lattice.run"],
        },
        "colorProvider" => true,
        "diagnosticProvider" => {
          "identifier"            => "lattice",
          "interFileDependencies" => true,
          "workspaceDiagnostics"  => true,
        },
      }

      result = {
        "capabilities" => capabilities,
        "serverInfo"   => {
          "name"    => "Lattice",
          "version" => Lattice::VERSION,
        },
      }

      @initialized = true

      # Kick off an initial full workspace index in the background.
      @indexing_manager.enqueue_full_scan

      JSON.parse(result.to_json)
    end

    # Handle an initialized notification
    private def handle_initialized(params : JSON::Any?)
      STDERR.puts "Server initialized"
    end

    # Handle a shutdown request
    private def handle_shutdown(params : JSON::Any?) : JSON::Any
      STDERR.puts "Shutdown requested"
      @shutdown_requested = true
      JSON.parse("null")
    end

    # Handle an exit notification
    private def handle_exit(params : JSON::Any?)
      STDERR.puts "Exiting"
      exit(0)
    end

    # Handle a didOpen notification
    private def handle_did_open(params : JSON::Any?)
      return unless params

      did_open = DidOpenTextDocumentParams.from_json(params.to_json)
      doc = did_open.text_document

      @document_manager.open(doc.uri, doc.language_id, doc.version, doc.text)
      @semantic_analyzer.update_source(doc.uri, doc.text)

      send_diagnostics(doc.uri)
    end

    # Handle a didChange notification
    private def handle_did_change(params : JSON::Any?)
      return unless params

      did_change = DidChangeTextDocumentParams.from_json(params.to_json)
      doc = did_change.text_document

      @document_manager.change(doc.uri, doc.version, did_change.content_changes)

      if text_doc = @document_manager.get(doc.uri)
        @semantic_analyzer.update_source(doc.uri, text_doc.text)
      end

      send_diagnostics(doc.uri)
    end

    # Handle a didClose notification
    private def handle_did_close(params : JSON::Any?)
      return unless params

      did_close = DidCloseTextDocumentParams.from_json(params.to_json)
      @document_manager.close(did_close.text_document.uri)
      @semantic_analyzer.remove_source(did_close.text_document.uri)
    end

    # Handle a didSave notification
    private def handle_did_save(params : JSON::Any?)
      return unless params

      did_save = DidSaveTextDocumentParams.from_json(params.to_json)
      # Re-index the saved file in the background so the workspace symbol
      # table stays current without blocking the response path.
      @indexing_manager.enqueue(did_save.text_document.uri)
      send_diagnostics(did_save.text_document.uri)
    end

    # Handle workspace/workspaceFolders request
    private def handle_workspace_folders(params : JSON::Any?) : JSON::Any
      JSON.parse("null")
    end

    # Handle workspace/didChangeWatchedFiles notification
    private def handle_did_change_watched_files(params : JSON::Any?)
      return unless params

      changes = params["changes"]?
      return unless changes

      changes.as_a?.try &.each do |change|
        uri = change["uri"]?.try(&.as_s?) || next
        type = change["type"]?.try(&.as_i?) || next

        case type
        when 1, 2
          # Created or Changed — enqueue background re-index of the affected file.
          @indexing_manager.enqueue(uri)
        when 3
          # Deleted — remove from semantic analyzer immediately.
          @semantic_analyzer.remove_source(uri)
        end
      end
    end

    # Completion request
    private def handle_completion(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      completion_params = CompletionParams.from_json(params.to_json)
      doc = @document_manager.get(completion_params.text_document.uri)
      return JSON.parse("null") unless doc

      items = [] of LSP::CompletionItem

      parser = Lattice::CrystalParser.new(doc.uri, doc.text)
      items += parser.completions(completion_params.position)

      semantic_items = @semantic_analyzer.completions(doc.uri, completion_params.position)
      items += semantic_items

      items = items.uniq(&.label)

      JSON.parse(items.to_json)
    end

    # Hover request
    private def handle_hover(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      hover_params = TextDocumentPositionParams.from_json(params.to_json)
      doc = @document_manager.get(hover_params.text_document.uri)
      return JSON.parse("null") unless doc

      if hover = @semantic_analyzer.hover(doc.uri, hover_params.position)
        return JSON.parse(hover.to_json)
      end
      if word = doc.get_word_at_position(hover_params.position)
        content = MarkupContent.new("markdown", "**#{word}**")
        hover = Hover.new(content)
        return JSON.parse(hover.to_json)
      end

      JSON.parse("null")
    end

    # Signature help request
    private def handle_signature_help(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      sig_params = SignatureHelpParams.from_json(params.to_json)
      doc = @document_manager.get(sig_params.text_document.uri)
      return JSON.parse("null") unless doc

      if sig_help = @semantic_analyzer.signature_help(doc.uri, sig_params.position)
        return JSON.parse(sig_help.to_json)
      end

      JSON.parse("null")
    end

    # Definition request
    private def handle_definition(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      def_params = TextDocumentPositionParams.from_json(params.to_json)
      doc = @document_manager.get(def_params.text_document.uri)
      return JSON.parse("null") unless doc

      if location = @semantic_analyzer.find_definition(doc.uri, def_params.position)
        return JSON.parse(location.to_json)
      end

      JSON.parse("null")
    end

    # References request
    private def handle_references(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      ref_params = ReferenceParams.from_json(params.to_json)
      doc = @document_manager.get(ref_params.text_document.uri)
      return JSON.parse("null") unless doc

      locations = @semantic_analyzer.find_references(
        doc.uri,
        ref_params.position,
        ref_params.context.include_declaration? || false
      )

      JSON.parse(locations.to_json)
    end

    # Document symbol request
    private def handle_document_symbol(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      symbol_params = DocumentSymbolParams.from_json(params.to_json)
      doc = @document_manager.get(symbol_params.text_document.uri)
      return JSON.parse("null") unless doc

      parser = Lattice::CrystalParser.new(doc.uri, doc.text)
      doc_symbols = parser.document_symbols

      symbol_infos = [] of SymbolInformation
      flatten_document_symbols(doc_symbols, doc.uri, symbol_infos)

      JSON.parse(symbol_infos.to_json)
    end

    # Flatten hierarchical DocumentSymbol to flat SymbolInformation
    private def flatten_document_symbols(
      symbols : Array(DocumentSymbol),
      uri : String,
      result : Array(SymbolInformation),
      container : String? = nil,
    )
      symbols.each do |sym|
        location = Location.new(uri, sym.selection_range)
        info = SymbolInformation.new(sym.name, sym.kind, location)
        info.container_name = container if container
        result << info

        if children = sym.children?
          flatten_document_symbols(children, uri, result, sym.name)
        end
      end
    end

    # Workspace symbol request
    private def handle_workspace_symbol(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      symbol_params = WorkspaceSymbolParams.from_json(params.to_json)
      query = symbol_params.query.downcase

      symbols = [] of SymbolInformation

      @document_manager.all.each do |doc|
        parser = Lattice::CrystalParser.new(doc.uri, doc.text)
        doc_symbols = parser.document_symbols
        doc_symbols.each do |sym|
          if sym.name.downcase.includes?(query)
            location = Location.new(doc.uri, sym.range)
            symbols << SymbolInformation.new(sym.name, sym.kind, location)
          end
        end
      end

      JSON.parse(symbols.to_json)
    end

    # Prepare rename request
    private def handle_prepare_rename(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      rename_params = TextDocumentPositionParams.from_json(params.to_json)
      doc = @document_manager.get(rename_params.text_document.uri)
      return JSON.parse("null") unless doc

      if range = @semantic_analyzer.prepare_rename(doc.uri, rename_params.position)
        return JSON.parse(range.to_json)
      end
      if word = doc.get_word_at_position(rename_params.position)
        # Reject type names (class/module/struct) that are defined in more than
        # one file — renaming them safely requires cross-file edits that need
        # explicit user confirmation which Zed does not yet surface.
        if type_spans_files?(word)
          return JSON.parse("null")
        end

        line = doc.get_line(rename_params.position.line)
        return JSON.parse("null") unless line
        char = rename_params.position.character
        start_pos = char
        while start_pos > 0 && word_char?(line[start_pos - 1])
          start_pos -= 1
        end

        end_pos = char
        while end_pos < line.size && word_char?(line[end_pos])
          end_pos += 1
        end

        range = Range.new(
          Position.new(rename_params.position.line, start_pos),
          Position.new(rename_params.position.line, end_pos)
        )
        return JSON.parse(range.to_json)
      end

      JSON.parse("null")
    end

    # Rename request
    private def handle_rename(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      rename_params = RenameParams.from_json(params.to_json)
      doc = @document_manager.get(rename_params.text_document.uri)
      return JSON.parse("null") unless doc

      if edit = @semantic_analyzer.rename(doc.uri, rename_params.position, rename_params.new_name)
        return JSON.parse(edit.to_json)
      end

      JSON.parse("null")
    end

    # Declaration request — for Crystal, declaration == definition.
    # Registered via the shared `handle_definition` handler above.

    # Implementation request — finds concrete classes/modules that include or
    # inherit from the abstract type under the cursor.  Falls back to the
    # definition location when no implementors are found in the workspace index.
    private def handle_implementation(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      pos_params = LSP::TextDocumentPositionParams.from_json(params.to_json)
      uri = pos_params.text_document.uri
      doc = @document_manager.get(uri)
      return JSON.parse("null") unless doc

      source = doc.text
      lines  = source.split('\n')
      return JSON.parse("null") if pos_params.position.line >= lines.size

      line_text = lines[pos_params.position.line]

      # Extract the symbol name at the cursor position.
      char = pos_params.position.character
      s = char
      while s > 0 && word_char?(line_text[s - 1])
        s -= 1
      end
      e = char
      while e < line_text.size && word_char?(line_text[e])
        e += 1
      end
      word = line_text[s...e]
      return JSON.parse("null") if word.empty?

      idx = @semantic_analyzer.workspace_analyzer.workspace_index
      locations = [] of LSP::Location

      # Collect all classes/structs/modules that inherit or include *word*.
      (idx.all_of_kind("class") + idx.all_of_kind("struct")).each do |entry|
        parent = entry.type_name
        next if parent.empty?
        short_parent = parent.split("::").last
        if short_parent == word || parent == word
          r = LSP::Range.new(
            LSP::Position.new(entry.line, entry.column),
            LSP::Position.new(entry.line, entry.column + entry.name.split("::").last.size),
          )
          locations << LSP::Location.new(file_path_to_uri(entry.file), r)
        end
      end

      # Include modules/classes that have an `include Word` statement in their source.
      # We scan the raw source of every indexed file rather than relying on
      # type_name, which stores the inheritance parent, not included modules.
      idx.all_of_kind("module").each do |entry|
        next if entry.name == word || entry.name.ends_with?("::#{word}")
        src = begin
          File.read(entry.file)
        rescue
          next
        end
        next unless src.includes?("include #{word}") || src.match(/include\s+#{Regex.escape(word)}\b/)
        r = LSP::Range.new(
          LSP::Position.new(entry.line, entry.column),
          LSP::Position.new(entry.line, entry.column + entry.name.split("::").last.size),
        )
        locations << LSP::Location.new(file_path_to_uri(entry.file), r)
      end

      # Also scan classes/structs for include statements (complement to the
      # inheritance check above, which only catches subclasses).
      (idx.all_of_kind("class") + idx.all_of_kind("struct")).each do |entry|
        # Skip entries already added by the inheritance check above.
        parent = entry.type_name
        short_parent = parent.split("::").last
        next if short_parent == word || parent == word
        src = begin
          File.read(entry.file)
        rescue
          next
        end
        next unless src.includes?("include #{word}") || src.match(/include\s+#{Regex.escape(word)}\b/)
        r = LSP::Range.new(
          LSP::Position.new(entry.line, entry.column),
          LSP::Position.new(entry.line, entry.column + entry.name.split("::").last.size),
        )
        locations << LSP::Location.new(file_path_to_uri(entry.file), r)
      end

      # Fall back to go-to-definition when no implementors are found, logging
      # a warning so the user knows the index had no implementors for this symbol.
      if locations.empty?
        STDERR.puts "(Lattice) go-to-implementation: no implementors found for '#{word}'; falling back to go-to-definition"
        return handle_definition(params)
      end

      JSON.parse(locations.to_json)
    end

    # Type definition request — resolves the *type* of the symbol at the cursor
    # and then jumps to the definition of that type.
    private def handle_type_definition(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      pos_params = LSP::TextDocumentPositionParams.from_json(params.to_json)
      uri = pos_params.text_document.uri
      doc = @document_manager.get(uri)
      return JSON.parse("null") unless doc

      source = doc.text

      # Ask the workspace analyzer to infer the type at the cursor position.
      inferred_type = @semantic_analyzer.workspace_analyzer.get_type_at_position(
        uri, source, pos_params.position
      )
      return JSON.parse("null") unless inferred_type

      type_name = inferred_type.split("(").first.strip   # strip generic parameters
      idx = @semantic_analyzer.workspace_analyzer.workspace_index

      # Try to resolve the type name to an indexed entry.
      idx.find_by_name(type_name).each do |entry|
        next unless %w[class module struct enum].includes?(entry.kind)
        next unless File.exists?(entry.file)

        r = LSP::Range.new(
          LSP::Position.new(entry.line, entry.column),
          LSP::Position.new(entry.line, entry.column + type_name.split("::").last.size),
        )
        return JSON.parse(LSP::Location.new(file_path_to_uri(entry.file), r).to_json)
      end

      # Last resort: use go-to-definition on the type name token itself so the
      # caller gets *something* useful rather than null.
      handle_definition(params)
    end

    # Semantic tokens — full document.
    private def handle_semantic_tokens_full(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      st_params = LSP::SemanticTokensParams.from_json(params.to_json)
      doc = @document_manager.get(st_params.text_document.uri)
      return JSON.parse("null") unless doc

      psi = Lattice::Psi::PsiFile.new(doc.uri, doc.text)
      provider = Lattice::SemanticTokensProvider.new(psi)
      tokens = provider.full
      JSON.parse(tokens.to_json)
    end

    # Semantic tokens — range only.
    private def handle_semantic_tokens_range(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      st_params = LSP::SemanticTokensRangeParams.from_json(params.to_json)
      doc = @document_manager.get(st_params.text_document.uri)
      return JSON.parse("null") unless doc

      psi = Lattice::Psi::PsiFile.new(doc.uri, doc.text)
      provider = Lattice::SemanticTokensProvider.new(psi)
      tokens = provider.range(st_params.range)
      JSON.parse(tokens.to_json)
    end

    # Inlay hints — return hints for the requested range.
    private def handle_inlay_hint(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      hint_params = LSP::InlayHintParams.from_json(params.to_json)
      doc = @document_manager.get(hint_params.text_document.uri)
      return JSON.parse("null") unless doc

      psi = Lattice::Psi::PsiFile.new(doc.uri, doc.text)
      provider = Lattice::InlayHintsProvider.new(psi, @inlay_hints_max_lines)
      hints = provider.hints(hint_params.range)
      JSON.parse(hints.to_json)
    end

    # Inlay hint resolve — no-op (resolve_provider is false; return hint as-is).
    private def handle_inlay_hint_resolve(params : JSON::Any?) : JSON::Any
      params || JSON.parse("null")
    end

    # ---------------------------------------------------------------------------
    # Code actions
    # ---------------------------------------------------------------------------

    private def handle_code_action(params : JSON::Any?) : JSON::Any
      return JSON.parse("[]") unless params

      ca_params = LSP::CodeActionParams.from_json(params.to_json)
      uri = ca_params.text_document.uri
      doc = @document_manager.get(uri)
      return JSON.parse("[]") unless doc

      # Use the cached diagnostics for this URI so we don't re-run the whole
      # analysis pipeline on every code-action request.
      cached = @diagnostics_cache[uri]? || [] of LSP::Diagnostic

      # Re-run analysis if the cache is empty (e.g. code action requested
      # before the first didSave/didChange diagnostic publish).
      if cached.empty?
        cached = @semantic_analyzer.analyze(uri)
        @diagnostics_cache[uri] = cached
      end

      # Filter to diagnostics that overlap the requested range.
      range_diags = cached.select do |d|
        d.range.start.line <= ca_params.range.ending.line &&
          d.range.ending.line >= ca_params.range.start.line
      end

      actions = @code_actions_provider.actions(uri, doc.text, range_diags, ca_params.range)
      JSON.parse(actions.to_json)
    end

    # Code action resolve — return the action unchanged (no lazy computation).
    private def handle_code_action_resolve(params : JSON::Any?) : JSON::Any
      params || JSON.parse("null")
    end

    # ---------------------------------------------------------------------------
    # Code lens
    # ---------------------------------------------------------------------------

    private def handle_code_lens(params : JSON::Any?) : JSON::Any
      return JSON.parse("[]") unless params

      cl_params = LSP::CodeLensParams.from_json(params.to_json)
      doc = @document_manager.get(cl_params.text_document.uri)
      return JSON.parse("[]") unless doc

      lenses = @code_lens_provider.lenses(cl_params.text_document.uri, doc.text)
      JSON.parse(lenses.to_json)
    end

    # Code lens resolve — lenses are fully populated; return as-is.
    private def handle_code_lens_resolve(params : JSON::Any?) : JSON::Any
      params || JSON.parse("null")
    end

    # ---------------------------------------------------------------------------
    # Formatting
    # ---------------------------------------------------------------------------

    private def handle_formatting(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      fmt_params = LSP::DocumentFormattingParams.from_json(params.to_json)
      doc = @document_manager.get(fmt_params.text_document.uri)
      return JSON.parse("null") unless doc

      edits = @formatting_provider.format(fmt_params.text_document.uri, doc.text)
      return JSON.parse("null") if edits.empty?

      JSON.parse(edits.to_json)
    end

    private def handle_range_formatting(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      fmt_params = LSP::DocumentRangeFormattingParams.from_json(params.to_json)
      doc = @document_manager.get(fmt_params.text_document.uri)
      return JSON.parse("null") unless doc

      edits = @formatting_provider.format_range(
        fmt_params.text_document.uri,
        doc.text,
        fmt_params.range,
      )
      return JSON.parse("null") if edits.empty?

      JSON.parse(edits.to_json)
    end

    private def handle_on_type_formatting(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      otf_params = LSP::DocumentOnTypeFormattingParams.from_json(params.to_json)
      doc = @document_manager.get(otf_params.text_document.uri)
      return JSON.parse("null") unless doc

      edits = @on_type_formatting_provider.edits(
        doc.text,
        otf_params.position.line,
        otf_params.position.character,
        otf_params.ch,
      )
      return JSON.parse("null") if edits.empty?

      JSON.parse(edits.to_json)
    end

    # ---------------------------------------------------------------------------
    # Phase 4 — Navigation & Visual Handlers
    # ---------------------------------------------------------------------------

    private def handle_document_highlight(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      pos_params = LSP::TextDocumentPositionParams.from_json(params.to_json)
      doc = @document_manager.get(pos_params.text_document.uri)
      return JSON.parse("null") unless doc

      highlights = Lattice::NavigationProviders.document_highlights(doc.text, pos_params.position)
      JSON.parse(highlights.to_json)
    end

    private def handle_folding_range(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      fr_params = LSP::FoldingRangeParams.from_json(params.to_json)
      doc = @document_manager.get(fr_params.text_document.uri)
      return JSON.parse("null") unless doc

      ranges = Lattice::NavigationProviders.folding_ranges(doc.text)
      JSON.parse(ranges.to_json)
    end

    private def handle_selection_range(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      sr_params = LSP::SelectionRangeParams.from_json(params.to_json)
      doc = @document_manager.get(sr_params.text_document.uri)
      return JSON.parse("null") unless doc

      results = Lattice::NavigationProviders.selection_ranges(doc.text, sr_params.positions)
      JSON.parse(results.to_json)
    end

    private def handle_linked_editing_range(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      ler_params = LSP::LinkedEditingRangeParams.from_json(params.to_json)
      doc = @document_manager.get(ler_params.text_document.uri)
      return JSON.parse("null") unless doc

      if result = Lattice::NavigationProviders.linked_editing_ranges(doc.text, ler_params.position)
        return JSON.parse(result.to_json)
      end
      JSON.parse("null")
    end

    private def handle_document_link(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      dl_params = LSP::DocumentLinkParams.from_json(params.to_json)
      doc = @document_manager.get(dl_params.text_document.uri)
      return JSON.parse("null") unless doc

      workspace_path = @workspace_root.try { |r|
        r.starts_with?("file://") ? r.sub(/^file:\/\//, "").gsub('/', File::SEPARATOR) : r
      }
      links = Lattice::NavigationProviders.document_links(
        dl_params.text_document.uri,
        doc.text,
        workspace_path,
      )
      JSON.parse(links.to_json)
    end

    private def handle_prepare_call_hierarchy(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      ch_params = LSP::CallHierarchyPrepareParams.from_json(params.to_json)
      doc = @document_manager.get(ch_params.text_document.uri)
      return JSON.parse("null") unless doc

      idx = @semantic_analyzer.workspace_analyzer.workspace_index
      if items = Lattice::NavigationProviders.prepare_call_hierarchy(
           ch_params.text_document.uri, doc.text, ch_params.position, idx
         )
        return JSON.parse(items.to_json)
      end
      JSON.parse("null")
    end

    private def handle_incoming_calls(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      ic_params = LSP::CallHierarchyIncomingCallsParams.from_json(params.to_json)
      idx = @semantic_analyzer.workspace_analyzer.workspace_index
      sources = build_sources_map
      calls = Lattice::NavigationProviders.incoming_calls(ic_params.item, idx, sources)
      JSON.parse(calls.to_json)
    end

    private def handle_outgoing_calls(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      oc_params = LSP::CallHierarchyOutgoingCallsParams.from_json(params.to_json)
      idx = @semantic_analyzer.workspace_analyzer.workspace_index
      sources = build_sources_map
      calls = Lattice::NavigationProviders.outgoing_calls(oc_params.item, idx, sources)
      JSON.parse(calls.to_json)
    end

    private def handle_prepare_type_hierarchy(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      th_params = LSP::TypeHierarchyPrepareParams.from_json(params.to_json)
      doc = @document_manager.get(th_params.text_document.uri)
      return JSON.parse("null") unless doc

      idx = @semantic_analyzer.workspace_analyzer.workspace_index
      if items = Lattice::NavigationProviders.prepare_type_hierarchy(
           th_params.text_document.uri, doc.text, th_params.position, idx
         )
        return JSON.parse(items.to_json)
      end
      JSON.parse("null")
    end

    private def handle_type_supertypes(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      st_params = LSP::TypeHierarchySupertypesParams.from_json(params.to_json)
      idx = @semantic_analyzer.workspace_analyzer.workspace_index
      items = Lattice::NavigationProviders.supertypes(st_params.item, idx)
      JSON.parse(items.to_json)
    end

    private def handle_type_subtypes(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      st_params = LSP::TypeHierarchySubtypesParams.from_json(params.to_json)
      idx = @semantic_analyzer.workspace_analyzer.workspace_index
      items = Lattice::NavigationProviders.subtypes(st_params.item, idx)
      JSON.parse(items.to_json)
    end

    # ---------------------------------------------------------------------------
    # Pull-model diagnostics
    # ---------------------------------------------------------------------------

    # Handle a textDocument/diagnostic request (LSP pull model).
    # Returns a FullDocumentDiagnosticReport with the current diagnostics for
    # the requested document.  This is called by Zed on-demand instead of
    # waiting for textDocument/publishDiagnostics push notifications.
    private def handle_text_document_diagnostic(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      diag_params = LSP::DocumentDiagnosticParams.from_json(params.to_json)
      uri = diag_params.text_document.uri

      # Ensure the document is loaded in the manager (open documents take
      # priority; fall back to reading from disk for closed-but-indexed files).
      unless @document_manager.get(uri)
        path = uri.sub(/^file:\/\//, "").gsub('/', File::SEPARATOR)
        path = path.lstrip('/') if path =~ /^\/[a-zA-Z]:\\/
        if File.exists?(path)
          source = File.read(path)
          @document_manager.open(uri, "crystal", 0, source)
          @semantic_analyzer.update_source(uri, source)
        end
      end

      diagnostics = @semantic_analyzer.analyze(uri)
      @diagnostics_cache[uri] = diagnostics

      report = LSP::FullDocumentDiagnosticReport.new(diagnostics)
      JSON.parse(report.to_json)
    end

    # Handle a workspace/diagnostic request.
    # Runs diagnostics across every file currently known to the workspace index
    # and returns a WorkspaceDiagnosticReport.
    private def handle_workspace_diagnostic(params : JSON::Any?) : JSON::Any
      items = [] of LSP::WorkspaceFullDocumentDiagnosticReport

      # Analyse all open documents first.
      @document_manager.all.each do |doc|
        diags = @semantic_analyzer.analyze(doc.uri)
        @diagnostics_cache[doc.uri] = diags
        items << LSP::WorkspaceFullDocumentDiagnosticReport.new(doc.uri, diags)
      end

      # Also analyse every file tracked by the workspace index that is not
      # currently open (load from disk on demand).
      open_uris = @document_manager.all.map(&.uri).to_set
      idx = @semantic_analyzer.workspace_analyzer.workspace_index

      indexed_paths = [] of String
      idx.all_of_kind("class").map(&.file).each { |f| indexed_paths << f }
      idx.all_of_kind("module").map(&.file).each { |f| indexed_paths << f }
      idx.all_of_kind("method").map(&.file).each { |f| indexed_paths << f }
      indexed_paths.uniq!

      indexed_paths.each do |path|
        uri = file_path_to_uri(path)
        next if open_uris.includes?(uri)
        next unless File.exists?(path)

        source = File.read(path)
        @semantic_analyzer.update_source(uri, source)
        diags = @semantic_analyzer.analyze(uri)
        @diagnostics_cache[uri] = diags
        next if diags.empty?  # omit clean files to keep the response concise
        items << LSP::WorkspaceFullDocumentDiagnosticReport.new(uri, diags)
      rescue
        next
      end

      report = LSP::WorkspaceDiagnosticReport.new(items)
      JSON.parse(report.to_json)
    end

    # textDocument/documentColor — scan the source for CSS-style hex color
    # literals (`#RRGGBB` / `#RRGGBBAA`) and return color information so the
    # client can render an inline color swatch.
    private def handle_document_color(params : JSON::Any?) : JSON::Any
      return JSON.parse("[]") unless params

      dc_params = LSP::SemanticTokensParams.from_json(params.to_json)
      doc = @document_manager.get(dc_params.text_document.uri)
      return JSON.parse("[]") unless doc

      results = [] of LSP::ColorInformation
      doc.text.split('\n').each_with_index do |line, ln|
        # Skip comment lines.
        next if line.strip.starts_with?('#') && !line.includes?('"')

        offset = 0
        while m = line.match(/"#([0-9a-fA-F]{6,8})"/, offset)
          hex = m[1]
          r = hex[0..1].to_i(16) / 255.0
          g = hex[2..3].to_i(16) / 255.0
          b = hex[4..5].to_i(16) / 255.0
          a = hex.size >= 8 ? hex[6..7].to_i(16) / 255.0 : 1.0

          col_start = m.begin(0)
          col_end   = m.end(0)
          range = LSP::Range.new(
            LSP::Position.new(ln, col_start),
            LSP::Position.new(ln, col_end),
          )
          results << LSP::ColorInformation.new(range, LSP::Color.new(r, g, b, a))
          offset = col_end
        end
      end

      JSON.parse(results.to_json)
    end

    # textDocument/colorPresentation — given a color chosen by the editor's
    # color picker, return the hex string representations the user can insert.
    private def handle_color_presentation(params : JSON::Any?) : JSON::Any
      return JSON.parse("[]") unless params

      cp_params = LSP::ColorPresentationParams.from_json(params.to_json)
      c = cp_params.color
      r = (c.red   * 255).round.to_i
      g = (c.green * 255).round.to_i
      b = (c.blue  * 255).round.to_i
      a = (c.alpha * 255).round.to_i

      hex_rgb  = "\"#%02X%02X%02X\""   % {r, g, b}
      hex_rgba = "\"#%02X%02X%02X%02X\"" % {r, g, b, a}

      presentations = [
        LSP::ColorPresentation.new(hex_rgb,
          LSP::TextEdit.new(cp_params.range, hex_rgb)),
        LSP::ColorPresentation.new(hex_rgba,
          LSP::TextEdit.new(cp_params.range, hex_rgba)),
      ]
      JSON.parse(presentations.to_json)
    end

    # workspace/executeCommand — run lattice.check, lattice.runSpec, or lattice.run
    # in a subprocess and stream stdout/stderr back via window/logMessage
    # notifications so the client can display progress without a terminal.
    private def handle_execute_command(params : JSON::Any?) : JSON::Any
      return JSON.parse("null") unless params

      command   = params["command"]?.try(&.as_s?) || ""
      arguments = params["arguments"]?.try(&.as_a?) || [] of JSON::Any

      case command
      when "lattice.check"
        # Run `lattice check <workspace>` and stream output as log messages.
        workspace_path = @workspace_root.try { |r|
          r.starts_with?("file://") ? r.sub(/^file:\/\//, "").gsub('/', File::SEPARATOR) : r
        } || "."
        workspace_path = workspace_path.lstrip('/') if workspace_path =~ /^\/[a-zA-Z]:\\/
        spawn_command(["lattice", "check", workspace_path])

      when "lattice.runSpec"
        # Arguments: [uri, description?]
        uri  = arguments[0]?.try(&.as_s?) || ""
        desc = arguments[1]?.try(&.as_s?)
        path = uri_to_file_path(uri)

        cmd = if desc
          ["crystal", "spec", "#{path}"]
        else
          ["crystal", "spec", path]
        end
        spawn_command(cmd)

      when "lattice.run"
        uri  = arguments[0]?.try(&.as_s?) || ""
        path = uri_to_file_path(uri)
        spawn_command(["crystal", "run", path])

      else
        STDERR.puts "(Lattice) workspace/executeCommand: unknown command '#{command}'"
      end

      JSON.parse("null")
    end

    # Spawn *argv* and forward its combined stdout+stderr to the client via
    # window/logMessage notifications so the user sees live output.
    private def spawn_command(argv : Array(String))
      display_cmd = argv.join(" ")
      notify_log("(Lattice) Running: #{display_cmd}", "log")

      begin
        process = Process.new(
          argv[0],
          args: argv[1..],
          output: Process::Redirect::Pipe,
          error: Process::Redirect::Pipe,
        )

        # Forward stdout
        spawn do
          process.output.each_line do |line|
            notify_log(line, "log")
          end
        end

        # Forward stderr
        spawn do
          process.error.each_line do |line|
            notify_log(line, "info")
          end
        end

        status = process.wait
        level  = status.success? ? "log" : "warning"
        notify_log("(Lattice) Command exited with code #{status.exit_code}: #{display_cmd}", level)
      rescue ex
        notify_log("(Lattice) Failed to run '#{display_cmd}': #{ex.message}", "error")
      end
    end

    # Send a window/logMessage notification to the client.
    # *level* is one of: "error" | "warning" | "info" | "log"
    private def notify_log(message : String, level : String)
      type_num = case level
                 when "error"   then 1
                 when "warning" then 2
                 when "info"    then 3
                 else                4
                 end
      @rpc.send_notification("window/logMessage",
        JSON.parse({"type" => type_num, "message" => message}.to_json))
    end

    # Convert a file:// URI back to a filesystem path.
    private def uri_to_file_path(uri : String) : String
      path = uri.sub(/^file:\/\/\//, "").gsub("%3A", ":")
      # Windows drive-letter paths (e.g. "C:/Users/...") must not gain a
      # leading slash; POSIX absolute paths need theirs restored after
      # stripping "file:///" above. This must not depend on File::SEPARATOR,
      # since a drive-letter path can appear even when File::SEPARATOR is '/'.
      path = "/#{path}" unless path =~ /^[a-zA-Z]:/
      path.gsub('/', File::SEPARATOR)
    end

    # Returns true if *word* is a type name (class/module/struct) whose
    # definition appears in more than one file in the workspace index.
    # Used by handle_prepare_rename to block rename for cross-file types.
    private def type_spans_files?(word : String) : Bool
      idx = @semantic_analyzer.workspace_analyzer.workspace_index
      type_kinds = %w[class module struct]
      matching = idx.find_by_name(word).select { |e| type_kinds.includes?(e.kind) }
      files = matching.map(&.file).uniq
      files.size > 1
    end

    # Convert a filesystem path to a file:// URI.  Mirrors the logic in
    # SemanticAnalyzer#filename_to_uri but lives in the server so navigation
    # helpers can use it without exposing private analyzer methods.
    private def file_path_to_uri(path : String) : String
      p = path.gsub(File::SEPARATOR, '/')
      if p =~ /^([a-zA-Z]):/
        drive = p[0].to_s
        rest  = p[2..]
        p = "#{drive}%3A#{rest}"
      end
      p = p.lstrip('/')
      "file:///#{p}"
    end

    # Build a URI → source map from all open documents (used by call hierarchy).
    private def build_sources_map : Hash(String, String)
      map = Hash(String, String).new
      @document_manager.all.each { |doc| map[doc.uri] = doc.text }
      map
    end

    # Send diagnostics for a document.
    # Skipped when the client supports pull-model diagnostics
    # (textDocument/diagnostic) to prevent duplicate diagnostics.
    private def send_diagnostics(uri : DocumentUri)
      return if @client_supports_pull_diagnostics

      doc = @document_manager.get(uri)
      return unless doc

      diagnostics = @semantic_analyzer.analyze(uri)
      @diagnostics_cache[uri] = diagnostics
      publish_params = PublishDiagnosticsParams.new(uri, diagnostics)
      @rpc.send_notification("textDocument/publishDiagnostics", JSON.parse(publish_params.to_json))
    end

    # Check if a character is a word character
    private def word_char?(char : Char) : Bool
      char.alphanumeric? || char == '_' || char == '?' || char == '!'
    end
  end
end
