require "../lsp/protocol"
require "./workspace_analyzer"
require "./completion/completion_provider"
require "./completion/providers/keywords_provider"
require "./completion/providers/reference_provider"
require "./completion/providers/require_provider"
require "./completion/providers/struct_literal_provider"
require "./completion/providers/snippet_provider"
require "compiler/crystal/syntax"
require "uri"
require "yaml"
require "./inspections/inspection"
require "./inspections/unused_variable_inspection"
require "./inspections/unused_parameter_inspection"
require "./inspections/shadowed_variable_inspection"
require "./inspections/missing_return_inspection"
require "./inspections/ameba_inspection"
require "./inspections/max_line_length_inspection"
require "./inspections/warn_on_debug_calls_inspection"
require "./inspections/redundant_require_inspection"
require "./inspections/unnecessary_not_nil_inspection"
require "./inspections/constant_in_interpolation_inspection"
require "./inspections/empty_rescue_inspection"

module Lattice
  class SemanticAnalyzer
    property workspace_root : String?
    property? enable_semantic_hover : Bool = true
    property? enable_type_aware_completion : Bool = true
    property? enable_strict_type_checking : Bool = false
    property? disable_missing_return_warning : Bool = true
    property? enable_ameba_linting : Bool = true
    property max_line_length : Int32 = 0
    property? warn_on_debug_calls : Bool = false
    property? disable_shadowed_variable_warning : Bool = false
    property? disable_unused_variable_warning : Bool = false
    property? disable_unused_parameter_warning : Bool = false

    def max_line_length=(val : Int32)
      @max_line_length = val
      @max_line_length_inspection.max_length = val
    end

    def warn_on_debug_calls=(val : Bool)
      @warn_on_debug_calls = val
      @warn_on_debug_inspection.enabled = val
    end

    @sources = Hash(String, String).new
    @source_lines_cache = Hash(String, Array(String)).new
    @last_saved_hashes = Hash(String, UInt64).new
    @temp_files = Hash(String, String).new
    @cache_dir : String?
    @main_file_cache : String?
    @main_file_cache_time : Time?
    @workspace_analyzer : WorkspaceAnalyzer

    # Expose the shared WorkspaceAnalyzer so IndexingManager can hold a reference.
    def workspace_analyzer : WorkspaceAnalyzer
      @workspace_analyzer
    end

    # Completion providers — instantiated once, reused per request.
    @keywords_provider : Lattice::Completion::Providers::KeywordsProvider = Lattice::Completion::Providers::KeywordsProvider.new
    @reference_provider : Lattice::Completion::Providers::ReferenceProvider = Lattice::Completion::Providers::ReferenceProvider.new(Lattice::Index::WorkspaceIndex.new)
    @require_provider : Lattice::Completion::Providers::RequireProvider = Lattice::Completion::Providers::RequireProvider.new
    @struct_literal_provider : Lattice::Completion::Providers::StructLiteralProvider = Lattice::Completion::Providers::StructLiteralProvider.new(Lattice::Index::WorkspaceIndex.new)
    @snippet_provider : Lattice::Completion::Providers::SnippetCompletionProvider = Lattice::Completion::Providers::SnippetCompletionProvider.new

    # Inspection pipeline — run in order on every analyze call when a valid AST is available.
    @max_line_length_inspection : Lattice::Inspections::MaxLineLengthInspection = Lattice::Inspections::MaxLineLengthInspection.new
    @warn_on_debug_inspection : Lattice::Inspections::WarnOnDebugCallsInspection = Lattice::Inspections::WarnOnDebugCallsInspection.new
    @inspections : Array(Lattice::Inspections::Inspection) = [] of Lattice::Inspections::Inspection

    def initialize(@workspace_root : String? = nil)
      @workspace_analyzer = WorkspaceAnalyzer.new(@workspace_root)
      @inspections = build_inspections
      if workspace_root = @workspace_root
        workspace_path = uri_to_filename(workspace_root)
        @cache_dir = cache_dir = File.join(workspace_path, ".lattice-cache")
        Dir.mkdir_p(cache_dir) unless Dir.exists?(cache_dir)
      end
      init_providers
    end

    def workspace_root=(root : String?)
      @workspace_root = root
      @workspace_analyzer = WorkspaceAnalyzer.new(root)
      if root
        workspace_path = uri_to_filename(root)
        @cache_dir = cache_dir = File.join(workspace_path, ".lattice-cache")
        Dir.mkdir_p(cache_dir) unless Dir.exists?(cache_dir)
      end
      init_providers
    end

    def update_source(uri : String, source : String)
      @sources[uri] = source
      @source_lines_cache[uri] = source.split('\n')
      @workspace_analyzer.update_source(uri, source)
      @workspace_analyzer.force_scan
    end

    def remove_source(uri : String)
      @sources.delete(uri)
      @source_lines_cache.delete(uri)
      cleanup_temp_file(uri)
    end

    private def get_lines(uri : String) : Array(String)?
      if lines = @source_lines_cache[uri]?
        return lines
      end

      if source = @sources[uri]?
        lines = source.split('\n')
        @source_lines_cache[uri] = lines
        return lines
      end

      nil
    end

    def analyze(uri : String) : Array(::LSP::Diagnostic)
      diagnostics = [] of ::LSP::Diagnostic

      source = @sources[uri]?
      return diagnostics unless source

      ast = nil
      begin
        parser = ::Crystal::Parser.new(source)
        parser.filename = uri_to_filename(uri)
        ast = parser.parse
      rescue ex : ::Crystal::SyntaxException
        line = ex.line_number - 1
        column = ex.column_number - 1

        range = ::LSP::Range.new(
          ::LSP::Position.new(line, column),
          ::LSP::Position.new(line, column + 1)
        )

        diagnostics << ::LSP::Diagnostic.new(
          range,
          ex.message || "Syntax error",
          ::LSP::DiagnosticSeverity::Error,
          "crystal"
        )
      rescue ex : Exception
        range = ::LSP::Range.new(
          ::LSP::Position.new(0, 0),
          ::LSP::Position.new(0, 1)
        )

        diagnostics << ::LSP::Diagnostic.new(
          range,
          "Parse error: #{ex.message}",
          ::LSP::DiagnosticSeverity::Error,
          "crystal"
        )
      end

      if ast && enable_strict_type_checking?
        type_check_diagnostics = check_argument_types(ast, uri, source)
        diagnostics.concat(type_check_diagnostics)
      end

      # Run the inspection pipeline when we have a valid AST.  Each inspection
      # is independent; a failure in one must not prevent the others from running.
      if ast
        lines = get_lines(uri) || source.split('\n')
        active_inspections = @inspections.reject do |insp|
          (disable_missing_return_warning? && insp.is_a?(Lattice::Inspections::MissingReturnInspection)) ||
          (!enable_ameba_linting? && insp.is_a?(Lattice::Inspections::AmebaInspection)) ||
          (disable_shadowed_variable_warning? && insp.is_a?(Lattice::Inspections::ShadowedVariableInspection)) ||
          (disable_unused_variable_warning? && insp.is_a?(Lattice::Inspections::UnusedVariableInspection)) ||
          (disable_unused_parameter_warning? && insp.is_a?(Lattice::Inspections::UnusedParameterInspection))
        end
        active_inspections.each do |inspection|
          begin
            diagnostics.concat(inspection.run(uri, source, lines, ast))
          rescue ex
            STDERR.puts "[lattice] Inspection #{inspection.class} raised: #{ex.message}"
          end
        end
      end

      diagnostics
    end

    def find_definition(uri : String, position : ::LSP::Position) : ::LSP::Location?
      source = @sources[uri]?
      return unless source
      lines = get_lines(uri)
      return unless lines
      return if position.line >= lines.size

      line_text = lines[position.line]

      if location = handle_require_definition(line_text, position, uri)
        return location
      end

      if location = handle_fun_definition(line_text, position, source, uri)
        return location
      end

      word = extract_qualified_name_at_position(line_text, position.character)

      return unless word

      {% if flag?(:debug) %}
        STDERR.puts "Find definition for: '#{word}' at #{position.line}:#{position.character}"
      {% end %}

      local_result = find_local_variable_or_parameter(source, word, position, uri)
      if local_result[0]
        {% if flag?(:debug) %}
          STDERR.puts "Found local variable or parameter: #{word}"
        {% end %}
        return local_result[0]
      end

      if symbol = @workspace_analyzer.find_symbol_info(word)
        {% if flag?(:debug) %}
          STDERR.puts "Found symbol in workspace: #{symbol.name} (#{symbol.kind}) in #{symbol.file}:#{symbol.line}"
        {% end %}
        def_uri = filename_to_uri(symbol.file)
        range = ::LSP::Range.new(
          ::LSP::Position.new(symbol.line, 0),
          ::LSP::Position.new(symbol.line, word.split("::").last.size)
        )
        return ::LSP::Location.new(def_uri, range)
      end

      # Fallback: search the persistent WorkspaceIndex (covers stdlib symbols
      # and any file indexed but not held by WorkspaceAnalyzer's text map).
      @workspace_analyzer.workspace_index.find_by_name(word.split("::").last).each do |entry|
        next unless File.exists?(entry.file)
        def_uri = filename_to_uri(entry.file)
        # For method symbols the column points at the leading whitespace before
        # "def"; offset past "def " (4 chars) to land on the method name itself.
        # For other kinds, offset past the keyword and its trailing space.
        start_col = case entry.kind
                    when "method"
                      entry.column + 4
                    when "class", "struct", "enum"
                      entry.column + entry.kind.size + 1
                    when "module"
                      entry.column + 7
                    else
                      entry.column
                    end
        range = ::LSP::Range.new(
          ::LSP::Position.new(entry.line, start_col),
          ::LSP::Position.new(entry.line, start_col + word.split("::").last.size)
        )
        return ::LSP::Location.new(def_uri, range)
      end

      if dot_pos = find_dot_in_line(line_text, position.character)
        receiver_word = extract_word_before_dot(line_text, dot_pos)
        if receiver_word
          if lib_symbol = @workspace_analyzer.find_symbol_info(receiver_word)
            if lib_symbol.kind == "lib"
              fun_qualified_name = "#{receiver_word}::#{word}"
              if fun_symbol = @workspace_analyzer.find_symbol_info(fun_qualified_name)
                if fun_symbol.kind == "fun"
                  def_uri = filename_to_uri(fun_symbol.file)
                  range = ::LSP::Range.new(
                    ::LSP::Position.new(fun_symbol.line, 0),
                    ::LSP::Position.new(fun_symbol.line, word.size)
                  )
                  return ::LSP::Location.new(def_uri, range)
                end
              end
            end
          end

          receiver_type = @workspace_analyzer.get_type_at_position(
            uri,
            source,
            ::LSP::Position.new(position.line, dot_pos - 1)
          )
          if receiver_type
            symbol = @workspace_analyzer.find_method_definition(receiver_type, word)
            if symbol
              def_uri = filename_to_uri(symbol.file)
              range = ::LSP::Range.new(
                ::LSP::Position.new(symbol.line, 0),
                ::LSP::Position.new(symbol.line, symbol.name.size)
              )
              return ::LSP::Location.new(def_uri, range)
            end
          end
        end
      end

      if location = find_definition_in_current_file(source, word, uri)
        {% if flag?(:debug) %}
          STDERR.puts "Found definition in current file"
        {% end %}
        return location
      end

      if word.starts_with?("@")
        if symbol = @workspace_analyzer.find_property_definition(word, uri, source)
          def_uri = filename_to_uri(symbol.file)
          range = ::LSP::Range.new(
            ::LSP::Position.new(symbol.line, 0),
            ::LSP::Position.new(symbol.line, symbol.name.size)
          )
          return ::LSP::Location.new(def_uri, range)
        end
      end

      filename = uri_to_filename(uri)
      line_num = position.line + 1
      column_num = position.character + 1

      begin
        output_io = IO::Memory.new
        error_io = IO::Memory.new
        main_file = find_main_file(filename)
        args = ["tool", "implementations", "-c"]

        if source = @sources[uri]
          temp_file = get_temp_file_for_uri(uri, source)
          cursor_loc = "#{temp_file}:#{line_num}:#{column_num}"
          args << cursor_loc
        else
          cursor_loc = "#{filename}:#{line_num}:#{column_num}"
          args << cursor_loc
        end

        args << main_file if main_file

        Process.run("crystal", args,
          output: output_io,
          error: error_io)

        output = output_io.to_s
        error = error_io.to_s

        STDERR.puts "find_definition output: #{output}" unless output.empty?
        STDERR.puts "find_definition error: #{error}" unless error.empty?

        lines = output.split('\n')

        lines.each do |line|
          if match = line.match(/^(.+):(\d+):(\d+)/)
            def_file = match[1]
            def_line = match[2].to_i - 1
            def_col = match[3].to_i - 1

            def_uri = filename_to_uri(def_file)

            range = ::LSP::Range.new(
              ::LSP::Position.new(def_line, def_col),
              ::LSP::Position.new(def_line, def_col + 1)
            )

            return ::LSP::Location.new(def_uri, range)
          end
        end
      rescue ex
        STDERR.puts "Error finding definition: #{ex.message}"
      end

      nil
    end

    def find_references(
      uri : String,
      position : ::LSP::Position,
      include_declaration : Bool = false,
    ) : Array(::LSP::Location)
      source = @sources[uri]?
      return [] of ::LSP::Location unless source

      lines = get_lines(uri)
      return [] of ::LSP::Location unless lines
      return [] of ::LSP::Location if position.line >= lines.size

      line_text = lines[position.line]
      word = extract_qualified_name_at_position(line_text, position.character)
      return [] of ::LSP::Location unless word

      results = [] of ::LSP::Location

      # Walk every indexed file and find all lines that reference *word*
      # as a whole token.  This replaces the old plain-text grep approach
      # with an Index-aware scan that respects word boundaries.
      all_uris = @sources.keys

      # Also pull paths from the workspace index so files not currently open
      # in the editor are included.
      idx = @workspace_analyzer.workspace_index

      # Collect file paths from index entries for symbols matching *word*.
      index_files = idx.find_by_name(word).map(&.file).uniq

      index_files.each do |file_path|
        next if @sources.has_key?(filename_to_uri(file_path))
        begin
          src = File.read(file_path)
          file_uri = filename_to_uri(file_path)
          scan_references(file_uri, src, word, results)
        rescue
        end
      end

      # Scan all open documents.
      @sources.each do |doc_uri, doc_source|
        scan_references(doc_uri, doc_source, word, results)
      end

      # Optionally exclude the declaration site.
      unless include_declaration
        if decl = find_definition(uri, position)
          results.reject! { |loc|
            loc.uri == decl.uri &&
            loc.range.start.line == decl.range.start.line
          }
        end
      end

      results
    end

    # Scan *source* for every whole-token occurrence of *word* and append
    # matching `::LSP::Location` objects to *results*.
    private def scan_references(
      uri : String,
      source : String,
      word : String,
      results : Array(::LSP::Location),
    )
      source.split('\n').each_with_index do |line, line_num|
        offset = 0
        while idx = line.index(word, offset)
          before_ok = idx == 0 || !word_char?(line[idx - 1])
          after_ok  = idx + word.size >= line.size || !word_char?(line[idx + word.size])
          if before_ok && after_ok
            range = ::LSP::Range.new(
              ::LSP::Position.new(line_num, idx),
              ::LSP::Position.new(line_num, idx + word.size)
            )
            results << ::LSP::Location.new(uri, range)
          end
          offset = idx + 1
        end
      end
    end

    def hover(uri : String, position : ::LSP::Position) : ::LSP::Hover?
      source = @sources[uri]?
      return unless source

      lines = get_lines(uri)
      return unless lines
      return if position.line >= lines.size

      line_text = lines[position.line]

      if hover_info = handle_require_hover(line_text, position, uri)
        return hover_info
      end

      if hover_info = handle_fun_hover(line_text, position, source)
        return hover_info
      end

      if hover_info = get_hover_from_workspace(uri, source, position)
        return hover_info
      end

      filename = uri_to_filename(uri)
      line = position.line + 1
      column = position.character + 1

      begin
        output_io = IO::Memory.new
        error_io = IO::Memory.new

        if source = @sources[uri]
          temp_file = get_temp_file_for_uri(uri, source)
          cursor_loc = "#{temp_file}:#{line}:#{column}"
        else
          cursor_loc = "#{filename}:#{line}:#{column}"
        end

        main_file = find_main_file(filename)
        args = ["tool", "context", "-c", cursor_loc]
        args << main_file if main_file

        Process.run("crystal", args, output: output_io, error: error_io)

        output = output_io.to_s

        if !output.empty? && !output.includes?("Error") &&
           !output.includes?("Usage:") &&
           !output.includes?("no context")
          content = "```crystal\n#{output.strip}\n```"
          return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
        end
      rescue ex
        {% if flag?(:debug) %}
          STDERR.puts "Error getting hover info: #{ex.message}"
        {% end %}
      end

      word = extract_word_at_position(line_text, position.character)

      if word && !word.empty?
        content = "**#{word}**\n\n*Type information not available*"
        return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
      end

      nil
    end

    def signature_help(uri : String, position : ::LSP::Position) : ::LSP::SignatureHelp?
      source = @sources[uri]?
      return unless source

      lines = get_lines(uri)
      return unless lines
      return if position.line >= lines.size

      line_text = lines[position.line]
      prefix = line_text[0...position.character]

      # Lib function call: GL.clear(
      if match = prefix.match(/(\w+)\.(\w+)\s*\([^)]*$/)
        lib_name = match[1]
        fun_name = match[2]

        if lib_symbol = @workspace_analyzer.find_symbol_info(lib_name)
          if lib_symbol.kind == "lib"
            fun_qualified_name = "#{lib_name}::#{fun_name}"
            if fun_symbol = @workspace_analyzer.find_symbol_info(fun_qualified_name)
              if fun_symbol.kind == "fun"
                if sig = fun_symbol.signature
                  doc_md = fun_symbol.documentation.try { |d| format_doc_comment(d) }
                  param_labels = extract_param_labels(sig)
                  signature_info = ::LSP::SignatureInformation.new(sig, doc_md, param_labels)

                  paren_start = match.begin(0) + match[0].index!('(')
                  param_index = count_active_parameter(prefix[paren_start..-1])

                  return ::LSP::SignatureHelp.new([signature_info], 0, param_index)
                end
              end
            end
          end
        end
      end

      # Regular method call: foo(
      if match = prefix.match(/(\w+)\s*\([^)]*$/)
        method_name = match[1]

        # Try source-local definition first.
        if sig = find_method_signature(source, method_name)
          param_labels = extract_param_labels(sig)
          signature_info = ::LSP::SignatureInformation.new(sig, nil, param_labels)
          paren_start = match.begin(0) + match[1].size
          param_index = count_active_parameter(prefix[paren_start..-1])
          return ::LSP::SignatureHelp.new([signature_info], 0, param_index)
        end

        # Fall back to workspace index.
        if symbol = @workspace_analyzer.find_symbol_info(method_name)
          if sig = symbol.signature
            doc_md = symbol.documentation.try { |d| format_doc_comment(d) }
            param_labels = extract_param_labels(sig)
            signature_info = ::LSP::SignatureInformation.new(sig, doc_md, param_labels)
            paren_start = match.begin(0) + match[1].size
            param_index = count_active_parameter(prefix[paren_start..-1])
            return ::LSP::SignatureHelp.new([signature_info], 0, param_index)
          end
        end

        # Also try the persistent WorkspaceIndex.
        @workspace_analyzer.workspace_index.find_by_name(method_name).each do |entry|
          next unless entry.kind == "method"
          next unless sig = entry.signature
          doc_md = entry.documentation.try { |d| format_doc_comment(d) }
          param_labels = extract_param_labels(sig)
          signature_info = ::LSP::SignatureInformation.new(sig, doc_md, param_labels)
          paren_start = match.begin(0) + match[1].size
          param_index = count_active_parameter(prefix[paren_start..-1])
          return ::LSP::SignatureHelp.new([signature_info], 0, param_index)
        end
      end

      nil
    end

    def completions(uri : String, position : ::LSP::Position) : Array(::LSP::CompletionItem)
      items = [] of ::LSP::CompletionItem

      source = @sources[uri]?
      return items unless source

      lines = get_lines(uri)
      return items unless lines
      return items if position.line >= lines.size

      line = lines[position.line]
      prefix = line[0...position.character]

      # --- Dot-completion: receiver.method_fragment ---
      if match = prefix.match(/([\w@]+)\.([\w]*)$/)
        receiver = match[1]
        partial_method = match[2]

        receiver_pos = ::LSP::Position.new(
          position.line,
          position.character - partial_method.size - 1
        )

        # Lib function completions.
        if lib_symbol = @workspace_analyzer.find_symbol_info(receiver)
          if lib_symbol.kind == "lib"
            @workspace_analyzer.get_lib_functions(receiver).each do |fun_symbol|
              fun_name = fun_symbol.name.split("::").last
              next unless fun_name.starts_with?(partial_method)
              item = ::LSP::CompletionItem.new(fun_name, ::LSP::CompletionItemKind::Function, fun_symbol.signature || "extern function")
              item.doc = fun_symbol.documentation.as(String) if fun_symbol.documentation
              items << item
            end
            return items
          end
        end

        # Type-resolved member completions via WorkspaceIndex.
        receiver_type = nil
        receiver_type = get_type_via_crystal_tool(uri, source, receiver_pos) if enable_type_aware_completion?
        receiver_type ||= @workspace_analyzer.get_type_at_position(uri, source, receiver_pos)
        receiver_type ||= find_variable_type_in_source(source, receiver, position.line)
        receiver_type ||= infer_type_from_constant_name(receiver)
        receiver_type ||= receiver if receiver[0].uppercase?

        if receiver_type
          # Seed stdlib type members from the built-in table before hitting the
          # workspace index, so modules like JSON and YAML have useful completions
          # even when their source files are not present in the workspace.
          add_stdlib_type_completions(items, receiver_type, partial_method)

          @workspace_analyzer.get_class_methods_and_properties(receiver_type).each do |sym|
            member_name = sym.name.split("::").last.lstrip('@')
            next unless member_name.starts_with?(partial_method)
            kind = case sym.kind
                   when "method"                       then ::LSP::CompletionItemKind::Method
                   when "property", "getter", "setter" then ::LSP::CompletionItemKind::Property
                   else                                     ::LSP::CompletionItemKind::Field
                   end
            item = ::LSP::CompletionItem.new(member_name, kind, sym.signature || sym.type)
            item.doc = sym.documentation.as(String) if sym.documentation
            items << item
          end

          @workspace_analyzer.get_completions_for_receiver(receiver_type).each do |method_name|
            next unless method_name.starts_with?(partial_method)
            next if items.any? { |i| i.label == method_name }
            items << ::LSP::CompletionItem.new(method_name, ::LSP::CompletionItemKind::Method, "#{receiver_type} method")
          end
        end

        add_common_method_completions(items)
        return items
      end
      if match = prefix.match(/([\w:]+)::([\w]*)$/)
        add_namespace_completions(items, match[1], match[2], source)
        return items
      end

      # --- require "..." path completions ---
      if prefix.match(/^\s*require\s+"[^"]*$/)
        items.concat(@require_provider.completions(uri, source, position))
        return items
      end

      # --- Struct / class literal field completions: TypeName{ ---
      if prefix.rstrip.match(/[A-Z]\w*\s*\{\s*$/) || prefix.rstrip.match(/[A-Z]\w*\.new\s*\(\s*$/)
        struct_items = @struct_literal_provider.completions(uri, source, position)
        unless struct_items.empty?
          items.concat(struct_items)
          return items
        end
      end

      # --- General completions: keywords + types + workspace symbols + locals + snippets ---
      items.concat(@keywords_provider.completions(uri, source, position))
      items.concat(@reference_provider.completions(uri, source, position))
      items.concat(@snippet_provider.completions(uri, source, position))

      items.uniq!(&.label)
      items
    end

    # Crystal keywords and built-in types that must not be renamed.
    KEYWORDS = %w[
      abstract alias annotation as as? asm begin break case class def do else
      elsif end ensure enum extend false for fun if include instance_sizeof
      is_a? lib macro module next nil nil? of out pointerof private protected
      require rescue responds_to? return select self sizeof struct super then
      true type typeof uninitialized union unless until verbatim when while
      with yield
    ]

    # Descriptions for commonly required Crystal stdlib modules.
    # Shown on hover when the cursor is over a `require "..."` path.
    STDLIB_MODULE_DESCRIPTIONS = {
      "json"             => "JSON parsing and serialization.\n\n`JSON.parse`, `JSON.build`, and the `JSON::Serializable` mixin for automatic struct/class mapping.",
      "yaml"             => "YAML parsing and serialization.\n\n`YAML.parse`, `YAML.dump`, and the `YAML::Serializable` mixin for automatic struct/class mapping.",
      "http"             => "HTTP client and server utilities.\n\n`HTTP::Client` for outgoing requests; `HTTP::Server` for simple HTTP servers.",
      "http/client"      => "HTTP client.\n\n`HTTP::Client.get`, `.post`, `.put`, `.delete` — make outgoing HTTP requests.",
      "http/server"      => "HTTP server.\n\n`HTTP::Server.new` — listen for incoming HTTP connections and dispatch to handlers.",
      "io"               => "Core IO abstractions: `IO`, `IO::Memory`, `IO::FileDescriptor`, and related types.",
      "file"             => "File system access.\n\n`File.read`, `File.write`, `File.exists?`, `File.open`, `File.join`.",
      "dir"              => "Directory access.\n\n`Dir.mkdir`, `Dir.mkdir_p`, `Dir.each_child`, `Dir.glob`.",
      "path"             => "Cross-platform path manipulation.\n\n`Path.new`, `.join`, `.dirname`, `.basename`, `.extension`.",
      "uri"              => "URI parsing and building.\n\n`URI.parse`, `URI.encode`, `URI.decode`.",
      "regex"            => "Regular expressions.\n\n`Regex.new`, `String#match`, `String#scan`.",
      "time"             => "Date and time utilities.\n\n`Time.utc`, `Time.local`, `Time.now`, `Time::Span`, `Time::Location`.",
      "random"           => "Random number generation.\n\n`Random.new`, `Random::DEFAULT`, `Array#sample`, `Array#shuffle`.",
      "math"             => "Mathematical functions.\n\n`Math.sqrt`, `Math.log`, `Math.sin`, `Math::PI`, `Math::E`.",
      "big"              => "Arbitrary-precision numeric types: `BigInt`, `BigFloat`, `BigDecimal`, `BigRational`.",
      "big/int"          => "Arbitrary-precision integer.\n\n`BigInt.new(string)`, `BigInt.new(int)`, arithmetic operators.",
      "big/float"        => "Arbitrary-precision floating-point.\n\n`BigFloat.new(string)`, `BigFloat.new(float)`.",
      "big/decimal"      => "Arbitrary-precision decimal.\n\n`BigDecimal.new(string)`, suitable for financial arithmetic.",
      "colorize"         => "Terminal color and style output.\n\n`\"text\".colorize(:red)`, `.on(:blue)`, `.bold`, `.underline`.",
      "benchmark"        => "Code performance benchmarking.\n\n`Benchmark.ips { |x| x.report(\"label\") { code } }`, `Benchmark.measure { ... }`.",
      "spec"             => "Crystal's built-in testing framework.\n\n`describe`, `it`, `expect_raises`, `should`, `should eq`.",
      "log"              => "Structured logging.\n\n`Log.info { \"message\" }`, `Log.setup(:debug)`, `Log::IOBackend`.",
      "digest"           => "Cryptographic digest algorithms: `Digest::MD5`, `Digest::SHA1`, `Digest::SHA256`, `Digest::SHA512`.",
      "base64"           => "Base64 encoding and decoding.\n\n`Base64.encode`, `Base64.decode`, `Base64.urlsafe_encode`.",
      "csv"              => "CSV parsing and building.\n\n`CSV.new(io)`, `CSV.build { |csv| csv.row \"a\", \"b\" }`, `CSV.each_row`.",
      "ini"              => "INI file parsing.\n\n`INI.parse(string)` returns a `Hash(String, Hash(String, String))`.",
      "xml"              => "XML parsing.\n\n`XML.parse(string)`, `XML::Node`, `XML::Builder`.",
      "html"             => "HTML escaping utilities.\n\n`HTML.escape(string)`, `HTML.unescape(string)`.",
      "ecr"              => "Embedded Crystal (ECR) templating.\n\n`ECR.render(filename)`, `ECR.def_to_s(filename)`.",
      "process"          => "OS process management.\n\n`Process.run`, `Process.new`, `Process.find_executable`.",
      "socket"           => "TCP/UDP/Unix socket support.\n\n`TCPSocket`, `UDPSocket`, `UNIXSocket`, `TCPServer`.",
      "openssl"          => "OpenSSL bindings: TLS/SSL contexts, HMAC, ciphers.\n\n`OpenSSL::SSL::Context`, `OpenSSL::HMAC.digest`.",
      "mutex"            => "Thread synchronization primitive.\n\n`Mutex.new`, `Mutex#synchronize { ... }`.",
      "channel"          => "Fiber communication channels (Go-style).\n\n`Channel(T).new`, `Channel#send`, `Channel#receive`, `select`.",
      "fiber"            => "Green thread (fiber) utilities.\n\n`Fiber.new { ... }`, `Fiber.yield`, `spawn { ... }`.",
      "flate"            => "DEFLATE compression.\n\n`Compress::Deflate::Writer`, `Compress::Deflate::Reader`.",
      "gzip"             => "Gzip compression.\n\n`Compress::Gzip::Writer`, `Compress::Gzip::Reader`.",
      "zlib"             => "Zlib compression.\n\n`Compress::Zlib::Writer`, `Compress::Zlib::Reader`.",
      "zip"              => "ZIP archive support.\n\n`Compress::Zip::Writer`, `Compress::Zip::Reader`.",
      "tempfile"         => "Temporary file creation.\n\n`File.tempname`, `File.tempfile`.",
      "enumerable"       => "The `Enumerable` mixin: `map`, `select`, `reduce`, `each_with_object`, and more.",
      "comparable"       => "The `Comparable` mixin: `<=>`, `<`, `>`, `<=`, `>=`, `between?`, `clamp`.",
      "iterable"         => "The `Iterable` mixin: lazy `each` and related collection helpers.",
      "weak_ref"         => "Weak reference wrapper.\n\n`WeakRef(T).new(object)` — holds a reference that does not prevent GC collection.",
      "gc"               => "Garbage collector control.\n\n`GC.collect`, `GC.stats`, `GC.disable`, `GC.enable`.",
      "system"           => "OS-level system queries.\n\n`System.hostname`, `System.cpu_count`.",
      "signal"           => "OS signal handling.\n\n`Signal::INT.trap { ... }`, `Signal::TERM.trap { ... }`.",
      "atomic"           => "Lock-free atomic operations.\n\n`Atomic(T).new(value)`, `.add`, `.compare_and_set`.",
      "intrinsics"       => "Low-level LLVM intrinsics (bswap, popcount, ctz, etc.).",
      "semantic_version" => "Semantic version parsing and comparison.\n\n`SemanticVersion.parse(\"1.2.3\")`, comparison operators.",
      "uuid"             => "UUID generation and parsing.\n\n`UUID.new`, `UUID.random`, `UUID#to_s`.",
      "db"               => "Generic database interface (crystal-db).\n\n`DB.open(uri)`, `DB::Database#query`, `DB::Database#exec`.",
    } of String => String

    BUILTIN_TYPES = %w[
      String Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64
      Float32 Float64 Bool Char Nil Void Symbol Pointer Proc
      Array Hash Set Tuple NamedTuple Range Regex Time IO Path
      Exception JSON YAML File Dir Process Channel Slice
    ]

    STDLIB_NAMESPACE_CONSTANTS = {
      "Math" => {
        "PI"        => "Float64 — ratio of a circle's circumference to its diameter (~3.14159)",
        "E"         => "Float64 — base of the natural logarithm (~2.71828)",
        "LOG2"      => "Float64 — natural logarithm of 2 (~0.69315)",
        "LOG10"     => "Float64 — natural logarithm of 10 (~2.30259)",
        "SQRT2"     => "Float64 — square root of 2 (~1.41421)",
        "INF"       => "Float64 — positive infinity",
        "NAN"       => "Float64 — not-a-number",
      },
      "Int8"    => {"MAX" => "Int8 maximum value (127)",           "MIN" => "Int8 minimum value (-128)"},
      "Int16"   => {"MAX" => "Int16 maximum value (32767)",        "MIN" => "Int16 minimum value (-32768)"},
      "Int32"   => {"MAX" => "Int32 maximum value (2147483647)",   "MIN" => "Int32 minimum value (-2147483648)"},
      "Int64"   => {"MAX" => "Int64 maximum value",                "MIN" => "Int64 minimum value"},
      "Int128"  => {"MAX" => "Int128 maximum value",               "MIN" => "Int128 minimum value"},
      "UInt8"   => {"MAX" => "UInt8 maximum value (255)",          "MIN" => "UInt8 minimum value (0)"},
      "UInt16"  => {"MAX" => "UInt16 maximum value (65535)",       "MIN" => "UInt16 minimum value (0)"},
      "UInt32"  => {"MAX" => "UInt32 maximum value (4294967295)",  "MIN" => "UInt32 minimum value (0)"},
      "UInt64"  => {"MAX" => "UInt64 maximum value",               "MIN" => "UInt64 minimum value (0)"},
      "UInt128" => {"MAX" => "UInt128 maximum value",              "MIN" => "UInt128 minimum value (0)"},
      "Float32" => {"INFINITY" => "Float32 positive infinity",     "NAN" => "Float32 NaN", "MAX" => "Float32 maximum finite value", "MIN" => "Float32 minimum positive normal value", "EPSILON" => "Float32 machine epsilon"},
      "Float64" => {"INFINITY" => "Float64 positive infinity",     "NAN" => "Float64 NaN", "MAX" => "Float64 maximum finite value", "MIN" => "Float64 minimum positive normal value", "EPSILON" => "Float64 machine epsilon"},
      "GC"      => {"VERSION" => "String — version of the garbage collector"},
      "Crystal" => {
        "VERSION"      => "String — current Crystal compiler version",
        "BUILD_COMMIT" => "String — Git commit hash of the compiler build",
        "BUILD_DATE"   => "String — ISO 8601 date of the compiler build",
        "CACHE_DIR"    => "String — path to the Crystal compiler's cache directory",
        "PATH"         => "String — colon-separated list of Crystal library search paths",
      },
      "IO" => {
        "DEFAULT_BUFFER_SIZE" => "Int32 — default IO buffer size in bytes (8192)",
      },
      "File" => {
        "SEPARATOR"     => "Char — OS path separator ('\\' on Windows, '/' elsewhere)",
        "PATH_SEPARATOR" => "Char — OS PATH env var separator (';' on Windows, ':' elsewhere)",
        "NULL"          => "String — the null device path ('/dev/null' or 'NUL')",
      },
      "Process" => {
        "INITIAL_PWD" => "String — working directory when the process was started",
      },
      "Regex" => {
        "ANCHORED"  => "Regex::Options — anchor match to start of string",
        "EXTENDED"  => "Regex::Options — allow comments and whitespace in pattern",
        "MULTILINE" => "Regex::Options — '.' matches newlines too",
      },
      "Time" => {
        "UNIX_EPOCH" => "Time — the Unix epoch (1970-01-01T00:00:00Z)",
      },
      "Colorize" => {
        "FORCE_ENABLED"  => "Bool — force colorized output even when stdout is not a TTY",
        "FORCE_DISABLED" => "Bool — force-disable all colorized output",
      },
      "Digest" => {
        "MD5"    => "Digest::MD5 class",
        "SHA1"   => "Digest::SHA1 class",
        "SHA256" => "Digest::SHA256 class",
        "SHA512" => "Digest::SHA512 class",
      },
      "HTTP" => {
        "DEFAULT_STATUS_MESSAGE" => "Hash(Int32, String) — standard HTTP status reason phrases",
      },
      "Base64" => {
        "CHARS"     => "String — the 64 base64 alphabet characters",
        "PAD_CHAR"  => "Char — padding character ('=')",
      },
      "SemanticVersion" => {
        "REGEX" => "Regex — pattern that matches a valid semver string",
      },
      "Random" => {
        "DEFAULT" => "Random — the default random number generator instance",
      },
      "Log" => {
        "DEFAULT_BACKEND"  => "Log::IOBackend — the default log backend (writes to STDERR)",
        "PROGNAME"         => "String — the default program name used in log messages",
      },
      "Benchmark" => {
        "VERSION" => "String — Benchmark library version",
      },
      "Pointer" => {
        "NULL" => "Pointer(Void) — a null pointer constant",
      },
      "Slice" => {
        "EMPTY" => "Slice(UInt8) — an empty byte slice",
      },
      "Channel" => {
        "CLOSED_EXCEPTION" => "Channel::ClosedError — raised when sending on a closed channel",
      },
    } of String => Hash(String, String)

    def prepare_rename(uri : String, position : ::LSP::Position) : ::LSP::Range?
      source = @sources[uri]?
      return unless source

      lines = get_lines(uri)
      return unless lines
      return if position.line >= lines.size

      line_text = lines[position.line]
      word = extract_word_at_position(line_text, position.character)
      return unless word
      return if word.empty?

      # Reject keyword and built-in rename targets.
      return if KEYWORDS.includes?(word)
      return if BUILTIN_TYPES.includes?(word)

      char = position.character
      start_pos = char
      while start_pos > 0 && word_char?(line_text[start_pos - 1])
        start_pos -= 1
      end
      end_pos = char
      while end_pos < line_text.size && word_char?(line_text[end_pos])
        end_pos += 1
      end
      return if start_pos == end_pos

      ::LSP::Range.new(
        ::LSP::Position.new(position.line, start_pos),
        ::LSP::Position.new(position.line, end_pos)
      )
    end

    def rename(
      uri : String,
      position : ::LSP::Position,
      new_name : String,
    ) : ::LSP::WorkspaceEdit?
      source = @sources[uri]?
      return unless source

      lines = get_lines(uri)
      return unless lines
      return if position.line >= lines.size

      current_line = lines[position.line]
      char = position.character
      return if char < 0 || char > current_line.size

      start_pos = char
      while start_pos > 0 && word_char?(current_line[start_pos - 1])
        start_pos -= 1
      end
      # Include a leading @ (ivar) or @@ (class var) sigil so that old_name
      # matches the token that find_references actually locates.
      if start_pos > 0 && current_line[start_pos - 1] == '@'
        start_pos -= 1
        if start_pos > 0 && current_line[start_pos - 1] == '@'
          start_pos -= 1
        end
      end

      end_pos = start_pos
      # Skip past the sigil(s) that start_pos may now point at.
      while end_pos < current_line.size && current_line[end_pos] == '@'
        end_pos += 1
      end
      while end_pos < current_line.size && word_char?(current_line[end_pos])
        end_pos += 1
      end

      return if start_pos == end_pos

      old_name = current_line[start_pos...end_pos]

      # Extract the sigil prefix (@@ or @) from the resolved token so it can
      # be re-attached to new_name when the user omitted it (e.g. they were
      # pre-filled with "last_ping" and typed "slapme" — we must write "@slapme").
      sigil = if old_name.starts_with?("@@")
                "@@"
              elsif old_name.starts_with?("@")
                "@"
              else
                ""
              end

      # If new_name already carries the correct sigil, use it as-is; otherwise
      # prepend the sigil so the token type is preserved.
      effective_new_name = if !sigil.empty? && !new_name.starts_with?(sigil)
                             sigil + new_name
                           else
                             new_name
                           end

      # Use the Index-backed reference graph to collect all sites across the
      # workspace, then build a `WorkspaceEdit` with per-file `TextEdit` arrays.
      all_refs = find_references(uri, position, include_declaration: true)

      changes = Hash(String, Array(::LSP::TextEdit)).new

      all_refs.each do |loc|
        edits = changes[loc.uri] ||= [] of ::LSP::TextEdit
        edit_range = ::LSP::Range.new(
          ::LSP::Position.new(loc.range.start.line, loc.range.start.character),
          ::LSP::Position.new(loc.range.start.line, loc.range.start.character + old_name.size)
        )
        edits << ::LSP::TextEdit.new(edit_range, effective_new_name)
      end

      return if changes.empty?

      ::LSP::WorkspaceEdit.new(changes)
    end

    private def word_char?(char : Char) : Bool
      char.alphanumeric? || char == '_' || char == '?' || char == '!'
    end

    private def add_keyword_completions(items : Array(::LSP::CompletionItem))
      keywords = [
        "abstract", "alias", "annotation", "as", "as?", "asm", "begin", "break",
        "case", "class", "def", "do", "else", "elsif", "end", "ensure", "enum",
        "extend", "false", "for", "fun", "if", "include", "instance_sizeof",
        "is_a?", "lib", "macro", "module", "next", "nil", "nil?", "of", "out",
        "pointerof", "private", "protected", "require", "rescue", "responds_to?",
        "return", "select", "self", "sizeof", "struct", "super", "then", "true",
        "type", "typeof", "uninitialized", "union", "unless", "until", "verbatim",
        "when", "while", "with", "yield",
      ]

      keywords.each do |keyword|
        items << ::LSP::CompletionItem.new(
          keyword,
          ::LSP::CompletionItemKind::Keyword,
          "Crystal keyword"
        )
      end
    end

    private def add_type_completions(items : Array(::LSP::CompletionItem))
      types = [
        "String", "Int32", "Int64", "Float64", "Bool", "Array", "Hash",
        "Nil", "Symbol", "Char", "Tuple", "NamedTuple", "Range", "Regex",
        "Time", "JSON", "YAML", "File", "Dir", "Process", "Channel",
        "Exception", "IO", "Path", "Set", "Slice", "Pointer", "Proc",
      ]

      types.each do |type|
        items << ::LSP::CompletionItem.new(
          type,
          ::LSP::CompletionItemKind::Class,
          "Crystal type"
        )
      end
    end

    private def uri_to_filename(uri : String) : String
      return uri unless uri.starts_with?("file://")

      filename = uri.sub(/^file:\/\//, "")
      filename = URI.decode(filename)

      if filename =~ /^\/([a-zA-Z]):/
        filename = filename[1..]
      end

      filename.gsub('/', File::SEPARATOR)
    end

    # Get or create a temp file for the given URI
    private def get_temp_file_for_uri(uri : String, source : String) : String
      filename = uri_to_filename(uri)
      source_hash = source.hash

      if temp_file = @temp_files[uri]?
        if @last_saved_hashes[uri]? == source_hash && File.exists?(temp_file)
          return temp_file
        else
          File.delete(temp_file) if File.exists?(temp_file)
        end
      end

      ext = File.extname(filename)
      basename = File.basename(filename, ext)
      temp_file = File.tempname("liger-#{basename}-", ext)

      File.write(temp_file, source)
      @temp_files[uri] = temp_file
      @last_saved_hashes[uri] = source_hash

      temp_file
    end

    # Clean up temp file for a URI
    private def cleanup_temp_file(uri : String)
      if temp_file = @temp_files.delete(uri)
        File.delete(temp_file) if File.exists?(temp_file)
      end
      @last_saved_hashes.delete(uri)
    end

    private def filename_to_uri(filename : String) : String
      path = filename.gsub(File::SEPARATOR, '/')

      if path =~ /^([a-zA-Z]):/
        drive = path[0].to_s
        rest = path[2..]
        path = "#{drive}%3A#{rest}"
      end

      path = path.lstrip('/')
      "file:///#{path}"
    end

    private def find_main_file(current_file : String) : String?
      return unless workspace_root = @workspace_root

      if (main_file_cache = @main_file_cache) && (cache_time = @main_file_cache_time)
        if (Time.utc - cache_time).total_seconds < 5
          return main_file_cache
        end
      end

      workspace_path = uri_to_filename(workspace_root)
      shard_yml = File.join(workspace_path, "shard.yml")
      result : String? = nil

      if File.exists?(shard_yml)
        begin
          yaml = YAML.parse(File.read(shard_yml))

          if targets = yaml["targets"]?
            targets.as_h.each do |name, config|
              if main_path = config["main"]?
                normalized_main = main_path.as_s.gsub('/', '\\')
                main_file = File.join(workspace_path, normalized_main)
                if File.exists?(main_file)
                  result = main_file
                  break
                else
                  STDERR.puts " Main file does not exist: #{main_file}"
                end
              end
            end
          else
            STDERR.puts "No targets section found in shard.yml"
          end
        rescue ex
          STDERR.puts "Error parsing shard.yml: #{ex.message}"
        end
      else
        STDERR.puts "shard.yml not found"
      end

      unless result
        STDERR.puts "Trying fallback candidates..."
        candidates = [
          File.join(workspace_path, "src", File.basename(workspace_path) + ".cr"),
          File.join(workspace_path, "src", "main.cr"),
          File.join(workspace_path, "main.cr"),
        ]

        candidates.each do |candidate|
          STDERR.puts "  Checking: #{candidate}"
          if File.exists?(candidate)
            STDERR.puts "  Found: #{candidate}"
            result = candidate
            break
          end
        end
      end

      @main_file_cache = result
      @main_file_cache_time = Time.utc

      STDERR.puts result ? "Main file: #{result}" : "No main file found"
      result
    rescue ex
      STDERR.puts "Exception in find_main_file: #{ex.message}"
      nil
    end

    private def extract_word_at_position(line : String, char : Int32) : String?
      return if char < 0 || char > line.size
      start_pos = char

      if char > 0 && line[char - 1] == '@'
        start_pos = char - 1
      elsif char < line.size && line[char] == '@'
        start_pos = char
      else
        while start_pos > 0 && word_char?(line[start_pos - 1])
          start_pos -= 1
        end
        if start_pos > 0 && line[start_pos - 1] == '@'
          start_pos -= 1
        end
      end

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

      return if start_pos == end_pos
      word = line[start_pos...end_pos]

      if word.ends_with?('?') || word.ends_with?('!')
        word = word[0...-1]
      end

      return if word.empty?
      word
    end

    private def extract_qualified_name_at_position(line : String, char : Int32) : String?
      return if char < 0 || char > line.size

      start_pos = char
      while start_pos > 0
        prev_char = line[start_pos - 1]
        if word_char?(prev_char)
          start_pos -= 1
        elsif prev_char == ':' && start_pos >= 2 && line[start_pos - 2] == ':'
          start_pos -= 2
        else
          break
        end
      end

      if start_pos > 0 && line[start_pos - 1] == '@'
        start_pos -= 1
        if start_pos > 0 && line[start_pos - 1] == '@'
          start_pos -= 1
        end
      end

      end_pos = char
      while end_pos < line.size
        curr_char = line[end_pos]
        if word_char?(curr_char)
          end_pos += 1
        elsif curr_char == ':' && end_pos + 1 < line.size && line[end_pos + 1] == ':'
          end_pos += 2
        else
          break
        end
      end

      return if start_pos == end_pos
      word = line[start_pos...end_pos]

      if word.ends_with?('?') || word.ends_with?('!')
        word = word[0...-1]
      end

      return if word.empty?
      word
    end

    # Handle goto definition for require statements
    private def handle_require_definition(
      line : String,
      position : ::LSP::Position,
      uri : String,
    ) : ::LSP::Location?
      return unless line.strip.starts_with?("require")

      if match = line.match(/require\s+["']([^"']+)["']/)
        require_path = match[1]

        quote_start = match.begin(1)
        quote_end = match.end(1)
        return if position.character < quote_start || position.character > quote_end

        STDERR.puts "Found require statement for: #{require_path}"

        if location = resolve_require_path(require_path, uri)
          return location
        end
      end

      nil
    end

    # Resolve require path to actual file location
    private def resolve_require_path(require_path : String, uri : String) : ::LSP::Location?
      current_file = uri_to_filename(uri)
      workspace_path = if workspace_root = @workspace_root
                         uri_to_filename(workspace_root)
                       else
                         File.dirname(current_file)
                       end

      if require_path.starts_with?("./") || require_path.starts_with?("../")
        base_dir = File.dirname(current_file)
        resolved_path = File.expand_path(require_path + ".cr", base_dir)

        if File.exists?(resolved_path)
          return create_location_for_file(resolved_path)
        end
      end

      lib_path = File.join(workspace_path, "lib", require_path, "src", "#{require_path}.cr")
      if File.exists?(lib_path)
        STDERR.puts "Found shard file: #{lib_path}"
        return create_location_for_file(lib_path)
      end

      lib_base = File.join(workspace_path, "lib", require_path)
      if Dir.exists?(lib_base)
        alt_path = File.join(lib_base, "src", "#{File.basename(require_path)}.cr")
        if File.exists?(alt_path)
          return create_location_for_file(alt_path)
        end

        alt_path = File.join(lib_base, "src", "lib.cr")
        if File.exists?(alt_path)
          return create_location_for_file(alt_path)
        end

        alt_path = File.join(lib_base, "#{File.basename(require_path)}.cr")
        if File.exists?(alt_path)
          return create_location_for_file(alt_path)
        end
      end

      nil
    end

    # Create a location pointing to the start of a file
    private def create_location_for_file(file_path : String) : ::LSP::Location
      file_uri = filename_to_uri(file_path)
      range = ::LSP::Range.new(
        ::LSP::Position.new(0, 0),
        ::LSP::Position.new(0, 0)
      )
      ::LSP::Location.new(file_uri, range)
    end

    # Handle goto definition for fun (extern function) statements
    private def handle_fun_definition(
      line : String,
      position : ::LSP::Position,
      source : String,
      uri : String,
    ) : ::LSP::Location?
      return unless line.strip.starts_with?("fun") || line.includes?(" fun ")

      if match = line.match(/fun\s+(\w+)/)
        fun_name = match[1]
        fun_start = match.begin(1)
        fun_end = match.end(1)

        return if position.character < fun_start || position.character > fun_end

        range = ::LSP::Range.new(
          ::LSP::Position.new(position.line, fun_start),
          ::LSP::Position.new(position.line, fun_end)
        )

        return ::LSP::Location.new(uri, range)
      end

      nil
    end

    # Handle hover for require statements
    private def handle_require_hover(
      line : String,
      position : ::LSP::Position,
      uri : String,
    ) : ::LSP::Hover?
      return unless line.strip.starts_with?("require")

      if match = line.match(/require\s+["']([^"']+)["']/)
        require_path = match[1]
        quote_start = match.begin(1)
        quote_end = match.end(1)

        return if position.character < quote_start || position.character > quote_end

        # If there is a known stdlib description, lead with it.
        stdlib_desc = STDLIB_MODULE_DESCRIPTIONS[require_path]?

        if location = resolve_require_path(require_path, uri)
          resolved_file = uri_to_filename(location.uri)
          content = "**`require \"#{require_path}\"`**\n\n"
          content += "#{stdlib_desc}\n\n" if stdlib_desc
          content += "Resolves to: `#{resolved_file}`\n\n"
          content += "```crystal\nrequire \"#{require_path}\"\n```"
          return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
        else
          content = "**`require \"#{require_path}\"`**\n\n"
          if stdlib_desc
            content += "#{stdlib_desc}\n\n"
          end

          workspace_path = if workspace_root = @workspace_root
                             uri_to_filename(workspace_root)
                           else
                             File.dirname(uri_to_filename(uri))
                           end

          unless stdlib_desc
            if require_path.starts_with?("./") || require_path.starts_with?("../")
              content += "Type: Relative require\n\n"
            else
              lib_path = File.join(workspace_path, "lib", require_path)
              if Dir.exists?(lib_path)
                content += "Type: Shard dependency\n\n"
                content += "Location: `#{lib_path}`\n\n"
              else
                content += "Type: Standard library module\n\n"
              end
            end
          end

          content += "```crystal\nrequire \"#{require_path}\"\n```"
          return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
        end
      end

      nil
    end

    # Handle hover for fun (extern) statements
    private def handle_fun_hover(
      line : String,
      position : ::LSP::Position,
      source : String,
    ) : ::LSP::Hover?
      return unless line.strip.starts_with?("fun") || line.includes?(" fun ")

      if match = line.match(/fun\s+(\w+)(?:\s*=\s*(\w+))?\s*(\([^)]*\))?\s*(?::\s*(.+))?/)
        fun_name = match[1]
        fun_start = match.begin(1)
        fun_end = match.end(1)

        return if position.character < fun_start || position.character > fun_end + 10

        actual_name = match[2]?
        args = match[3]? || "()"
        return_type = match[4]? || "Void"

        content = "**Extern Function Declaration**\n\n"
        content += "```crystal\n"

        if actual_name
          content += "fun #{fun_name} = #{actual_name}#{args}"
        else
          content += "fun #{fun_name}#{args}"
        end

        unless return_type.strip.empty?
          content += " : #{return_type.strip}"
        end

        content += "\n```\n\n"

        if actual_name
          content += "Crystal name: `#{fun_name}`\n\n"
          content += "C name:       `#{actual_name}`\n\n"
        else
          content += "C name:       `#{fun_name}`\n\n"
        end

        content += "*External function binding for C library*"

        return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
      end

      nil
    end

    private def add_type_aware_completions(
      items : Array(::LSP::CompletionItem),
      context_output : String,
    )
      if match = context_output.match(/(\w+)#(\w+)/)
        type_name = match[1]
      end
    end

    private def add_type_specific_completions(
      items : Array(::LSP::CompletionItem),
      type_name : String,
      partial : String,
    )
      case type_name
      when "String"
        string_methods = [
          {"size", "String length", ::LSP::CompletionItemKind::Method},
          {"empty?", "Check if empty", ::LSP::CompletionItemKind::Method},
          {"upcase", "Convert to uppercase", ::LSP::CompletionItemKind::Method},
          {"downcase", "Convert to lowercase", ::LSP::CompletionItemKind::Method},
          {"strip", "Remove whitespace", ::LSP::CompletionItemKind::Method},
          {"split", "Split into array", ::LSP::CompletionItemKind::Method},
          {"starts_with?", "Check prefix", ::LSP::CompletionItemKind::Method},
          {"ends_with?", "Check suffix", ::LSP::CompletionItemKind::Method},
          {"includes?", "Check substring", ::LSP::CompletionItemKind::Method},
          {"chars", "Get array of characters", ::LSP::CompletionItemKind::Method},
          {"gsub", "Global substitution", ::LSP::CompletionItemKind::Method},
          {"match", "Regex match", ::LSP::CompletionItemKind::Method},
        ]
        string_methods.each do |name, detail, kind|
          if name.starts_with?(partial)
            items << ::LSP::CompletionItem.new(name, kind, detail)
          end
        end
      when "Array"
        array_methods = [
          {"each", "Iterate over elements", ::LSP::CompletionItemKind::Method},
          {"map", "Transform elements", ::LSP::CompletionItemKind::Method},
          {"select", "Filter elements", ::LSP::CompletionItemKind::Method},
          {"reject", "Reject elements", ::LSP::CompletionItemKind::Method},
          {"first", "Get first element", ::LSP::CompletionItemKind::Method},
          {"last", "Get last element", ::LSP::CompletionItemKind::Method},
          {"push", "Add element", ::LSP::CompletionItemKind::Method},
          {"pop", "Remove last element", ::LSP::CompletionItemKind::Method},
          {"sort", "Sort elements", ::LSP::CompletionItemKind::Method},
          {"size", "Array size", ::LSP::CompletionItemKind::Method},
          {"empty?", "Check if empty", ::LSP::CompletionItemKind::Method},
        ]
        array_methods.each do |name, detail, kind|
          if name.starts_with?(partial)
            items << ::LSP::CompletionItem.new(name, kind, detail)
          end
        end
      when "Hash"
        hash_methods = [
          {"each", "Iterate over key-value pairs", ::LSP::CompletionItemKind::Method},
          {"keys", "Get all keys", ::LSP::CompletionItemKind::Method},
          {"values", "Get all values", ::LSP::CompletionItemKind::Method},
          {"has_key?", "Check if key exists", ::LSP::CompletionItemKind::Method},
          {"size", "Hash size", ::LSP::CompletionItemKind::Method},
          {"empty?", "Check if empty", ::LSP::CompletionItemKind::Method},
        ]
        hash_methods.each do |name, detail, kind|
          if name.starts_with?(partial)
            items << ::LSP::CompletionItem.new(name, kind, detail)
          end
        end
      end
    end

    private def add_namespace_completions(
      items : Array(::LSP::CompletionItem),
      namespace : String,
      partial : String,
      source : String,
    )
      # Seed well-known stdlib namespace constants so they appear without
      # requiring the stdlib files to be present in the workspace index.
      STDLIB_NAMESPACE_CONSTANTS[namespace]?.try &.each do |name, detail|
        if name.starts_with?(partial)
          items << ::LSP::CompletionItem.new(name, ::LSP::CompletionItemKind::Constant, detail)
        end
      end

      symbols = @workspace_analyzer.find_symbols_in_namespace(namespace)

      symbols.each do |symbol|
        symbol_short_name = symbol.name.sub(/^.*::/, "")
        if symbol_short_name.starts_with?(partial)
          kind = case symbol.kind
                 when "class"
                   ::LSP::CompletionItemKind::Class
                 when "module"
                   ::LSP::CompletionItemKind::Module
                 when "method"
                   ::LSP::CompletionItemKind::Method
                 when "constant"
                   ::LSP::CompletionItemKind::Constant
                 when "struct"
                   ::LSP::CompletionItemKind::Struct
                 when "enum"
                   ::LSP::CompletionItemKind::Enum
                 else
                   ::LSP::CompletionItemKind::Variable
                 end

          items << ::LSP::CompletionItem.new(
            symbol_short_name,
            kind,
            "#{namespace}::#{symbol_short_name}"
          )
        end
      end

      lines = get_lines("")
      if lines.nil?
        lines = source.split('\n')
      end

      in_namespace = false
      nesting_level = 0

      lines.each do |line|
        if line.match(/^\s*(module|class)\s+#{Regex.escape(namespace)}\b/)
          in_namespace = true
          nesting_level = 0
          next
        end

        if in_namespace
          nesting_level += 1 if line =~ /^\s*(module|class|def|if|case|while|until|begin)\b/
          nesting_level -= 1 if line =~ /^\s*end\b/

          if nesting_level < 0
            in_namespace = false
            next
          end

          if match = line.match(/^\s*(def|class|module|struct|enum)\s+(\w+)/)
            symbol_name = match[2]
            if symbol_name.starts_with?(partial)
              kind = case match[1]
                     when "class"
                       ::LSP::CompletionItemKind::Class
                     when "module"
                       ::LSP::CompletionItemKind::Module
                     when "def"
                       ::LSP::CompletionItemKind::Method
                     when "struct"
                       ::LSP::CompletionItemKind::Struct
                     when "enum"
                       ::LSP::CompletionItemKind::Enum
                     else
                       ::LSP::CompletionItemKind::Variable
                     end

              items << ::LSP::CompletionItem.new(
                symbol_name,
                kind,
                "#{namespace}::#{symbol_name}"
              )
            end
          end

          if match = line.match(/^\s*([A-Z][A-Za-z_0-9]*)\s*=/)
            const_name = match[1]
            if const_name.starts_with?(partial)
              items << ::LSP::CompletionItem.new(
                const_name,
                ::LSP::CompletionItemKind::Constant,
                "#{namespace}::#{const_name}"
              )
            end
          end
        end
      end
    end

    private def find_method_signature(source : String, method_name : String) : String?
      lines = @source_lines_cache.values.first? || source.split('\n')

      lines.each do |line|
        if match = line.match(/^\s*def\s+#{Regex.escape(method_name)}\s*(\([^)]*\))?(?:\s*:\s*(\w+))?/)
          params = match[1]? || "()"
          return_type = match[2]? || ""

          signature = "def #{method_name}#{params}"
          signature += " : #{return_type}" unless return_type.empty?

          return signature
        end
      end

      nil
    end

    private def find_variable_type_in_source(
      source : String,
      var_name : String,
      current_line : Int32,
    ) : String?
      lines = @source_lines_cache.values.first? || source.split('\n')

      if var_name.starts_with?("@")
        lines.each do |line|
          if match = line.match(/^\s*#{Regex.escape(var_name)}\s*:\s*(\w+(?:::\w+)?)/)
            return match[1]
          end
          clean_name = var_name.sub(/^@/, "")
          if match = line.match(/^\s*(?:property|getter|setter)\s+#{Regex.escape(clean_name)}\s*:\s*(\w+(?:::\w+)?)/)
            return match[1]
          end
        end

        lines.each do |line|
          if match = line.match(/#{Regex.escape(var_name)}\s*=\s*(.+)/)
            assignment = match[1].strip
            return infer_type_from_assignment(assignment)
          end
        end
      end

      if var_name[0].uppercase?
        lines.each do |line|
          if match = line.match(/^\s*#{Regex.escape(var_name)}\s*:\s*(\w+(?:::\w+)?)\s*=/)
            return match[1]
          end
          if match = line.match(/^\s*#{Regex.escape(var_name)}\s*=\s*(.+)/)
            assignment = match[1].strip
            return infer_type_from_assignment(assignment)
          end
        end
      end

      (0...current_line).reverse_each do |line_num|
        line = lines[line_num]

        if match = line.match(/#{Regex.escape(var_name)}\s*:\s*(\w+(?:::\w+)?)\s*=/)
          return match[1]
        end
        if match = line.match(/#{Regex.escape(var_name)}\s*=\s*(.+)/)
          assignment = match[1].strip
          return infer_type_from_assignment(assignment)
        end
      end

      nil
    end

    private def infer_type_from_assignment(value : String) : String
      value = value.strip

      return "String" if value.starts_with?('"') || value.starts_with?("'")
      return "Int32" if value.match(/^\d+$/)
      return "Int64" if value.match(/^\d+_i64$/) || value.match(/^\d+i64$/)
      return "Float64" if value.match(/^\d+\.\d+$/)
      return "Bool" if value == "true" || value == "false"
      return "Nil" if value == "nil"
      return "Array" if value.starts_with?('[')
      return "Hash" if value.starts_with?('{')
      return "Regex" if value.starts_with?('/')
      return "Symbol" if value.starts_with?(':')
      return "Range" if value.match(/\d+\.\.\d+/)
      return "Proc" if value.starts_with?("->")

      if match = value.match(/(\w+)\.(\w+)/)
        method = match[2]
        case method
        when "to_s"                                 then return "String"
        when "to_i"                                 then return "Int32"
        when "to_f"                                 then return "Float64"
        when "size", "length"                       then return "Int32"
        when "empty?"                               then return "Bool"
        when "split"                                then return "Array(String)"
        when "chars"                                then return "Array(Char)"
        when "keys"                                 then return "Array"
        when "values"                               then return "Array"
        when "upcase", "downcase", "strip", "chomp" then return "String"
        when "first", "last"                        then return "Object"
        when "map", "select", "reject"              then return "Array"
        end
      end

      if match = value.match(/(\w+(?:::\w+)*)\.new(?:\(|$)/)
        return match[1]
      end

      if match = value.match(/(\w+(?:::\w+)*)\.from_json/)
        return match[1]
      end

      if match = value.match(/^(\w+(?:::\w+)*)\(/)
        return match[1]
      end

      "Object"
    end

    private def get_type_via_crystal_tool(
      uri : String,
      source : String,
      position : ::LSP::Position,
    ) : String?
      filename = uri_to_filename(uri)
      line = position.line + 1
      column = position.character + 1

      begin
        output_io = IO::Memory.new
        error_io = IO::Memory.new
        temp_file = get_temp_file_for_uri(uri, source)
        cursor_loc = "#{temp_file}:#{line}:#{column}"

        Process.run("crystal", ["tool", "context", "-c", cursor_loc, temp_file],
          output: output_io,
          error: error_io)

        output = output_io.to_s.strip

        if match = output.match(/:\s*(\w+(?:::\w+)*)/)
          return match[1]
        end
      rescue
      end

      nil
    end

    private def infer_type_from_constant_name(constant_name : String) : String?
      case constant_name
      when "PROGRAM_NAME", "ARGV_UNSAFE"
        "String"
      when "ARGV"
        "Array(String)"
      when "STDIN"
        "IO::FileDescriptor"
      when "STDOUT", "STDERR"
        "IO::FileDescriptor"
      when "ENV"
        "ENV"
      when "CRYSTAL_VERSION"
        "String"
      else
        if constant_name.ends_with?("_PATH") || constant_name.ends_with?("_DIR") ||
           constant_name.ends_with?("_FILE") || constant_name.ends_with?("_NAME")
          "String"
        elsif constant_name.ends_with?("_COUNT") || constant_name.ends_with?("_SIZE")
          "Int32"
        elsif constant_name.ends_with?("_ENABLED") || constant_name.starts_with?("IS_")
          "Bool"
        else
          nil
        end
      end
    end

    private def get_hover_from_workspace(
      uri : String,
      source : String,
      position : ::LSP::Position,
    ) : ::LSP::Hover?
      lines = get_lines(uri)
      return unless lines
      return if position.line >= lines.size

      line_text = lines[position.line]
      word = extract_qualified_name_at_position(line_text, position.character)
      return unless word

      local_result = find_local_variable_or_parameter(source, word, position, uri)
      if local_result[1]
        content = "```crystal\n#{word} : #{local_result[1]}\n```\n\n*Local variable or parameter*"
        return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
      end

      if dot_pos = find_dot_in_line(line_text, position.character)
        receiver_word = extract_word_before_dot(line_text, dot_pos)
        if receiver_word
          if lib_symbol = @workspace_analyzer.find_symbol_info(receiver_word)
            if lib_symbol.kind == "lib"
              fun_qualified_name = "#{receiver_word}::#{word}"
              if fun_symbol = @workspace_analyzer.find_symbol_info(fun_qualified_name)
                if fun_symbol.kind == "fun" && fun_symbol.signature
                  content = "**Extern Function**\n\n"
                  content += "```crystal\n#{fun_symbol.signature}\n```"
                  if doc = fun_symbol.documentation
                    content += "\n\n---\n\n#{format_doc_comment(doc)}"
                  end
                  content += "\n\n*C library binding*"
                  return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
                end
              end
            end
          end

          receiver_type = @workspace_analyzer.get_type_at_position(
            uri,
            source,
            ::LSP::Position.new(position.line, dot_pos - 1)
          )
          if receiver_type
            if symbol = @workspace_analyzer.find_method_definition(receiver_type, word)
              content = case symbol.kind
                        when "method"
                          if signature = symbol.signature
                            sig_content = "```crystal\n#{signature}\n```"
                            if doc = symbol.documentation
                              sig_content += "\n\n---\n\n#{format_doc_comment(doc)}"
                            end
                            sig_content
                          else
                            "```crystal\ndef #{word} : #{symbol.type}\n```"
                          end
                        else
                          "```crystal\n#{symbol.signature}\n```"
                        end
              return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
            end
          end
        end
      end

      if signature = find_signature_in_current_file(source, word)
        if signature.includes?("\n\n")
          parts = signature.split("\n\n", 2)
          sig_part = parts[0]
          doc_part = parts[1]
          content = "```crystal\n#{sig_part}\n```\n\n---\n\n#{doc_part}"
        else
          content = "```crystal\n#{signature}\n```"
        end
        return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
      end

      if symbol = @workspace_analyzer.find_symbol_info(word)
        content = case symbol.kind
                  when "method"
                    if signature = symbol.signature
                      "```crystal\n#{signature}\n```"
                    else
                      "```crystal\ndef #{symbol.name} : #{symbol.type}\n```"
                    end
                  when "fun"
                    fun_content = "**Extern Function**\n\n"
                    if signature = symbol.signature
                      fun_content += "```crystal\n#{signature}\n```"
                    else
                      fun_content += "```crystal\nfun #{symbol.name} : #{symbol.type}\n```"
                    end
                    fun_content += "\n\n*C library binding*"
                    fun_content
                  when "lib"
                    "```crystal\nlib #{symbol.name}\n```\n\n*C library declaration*"
                  when "class"
                    class_content = "```crystal\nclass #{symbol.name} < #{symbol.type}\n```"
                    if members = @workspace_analyzer.get_class_members(symbol.name)
                      class_content += "\n\n**Members:**\n" + members
                    end
                    class_content
                  when "module"
                    module_content = "```crystal\nmodule #{symbol.name}\n```"
                    if members = @workspace_analyzer.get_module_members(symbol.name)
                      module_content += "\n\n**Members:**\n" + members
                    end
                    module_content
                  when "enum"
                    enum_content = "```crystal\nenum #{symbol.name}\n```"
                    if values = @workspace_analyzer.get_enum_values(symbol.name, symbol.file)
                      enum_content += "\n\n**Values:**\n" + values
                    end
                    enum_content
                  when "struct"
                    struct_content = "```crystal\nstruct #{symbol.name}\n```"
                    if members = @workspace_analyzer.get_struct_members(symbol.name)
                      struct_content += "\n\n**Members:**\n" + members
                    end
                    struct_content
                  when "property", "getter", "setter"
                    "```crystal\n#{symbol.kind} #{symbol.name.sub("@", "")} : #{symbol.type}\n```"
                  when "instance_variable"
                    "```crystal\n#{symbol.name} : #{symbol.type}\n```"
                  when "constant"
                    "```crystal\n#{symbol.name} : #{symbol.type}\n```"
                  when "alias"
                    "```crystal\nalias #{symbol.name} = #{symbol.type}\n```"
                  else
                    "```crystal\n#{symbol.name} : #{symbol.type}\n```"
                  end

        if doc = symbol.documentation
          content += "\n\n---\n\n#{format_doc_comment(doc)}"
        end

        return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
      end

      if type_info = @workspace_analyzer.get_type_at_position(uri, source, position)
        content = "```crystal\n#{word} : #{type_info}\n```"
        return ::LSP::Hover.new(::LSP::MarkupContent.new("markdown", content))
      end

      nil
    end

    private def find_signature_in_current_file(source : String, method_name : String) : String?
      lines = nil
      @source_lines_cache.each_value do |cached_lines|
        source_from_cache = cached_lines.join('\n')
        if source_from_cache == source
          lines = cached_lines
          break
        end
      end
      lines ||= source.split('\n')

      lines.each_with_index do |line, line_num|
        if match = line.match(
             /^\s*((?:private\s+)?def\s+#{Regex.escape(method_name)}\s*(?:\([^)]*\))?\s*(?::\s*\w+)?)/
           )
          signature = match[1].strip

          if line.ends_with?("\\") || (!line.includes?(")") && line.includes?("("))
            full_signature = signature
            (line_num + 1...lines.size).each do |i|
              next_line = lines[i].strip
              full_signature += " " + next_line
              break if next_line.includes?(")")
              break if i > line_num + 5
            end
            signature = full_signature
          end

          docs = [] of String
          current_line = line_num - 1

          while current_line >= 0
            doc_line = lines[current_line].strip
            if doc_line.starts_with?("#")
              docs.unshift(doc_line.sub(/^#\s?/, ""))
              current_line -= 1
            elsif doc_line.empty?
              current_line -= 1
            else
              break
            end
          end

          result = signature
          if !docs.empty?
            result += "\n\n" + docs.join("\n")
          end

          return result
        end

        if match = line.match(/^\s*(class\s+#{Regex.escape(method_name)}(?:\s*<\s*\w+)?)/)
          class_signature = match[1].strip

          docs = [] of String
          current_line = line_num - 1

          while current_line >= 0
            doc_line = lines[current_line].strip
            if doc_line.starts_with?("#")
              docs.unshift(doc_line.sub(/^#\s?/, ""))
              current_line -= 1
            elsif doc_line.empty?
              current_line -= 1
            else
              break
            end
          end

          result = class_signature
          if !docs.empty?
            result += "\n\n" + docs.join("\n")
          end

          return result
        end

        if match = line.match(/^\s*(module\s+#{Regex.escape(method_name)})(?:\s|$)/)
          module_signature = match[1].strip

          docs = [] of String
          current_line = line_num - 1
          while current_line >= 0
            doc_line = lines[current_line].strip
            if doc_line.starts_with?("#")
              docs.unshift(doc_line.sub(/^#\s?/, ""))
              current_line -= 1
            elsif doc_line.empty?
              current_line -= 1
            else
              break
            end
          end

          # Collect public member signatures from the module body.
          members = [] of String
          depth = 0
          (line_num + 1...lines.size).each do |i|
            ml = lines[i].strip
            next if ml.empty? || ml.starts_with?('#')
            if ml.match(/^(class|module|struct|def|if|case|while|begin)\b/)
              depth += 1
            end
            if ml == "end"
              break if depth == 0
              depth -= 1
              next
            end
            if depth == 0 && ml.match(/^(def |abstract def |property |getter |setter |[A-Z]\w*\s*=)/)
              members << "  " + ml
            end
          end

          result = module_signature
          unless members.empty?
            result += "\n" + members.join("\n") + "\nend"
          end
          if !docs.empty?
            result += "\n\n" + docs.join("\n")
          end

          return result
        end

        if match = line.match(/^\s*(enum\s+#{Regex.escape(method_name)})/)
          enum_signature = match[1].strip

          docs = [] of String
          current_line = line_num - 1

          while current_line >= 0
            doc_line = lines[current_line].strip
            if doc_line.starts_with?("#")
              docs.unshift(doc_line.sub(/^#\s?/, ""))
              current_line -= 1
            elsif doc_line.empty?
              current_line -= 1
            else
              break
            end
          end

          # Collect enum member lines from the body.
          members = [] of String
          depth = 0
          (line_num + 1...lines.size).each do |i|
            ml = lines[i].strip
            next if ml.empty? || ml.starts_with?('#')
            if ml == "end"
              break if depth == 0
              depth -= 1
              next
            end
            if ml.match(/^(def |abstract def )/)
              depth += 1
            end
            if depth == 0
              members << "  " + ml
            end
          end

          result = enum_signature
          unless members.empty?
            result += "\n" + members.join("\n") + "\nend"
          end
          if !docs.empty?
            result += "\n\n" + docs.join("\n")
          end

          return result
        end

        if match = line.match(/^\s*(struct\s+#{Regex.escape(method_name)})/)
          struct_signature = match[1].strip

          docs = [] of String
          current_line = line_num - 1

          while current_line >= 0
            doc_line = lines[current_line].strip
            if doc_line.starts_with?("#")
              docs.unshift(doc_line.sub(/^#\s?/, ""))
              current_line -= 1
            elsif doc_line.empty?
              current_line -= 1
            else
              break
            end
          end

          # Collect public member signatures from the struct body.
          members = [] of String
          depth = 0
          (line_num + 1...lines.size).each do |i|
            ml = lines[i].strip
            next if ml.empty? || ml.starts_with?('#')
            if ml.match(/^(class|module|struct|def|if|case|while|begin)\b/)
              depth += 1
            end
            if ml == "end"
              break if depth == 0
              depth -= 1
              next
            end
            if depth == 0 && ml.match(/^(def |abstract def |property |getter |setter |[A-Z]\w*\s*=)/)
              members << "  " + ml
            end
          end

          result = struct_signature
          unless members.empty?
            result += "\n" + members.join("\n") + "\nend"
          end
          if !docs.empty?
            result += "\n\n" + docs.join("\n")
          end

          return result
        end

        if match = line.match(
             /^\s*((?:property|getter|setter)\s+#{Regex.escape(method_name.sub("@", ""))}\s*:\s*\w+)/
           )
          return match[1].strip
        end

        if match = line.match(/^\s*(#{Regex.escape(method_name)}\s*=\s*.+)/)
          return match[1].strip
        end

        if match = line.match(/^\s*(alias\s+#{Regex.escape(method_name)}\s*=\s*.+)/)
          return match[1].strip
        end
      end

      nil
    end

    private def find_local_variable_or_parameter(
      source : String,
      symbol_name : String,
      position : ::LSP::Position,
      uri : String,
    ) : {::LSP::Location?, String?}
      lines = get_lines(uri)
      unless lines
        lines = source.split('\n')
      end

      method_start = nil
      method_end = nil
      indent_level = 0

      (0..position.line).reverse_each do |line_num|
        line = lines[line_num]
        if match = line.match(/^\s*(?:private\s+)?def\s+[\w=]+/)
          method_start = line_num
          indent_level = line[/^\s*/].size

          (line_num + 1...lines.size).each do |end_line_num|
            end_line = lines[end_line_num]
            end_indent = end_line[/^\s*/].size

            if end_line.strip == "end" && end_indent == indent_level
              method_end = end_line_num
              break
            end
          end
          break
        end
      end

      if method_start && method_end
        method_line = lines[method_start]
        if match = method_line.match(/def\s+[\w=]+\s*\((.*?)(?:\)|$)/)
          params_str = match[1]

          if !method_line.includes?(")") && method_start < lines.size - 1
            (method_start + 1...lines.size).each do |i|
              next_line = lines[i]
              params_str += next_line
              break if next_line.includes?(")")
              break if i > method_start + 10
            end
          end

          params_str.split(',').each do |param|
            param = param.strip
            if param_match = param.match(/^(\w+)\s*:\s*([^=]+)/)
              param_name = param_match[1]
              param_type = param_match[2].strip

              if param_name == symbol_name
                range = ::LSP::Range.new(
                  ::LSP::Position.new(method_start, method_line.index!(param_name)),
                  ::LSP::Position.new(method_start, method_line.index!(param_name) + param_name.size)
                )
                return {::LSP::Location.new(uri, range), param_type}
              end
            end
          end
        end

        (method_start..position.line).each do |line_num|
          line = lines[line_num]

          if match = line.match(/^\s*(#{Regex.escape(symbol_name)})\s*=/)
            range = ::LSP::Range.new(
              ::LSP::Position.new(line_num, match.begin(1)),
              ::LSP::Position.new(line_num, match.end(1))
            )
            return {::LSP::Location.new(uri, range), nil}
          end

          if match = line.match(/^\s*(#{Regex.escape(symbol_name)})\s*:\s*([^=]+)=/)
            param_type = match[2].strip
            range = ::LSP::Range.new(
              ::LSP::Position.new(line_num, match.begin(1)),
              ::LSP::Position.new(line_num, match.end(1))
            )
            return {::LSP::Location.new(uri, range), param_type}
          end
        end
      end

      {nil, nil}
    end

    private def find_definition_in_current_file(
      source : String,
      symbol_name : String,
      uri : String,
    ) : ::LSP::Location?
      lines = get_lines(uri)
      unless lines
        lines = source.split('\n')
      end

      lines.each_with_index do |line, line_num|
        if match = line.match(/^\s*(#{Regex.escape(symbol_name)})\s*[=:]/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Method definitions
        if match = line.match(/^\s*def\s+(#{Regex.escape(symbol_name)})(?:\(|$|\s)/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Private method definitions
        if match = line.match(/^\s*private\s+def\s+(#{Regex.escape(symbol_name)})(?:\(|$|\s)/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Struct definitions
        if match = line.match(/^\s*struct\s+(#{Regex.escape(symbol_name)})(?:\s|$)/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Class definitions
        if match = line.match(/^\s*class\s+(#{Regex.escape(symbol_name)})(?:\s|$)/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Module definitions
        if match = line.match(/^\s*module\s+(#{Regex.escape(symbol_name)})(?:\s|$)/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Instance variable definitions
        if match = line.match(/^\s*(#{Regex.escape(symbol_name)})\s*[=:]/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Enum definitions
        if match = line.match(/^\s*enum\s+(#{Regex.escape(symbol_name)})(?:\s|$)/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Struct definitions
        if match = line.match(/^\s*struct\s+(#{Regex.escape(symbol_name)})(?:\s|$)/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Alias definitions
        if match = line.match(/^\s*alias\s+(#{Regex.escape(symbol_name)})(?:\s|$)/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Constants
        if match = line.match(/^\s*(#{Regex.escape(symbol_name)})\s*=/)
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Property declarations
        if match = line.match(
             /^\s*(?:property|getter|setter)\s+(#{Regex.escape(symbol_name.sub("@", ""))})(?:\s|$)/
           )
          range = ::LSP::Range.new(
            ::LSP::Position.new(line_num, match.begin(1)),
            ::LSP::Position.new(line_num, match.end(1))
          )
          return ::LSP::Location.new(uri, range)
        end

        # Instance variables
        if symbol_name.starts_with?("@")
          if match = line.match(/(#{Regex.escape(symbol_name)})\s*:/)
            range = ::LSP::Range.new(
              ::LSP::Position.new(line_num, match.begin(1)),
              ::LSP::Position.new(line_num, match.end(1))
            )
            return ::LSP::Location.new(uri, range)
          end
        end
      end

      nil
    end

    private def find_dot_in_line(line : String, pos : Int32) : Int32?
      search_pos = pos - 1
      while search_pos >= 0 && (line[search_pos].alphanumeric? || line[search_pos] == '_')
        search_pos -= 1
      end

      if search_pos >= 0 && line[search_pos] == '.'
        return search_pos
      end

      (0...pos).reverse_each do |i|
        return i if line[i] == '.'
        break unless line[i].whitespace?
      end
      nil
    end

    private def extract_word_before_dot(line : String, dot_pos : Int32) : String?
      return if dot_pos <= 0

      end_pos = dot_pos - 1
      while end_pos >= 0 && line[end_pos].whitespace?
        end_pos -= 1
      end
      return if end_pos < 0

      start_pos = end_pos
      while start_pos > 0 && word_char?(line[start_pos - 1])
        start_pos -= 1
      end

      return if start_pos == end_pos + 1
      line[start_pos..end_pos]
    end

    private def add_workspace_symbol_completions(items : Array(::LSP::CompletionItem))
      if symbol = @workspace_analyzer.find_symbol_info("")
      end
    end

    # Stdlib type member completions — seeded from a built-in table so that
    # modules like JSON, YAML, HTTP::Client, Math, etc. produce useful
    # dot-completions even when the stdlib source files are absent from the
    # workspace index.
    STDLIB_TYPE_MEMBERS = {
      "JSON" => [
        {"parse",           "(raw : String) : Any",                   ::LSP::CompletionItemKind::Method},
        {"build",           "(&block : Builder ->) : String",          ::LSP::CompletionItemKind::Method},
        {"any",            "(raw : String) : Any",                    ::LSP::CompletionItemKind::Method},
      ],
      "YAML" => [
        {"parse",           "(raw : String) : Any",                   ::LSP::CompletionItemKind::Method},
        {"dump",            "(object) : String",                      ::LSP::CompletionItemKind::Method},
      ],
      "Base64" => [
        {"encode",          "(data : Bytes | String) : String",       ::LSP::CompletionItemKind::Method},
        {"decode",          "(data : String) : Bytes",                ::LSP::CompletionItemKind::Method},
        {"strict_encode",   "(data : Bytes | String) : String",       ::LSP::CompletionItemKind::Method},
        {"strict_decode",   "(data : String) : Bytes",                ::LSP::CompletionItemKind::Method},
        {"urlsafe_encode",  "(data : Bytes | String) : String",       ::LSP::CompletionItemKind::Method},
        {"urlsafe_decode",  "(data : String) : Bytes",                ::LSP::CompletionItemKind::Method},
      ],
      "File" => [
        {"read",            "(filename : String) : String",           ::LSP::CompletionItemKind::Method},
        {"write",           "(filename : String, content) : Nil",     ::LSP::CompletionItemKind::Method},
        {"exists?",         "(path : String) : Bool",                 ::LSP::CompletionItemKind::Method},
        {"open",            "(filename : String, mode = ...) : File", ::LSP::CompletionItemKind::Method},
        {"join",            "(*parts : String) : String",             ::LSP::CompletionItemKind::Method},
        {"dirname",         "(path : String) : String",               ::LSP::CompletionItemKind::Method},
        {"basename",        "(path : String) : String",               ::LSP::CompletionItemKind::Method},
        {"extname",         "(path : String) : String",               ::LSP::CompletionItemKind::Method},
        {"expand_path",     "(path : String, dir = nil) : String",    ::LSP::CompletionItemKind::Method},
        {"delete",          "(path : String) : Nil",                  ::LSP::CompletionItemKind::Method},
        {"rename",          "(old_path, new_path) : Nil",             ::LSP::CompletionItemKind::Method},
        {"size",            "(path : String) : Int64",                ::LSP::CompletionItemKind::Method},
        {"directory?",      "(path : String) : Bool",                 ::LSP::CompletionItemKind::Method},
        {"symlink?",        "(path : String) : Bool",                 ::LSP::CompletionItemKind::Method},
        {"tempname",        "(prefix = nil, suffix = nil) : String",  ::LSP::CompletionItemKind::Method},
        {"tempfile",        "(prefix = nil, suffix = nil) : File",    ::LSP::CompletionItemKind::Method},
      ],
      "Dir" => [
        {"mkdir",           "(path : String, mode = 0o777) : Nil",    ::LSP::CompletionItemKind::Method},
        {"mkdir_p",         "(path : String, mode = 0o777) : Nil",    ::LSP::CompletionItemKind::Method},
        {"exists?",         "(path : String) : Bool",                 ::LSP::CompletionItemKind::Method},
        {"each_child",      "(dir : String, &block : String ->) : Nil", ::LSP::CompletionItemKind::Method},
        {"glob",            "(pattern : String) : Array(String)",     ::LSP::CompletionItemKind::Method},
        {"current",         ": String",                               ::LSP::CompletionItemKind::Method},
        {"cd",              "(path : String) : Nil",                  ::LSP::CompletionItemKind::Method},
        {"delete",          "(path : String) : Nil",                  ::LSP::CompletionItemKind::Method},
        {"tempdir",         ": String",                               ::LSP::CompletionItemKind::Method},
      ],
      "Math" => [
        {"sqrt",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"cbrt",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"log",    "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"log2",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"log10",  "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"exp",    "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"exp2",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"sin",    "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"cos",    "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"tan",    "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"asin",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"acos",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"atan",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"atan2",  "(y : Float64, x : Float64) : Float64",            ::LSP::CompletionItemKind::Method},
        {"sinh",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"cosh",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"tanh",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"hypot",  "(x : Float64, y : Float64) : Float64",            ::LSP::CompletionItemKind::Method},
        {"floor",  "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"ceil",   "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"round",  "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"abs",    "(x : Float64) : Float64",                         ::LSP::CompletionItemKind::Method},
        {"min",    "(x, y) : Float64",                                ::LSP::CompletionItemKind::Method},
        {"max",    "(x, y) : Float64",                                ::LSP::CompletionItemKind::Method},
        {"pw2ceil","(x : Int32) : Int32",                             ::LSP::CompletionItemKind::Method},
        {"ilogb",  "(x : Float64) : Int32",                           ::LSP::CompletionItemKind::Method},
      ],
      "Random" => [
        {"rand",   ": Float64",                                       ::LSP::CompletionItemKind::Method},
        {"next_bool", ": Bool",                                       ::LSP::CompletionItemKind::Method},
        {"next_float", ": Float64",                                   ::LSP::CompletionItemKind::Method},
        {"next_int", ": Int32",                                       ::LSP::CompletionItemKind::Method},
        {"new",    "(seed : UInt64 = ...) : Random",                  ::LSP::CompletionItemKind::Method},
      ],
      "Time" => [
        {"utc",    ": Time",                                          ::LSP::CompletionItemKind::Method},
        {"local",  ": Time",                                          ::LSP::CompletionItemKind::Method},
        {"now",    ": Time",                                          ::LSP::CompletionItemKind::Method},
        {"parse",  "(time : String, pattern : String, ...) : Time",   ::LSP::CompletionItemKind::Method},
        {"unix",   "(seconds : Int64) : Time",                        ::LSP::CompletionItemKind::Method},
        {"unix_ms","(milliseconds : Int64) : Time",                   ::LSP::CompletionItemKind::Method},
        {"measure","(&block) : Time::Span",                           ::LSP::CompletionItemKind::Method},
      ],
      "URI" => [
        {"parse",        "(uri : String) : URI",                      ::LSP::CompletionItemKind::Method},
        {"encode",       "(string : String) : String",                ::LSP::CompletionItemKind::Method},
        {"decode",       "(string : String) : String",                ::LSP::CompletionItemKind::Method},
        {"encode_path",  "(string : String) : String",                ::LSP::CompletionItemKind::Method},
        {"encode_www_form", "(string : String) : String",             ::LSP::CompletionItemKind::Method},
        {"decode_www_form", "(string : String) : String",             ::LSP::CompletionItemKind::Method},
      ],
      "Process" => [
        {"run",           "(command : String, args = nil, ...) : Process::Status", ::LSP::CompletionItemKind::Method},
        {"new",           "(command : String, args = nil, ...) : Process",         ::LSP::CompletionItemKind::Method},
        {"find_executable","(name : String) : String?",              ::LSP::CompletionItemKind::Method},
        {"pid",           ": Int32",                                  ::LSP::CompletionItemKind::Method},
        {"ppid",          ": Int32",                                  ::LSP::CompletionItemKind::Method},
        {"signal",        "(signal : Signal, pid : Int32) : Nil",     ::LSP::CompletionItemKind::Method},
        {"on_terminate",  "(&block) : Nil",                           ::LSP::CompletionItemKind::Method},
      ],
      "GC" => [
        {"collect",       ": Nil",                                    ::LSP::CompletionItemKind::Method},
        {"disable",       ": Nil",                                    ::LSP::CompletionItemKind::Method},
        {"enable",        ": Nil",                                    ::LSP::CompletionItemKind::Method},
        {"stats",         ": GC::Stats",                              ::LSP::CompletionItemKind::Method},
        {"malloc_limit=", "(bytes : UInt64) : Nil",                   ::LSP::CompletionItemKind::Method},
      ],
      "Digest" => [
        {"md5",    "(data : Bytes | String) : String",                ::LSP::CompletionItemKind::Method},
        {"sha1",   "(data : Bytes | String) : String",                ::LSP::CompletionItemKind::Method},
        {"sha256", "(data : Bytes | String) : String",                ::LSP::CompletionItemKind::Method},
        {"sha512", "(data : Bytes | String) : String",                ::LSP::CompletionItemKind::Method},
      ],
      "HTML" => [
        {"escape",   "(string : String) : String",                    ::LSP::CompletionItemKind::Method},
        {"unescape", "(string : String) : String",                    ::LSP::CompletionItemKind::Method},
      ],
      "CSV" => [
        {"new",       "(string_or_io, ...) : CSV",                    ::LSP::CompletionItemKind::Method},
        {"build",     "(&block : Builder ->) : String",               ::LSP::CompletionItemKind::Method},
        {"each_row",  "(string_or_io, &block : Array(String) ->) : Nil", ::LSP::CompletionItemKind::Method},
        {"parse",     "(string : String) : Array(Array(String))",     ::LSP::CompletionItemKind::Method},
      ],
      "XML" => [
        {"parse",        "(string : String) : XML::Node",             ::LSP::CompletionItemKind::Method},
        {"parse_html",   "(string : String) : XML::Node",             ::LSP::CompletionItemKind::Method},
        {"build",        "(&block : Builder ->) : String",            ::LSP::CompletionItemKind::Method},
        {"build_fragment","(&block : Builder ->) : String",           ::LSP::CompletionItemKind::Method},
      ],
      "UUID" => [
        {"new",    "(value : String) : UUID",                         ::LSP::CompletionItemKind::Method},
        {"random", ": UUID",                                          ::LSP::CompletionItemKind::Method},
        {"empty",  ": UUID",                                          ::LSP::CompletionItemKind::Method},
      ],
      "SemanticVersion" => [
        {"parse",  "(version : String) : SemanticVersion",            ::LSP::CompletionItemKind::Method},
        {"new",    "(major, minor, patch, ...) : SemanticVersion",    ::LSP::CompletionItemKind::Method},
      ],
    } of String => Array(Tuple(String, String, ::LSP::CompletionItemKind))

    private def add_stdlib_type_completions(
      items : Array(::LSP::CompletionItem),
      receiver_type : String,
      partial : String,
    )
      STDLIB_TYPE_MEMBERS[receiver_type]?.try &.each do |name, detail, kind|
        next unless name.starts_with?(partial)
        next if items.any? { |i| i.label == name }
        item = ::LSP::CompletionItem.new(name, kind, detail)
        items << item
      end
    end

    private def add_common_method_completions(items : Array(::LSP::CompletionItem))
      common_methods = [
        {"to_s", "Convert to String", ::LSP::CompletionItemKind::Method},
        {"to_i", "Convert to Int32", ::LSP::CompletionItemKind::Method},
        {"to_f", "Convert to Float64", ::LSP::CompletionItemKind::Method},
        {"inspect", "Debug representation", ::LSP::CompletionItemKind::Method},
        {"class", "Get object class", ::LSP::CompletionItemKind::Method},
        {"nil?", "Check if nil", ::LSP::CompletionItemKind::Method},
        {"is_a?", "Check type", ::LSP::CompletionItemKind::Method},
        {"as", "Type cast", ::LSP::CompletionItemKind::Method},
        {"size", "Get size/length", ::LSP::CompletionItemKind::Method},
        {"empty?", "Check if empty", ::LSP::CompletionItemKind::Method},
        {"each", "Iterate elements", ::LSP::CompletionItemKind::Method},
        {"map", "Transform elements", ::LSP::CompletionItemKind::Method},
        {"select", "Filter elements", ::LSP::CompletionItemKind::Method},
        {"reject", "Reject elements", ::LSP::CompletionItemKind::Method},
        {"first", "Get first element", ::LSP::CompletionItemKind::Method},
        {"last", "Get last element", ::LSP::CompletionItemKind::Method},
        {"upcase", "Convert to uppercase", ::LSP::CompletionItemKind::Method},
        {"downcase", "Convert to lowercase", ::LSP::CompletionItemKind::Method},
        {"strip", "Remove whitespace", ::LSP::CompletionItemKind::Method},
        {"split", "Split string", ::LSP::CompletionItemKind::Method},
        {"join", "Join array", ::LSP::CompletionItemKind::Method},
        {"includes?", "Check if includes", ::LSP::CompletionItemKind::Method},
        {"starts_with?", "Check prefix", ::LSP::CompletionItemKind::Method},
        {"ends_with?", "Check suffix", ::LSP::CompletionItemKind::Method},
      ]

      common_methods.each do |name, detail, kind|
        items << ::LSP::CompletionItem.new(name, kind, detail)
      end
    end

    private def add_file_symbol_completions(
      items : Array(::LSP::CompletionItem),
      source : String,
    )
      begin
        parser = ::Crystal::Parser.new(source)
        node = parser.parse
        extract_symbols_for_completion(node, items)
      rescue
      end
    end

    private def extract_symbols_for_completion(
      node : ::Crystal::ASTNode,
      items : Array(::LSP::CompletionItem),
    )
      case node
      when ::Crystal::ClassDef
        items << ::LSP::CompletionItem.new(
          node.name.to_s,
          ::LSP::CompletionItemKind::Class,
          "Class"
        )
        node.body.try { |body| extract_symbols_for_completion(body, items) }
      when ::Crystal::ModuleDef
        items << ::LSP::CompletionItem.new(
          node.name.to_s,
          ::LSP::CompletionItemKind::Module,
          "Module"
        )
        node.body.try { |body| extract_symbols_for_completion(body, items) }
      when ::Crystal::Def
        items << ::LSP::CompletionItem.new(
          node.name,
          ::LSP::CompletionItemKind::Method,
          "Method"
        )
      when ::Crystal::Assign
        if target = node.target
          if target.is_a?(::Crystal::Var)
            items << ::LSP::CompletionItem.new(
              target.name,
              ::LSP::CompletionItemKind::Variable,
              "Variable"
            )
          end
        end
      when ::Crystal::Expressions
        node.expressions.each { |expr| extract_symbols_for_completion(expr, items) }
      end
    end

    private def check_argument_types(node : ::Crystal::ASTNode, uri : String, source : String) : Array(::LSP::Diagnostic)
      diagnostics = [] of ::LSP::Diagnostic
      visitor = ArgumentTypeChecker.new(self, @workspace_analyzer, uri, source)
      node.accept(visitor)
      diagnostics.concat(visitor.diagnostics)
      diagnostics
    end

    # Walks the AST produced by Crystal::Parser and emits type-mismatch
    # diagnostics for call sites where we can determine — with high confidence
    # and zero false positives — that a literal argument is incompatible with
    # the declared parameter type of a known method.
    #
    # Design constraints
    # ------------------
    # 1. Only fire on *literal* arguments (NumberLiteral, StringLiteral, etc.).
    #    Variables, method calls, and anything requiring data-flow analysis are
    #    skipped; we return nil from infer_argument_type for those.
    # 2. Only fire when the receiver is a *Path* (constant / type name) so we
    #    never guess a variable's type by capitalising its name.
    # 3. Only fire when every parameter type token in the signature is a *single*
    #    concrete type with no union (`|`) and no generic (`(`).  Ambiguous
    #    signatures are skipped entirely.
    # 4. Skip operator methods, block-only calls, and splat/variadic params.
    # 5. Widen numeric types according to Crystal's implicit conversion rules.
    class ArgumentTypeChecker < ::Crystal::Visitor
      getter diagnostics : Array(::LSP::Diagnostic)

      # Operator method names that Crystal resolves via special dispatch rules;
      # never attempt type-checking on these.
      OPERATOR_METHODS = %w[
        + - * / // % ** & | ^ ~ << >> <=> == != < <= > >= [] []= []? <
      ]

      def initialize(@analyzer : SemanticAnalyzer, @workspace_analyzer : WorkspaceAnalyzer, @uri : String, @source : String)
        @diagnostics = [] of ::LSP::Diagnostic
        @lines = @source.split('\n')
      end

      def visit(node : ::Crystal::ASTNode)
        if node.is_a?(::Crystal::Call)
          if location = node.location
            check_call_arguments(node, location)
          end
        end
        true
      end

      private def check_call_arguments(node : ::Crystal::Call, location : ::Crystal::Location)
        method_name = node.name
        args = node.args

        # Skip operators and calls with no positional arguments.
        return if args.empty?
        return if OPERATOR_METHODS.includes?(method_name)
        # Skip calls that pass a block — those are almost always correct and
        # the parameter list in the signature will not account for them.
        return if node.block

        # Only trust Path receivers (e.g. `MyClass.foo`, `LibFoo.bar`).
        # Anything else — local variables, instance vars, chained calls —
        # requires data-flow inference we do not have; skip rather than guess.
        obj = node.obj
        receiver_type = infer_receiver_type(obj)
        return if obj && receiver_type.nil?

        method_symbol = find_method_symbol(method_name, receiver_type)
        return unless method_symbol

        param_types = extract_parameter_types(method_symbol.signature)
        return if param_types.empty?

        args.each_with_index do |arg, idx|
          # More args than typed params means the signature is variadic or has
          # defaults; stop rather than misattribute errors.
          break if idx >= param_types.size

          expected_type = param_types[idx]
          actual_type = infer_argument_type(arg)

          next unless actual_type
          next if types_compatible?(expected_type, actual_type)

          if arg_location = arg.location
            line = arg_location.line_number - 1
            column = arg_location.column_number - 1

            arg_text = get_node_text(arg)
            end_column = column + arg_text.size

            range = ::LSP::Range.new(
              ::LSP::Position.new(line, column),
              ::LSP::Position.new(line, end_column)
            )

            message = "Type mismatch: expected #{expected_type}, got #{actual_type}"
            @diagnostics << ::LSP::Diagnostic.new(
              range,
              message,
              ::LSP::DiagnosticSeverity::Error,
              "lattice"
            )
          end
        end
      end

      # Returns the receiver type only for Path nodes (constant names).
      # Returns nil for everything else so the caller can skip the check.
      private def infer_receiver_type(obj : ::Crystal::ASTNode?) : String?
        return nil unless obj
        return obj.names.join("::") if obj.is_a?(::Crystal::Path)
        nil
      end

      # Look up a method symbol, optionally scoped to a receiver type.
      # Only returns symbols whose kind is "method" or "fun".
      private def find_method_symbol(method_name : String, receiver_type : String?) : WorkspaceAnalyzer::SymbolInfo?
        if receiver_type
          qualified_name = "#{receiver_type}::#{method_name}"
          if symbol = @workspace_analyzer.find_symbol_info(qualified_name)
            return symbol if symbol.kind == "method" || symbol.kind == "fun"
          end
        end

        # For unqualified calls (no receiver), only match when there is exactly
        # one symbol with this name to avoid false positives from overloads.
        if receiver_type.nil?
          if symbol = @workspace_analyzer.find_symbol_info(method_name)
            return symbol if symbol.kind == "method" || symbol.kind == "fun"
          end
        end

        nil
      end

      # Parse the parameter list from a signature string and return an ordered
      # list of *concrete, non-union* type names for each required positional
      # parameter.  Returns an empty array if the signature is ambiguous or
      # contains constructs we cannot safely reason about.
      private def extract_parameter_types(signature : String?) : Array(String)
        return [] of String unless signature

        params = [] of String

        paren_start = signature.index('(')
        paren_end   = signature.rindex(')')
        return [] of String unless paren_start && paren_end && paren_end > paren_start

        param_list = signature[(paren_start + 1)...paren_end]
        return [] of String if param_list.strip.empty?

        # Split on commas that are not inside nested parentheses (generics).
        split_params(param_list).each do |raw|
          param = raw.strip

          # Skip splat, double-splat, and block parameters — these make the
          # method variadic or block-accepting; we cannot safely map positional
          # argument indices past them.
          if param.starts_with?("*") || param.starts_with?("&")
            # A bare splat `*` or `**` means "no more positional params".
            break
          end

          # Strip the parameter name to isolate the type annotation.
          # Patterns handled:
          #   name : Type
          #   name : Type = default
          #   name : Type | OtherType      ← union, skip
          #   name : Type(Generic)         ← generic, skip
          #   name = default               ← no type annotation, skip
          type_str = nil
          if type_match = param.match(/:\s*(.+?)(?:\s*=.*)?$/)
            type_str = type_match[1].strip
          end

          unless type_str
            # No type annotation — cannot verify, treat as a wildcard slot.
            params << "*"
            next
          end

          # Union types and generics require full type inference; skip the whole
          # method rather than fire on a partial match.
          if type_str.includes?("|") || type_str.includes?("(")
            return [] of String
          end

          params << type_str
        end

        params
      end

      # Split a parameter list string on top-level commas (i.e. not inside
      # nested parentheses from generic type parameters).
      private def split_params(param_list : String) : Array(String)
        parts = [] of String
        depth = 0
        current = String::Builder.new
        param_list.each_char do |ch|
          case ch
          when '('
            depth += 1
            current << ch
          when ')'
            depth -= 1
            current << ch
          when ','
            if depth == 0
              parts << current.to_s
              current = String::Builder.new
            else
              current << ch
            end
          else
            current << ch
          end
        end
        parts << current.to_s unless current.to_s.strip.empty?
        parts
      end

      # Infer a concrete type string from a literal AST node.
      # Returns nil for anything that requires data-flow analysis.
      private def infer_argument_type(arg : ::Crystal::ASTNode) : String?
        case arg
        when ::Crystal::NumberLiteral
          case arg.kind
          when .i8?                  then "Int8"
          when .i16?                 then "Int16"
          when .i32?                 then "Int32"
          when .i64?                 then "Int64"
          when .i128?                then "Int128"
          when .u8?                  then "UInt8"
          when .u16?                 then "UInt16"
          when .u32?                 then "UInt32"
          when .u64?                 then "UInt64"
          when .u128?                then "UInt128"
          when .f32?                 then "Float32"
          when .f64?                 then "Float64"
          else                            "Int32"
          end
        when ::Crystal::StringLiteral   then "String"
        when ::Crystal::BoolLiteral     then "Bool"
        when ::Crystal::CharLiteral     then "Char"
        when ::Crystal::SymbolLiteral   then "Symbol"
        when ::Crystal::NilLiteral      then "Nil"
        when ::Crystal::ArrayLiteral    then "Array"
        when ::Crystal::HashLiteral     then "Hash"
        when ::Crystal::TupleLiteral    then "Tuple"
        when ::Crystal::ProcLiteral     then "Proc"
        when ::Crystal::RegexLiteral    then "Regex"
        # Anything else (Var, Call, Cast, etc.) needs data-flow — skip.
        else nil
        end
      end

      # Returns true when *actual* is acceptable where *expected* is declared.
      #
      # Rules applied (in order):
      #  1. Exact match.
      #  2. Wildcard slot (no type annotation in signature) → always ok.
      #  3. Nil is compatible with any nilable type name ending in "?".
      #  4. Numeric widening: smaller integer/float types are compatible with
      #     larger ones, and all integer types are compatible with Float64.
      #  5. Abstract numeric supertypes (Int, Float, Number).
      private def types_compatible?(expected : String, actual : String) : Bool
        return true if expected == actual
        return true if expected == "*"

        # Nilable types: `String?` is shorthand for `String | Nil`
        return true if actual == "Nil" && expected.ends_with?("?")

        # All signed integers widen to Int32 → Int64 → Int128.
        int_width = {"Int8" => 8, "Int16" => 16, "Int32" => 32, "Int64" => 64, "Int128" => 128}
        uint_width = {"UInt8" => 8, "UInt16" => 16, "UInt32" => 32, "UInt64" => 64, "UInt128" => 128}
        float_width = {"Float32" => 32, "Float64" => 64}

        if (aw = int_width[actual]?) && (ew = int_width[expected]?)
          return true if aw <= ew
        end
        if (aw = uint_width[actual]?) && (ew = uint_width[expected]?)
          return true if aw <= ew
        end
        if (aw = float_width[actual]?) && (ew = float_width[expected]?)
          return true if aw <= ew
        end
        # Integer literals are compatible with Float parameters (Crystal auto-promotes).
        if int_width[actual]? && float_width[expected]?
          return true
        end

        # Abstract numeric supertypes.
        all_ints   = int_width.keys + uint_width.keys
        all_floats = float_width.keys
        all_nums   = all_ints + all_floats

        return true if expected == "Int"    && all_ints.includes?(actual)
        return true if expected == "Float"  && all_floats.includes?(actual)
        return true if expected == "Number" && all_nums.includes?(actual)

        false
      end

      # Extract the source text for a node using its location data.
      # Respects nested parentheses so that `foo(bar(1, 2))` correctly
      # captures `bar(1, 2)` as the argument text rather than stopping at
      # the first `,`.
      private def get_node_text(node : ::Crystal::ASTNode) : String
        if location = node.location
          line_idx = location.line_number - 1
          return "" if line_idx >= @lines.size

          line = @lines[line_idx]
          column = location.column_number - 1
          return "" if column >= line.size

          depth = 0
          end_pos = column
          while end_pos < line.size
            ch = line[end_pos]
            case ch
            when '(', '[', '{'
              depth += 1
              end_pos += 1
            when ')', ']', '}'
              break if depth == 0
              depth -= 1
              end_pos += 1
            when ',', ' ', '\t'
              break if depth == 0
              end_pos += 1
            else
              end_pos += 1
            end
          end

          return line[column...end_pos]
        end

        ""
      end
    end
  end

  class SemanticAnalyzer
    private def init_providers
      idx = @workspace_analyzer.workspace_index
      @keywords_provider      = Lattice::Completion::Providers::KeywordsProvider.new
      @reference_provider     = Lattice::Completion::Providers::ReferenceProvider.new(idx)
      @require_provider       = Lattice::Completion::Providers::RequireProvider.new
      @struct_literal_provider = Lattice::Completion::Providers::StructLiteralProvider.new(idx)
      @snippet_provider       = Lattice::Completion::Providers::SnippetCompletionProvider.new
    end

    # Format Crystal doc comments: render `@param name desc` and `@return desc`
    # as Markdown bold labels, and leave plain lines as-is.
    private def format_doc_comment(doc : String) : String
      doc.lines.map do |line|
        case line.strip
        when /^@param\s+(\S+)\s+(.*)/
          "**`#{$~[1]}`** \u2014 #{$~[2]}"
        when /^@return\s+(.*)/
          "**Returns:** #{$~[1]}"
        else
          line.rstrip
        end
      end.join("\n")
    end

    # Extract parameter label strings from a Crystal method signature string.
    # e.g. "def foo(x : Int32, y : String)" → ["x : Int32", "y : String"]
    private def extract_param_labels(signature : String) : Array(::LSP::ParameterInformation)
      if m = signature.match(/\(([^)]*)\)/)
        m[1].split(',').map do |param|
          ::LSP::ParameterInformation.new(param.strip)
        end
      else
        [] of ::LSP::ParameterInformation
      end
    end

    # Count the active parameter index by scanning *text* (the substring of
    # the current line from the opening parenthesis to the cursor) while
    # respecting nesting depth so that commas inside inner calls, brackets,
    # and braces are not counted.
    #
    # Example: `foo(bar(1, 2), |` → 1  (cursor is on second argument of foo)
    private def count_active_parameter(text : String) : Int32
      depth = 0
      index = 0
      text.each_char do |ch|
        case ch
        when '(', '[', '{'
          depth += 1
        when ')', ']', '}'
          depth -= 1 if depth > 0
        when ','
          index += 1 if depth == 1
        end
      end
      index
    end

    private def build_inspections : Array(Lattice::Inspections::Inspection)
      [
        Lattice::Inspections::UnusedVariableInspection.new,
        Lattice::Inspections::UnusedParameterInspection.new,
        Lattice::Inspections::ShadowedVariableInspection.new,
        Lattice::Inspections::MissingReturnInspection.new,
        Lattice::Inspections::AmebaInspection.new,
        @max_line_length_inspection,
        @warn_on_debug_inspection,
        Lattice::Inspections::RedundantRequireInspection.new,
        Lattice::Inspections::UnnecessaryNotNilInspection.new,
        Lattice::Inspections::ConstantInInterpolationInspection.new,
        Lattice::Inspections::EmptyRescueInspection.new,
      ] of Lattice::Inspections::Inspection
    end
  end
end
