摆脱 IntelliJ:在 Emacs Eglot 上使用 Scala 和 Kotlin 的 LSP
Escape IntelliJ: Scala and Kotlin LSPs on Emacs Eglot

原始链接: https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html

这篇文章详细介绍了如何将 IntelliJ 等资源占用较大的 IDE 替换为高度定制的 Emacs **Eglot** 环境,以用于 Scala 和 Kotlin 等 JVM 语言开发。 虽然 Eglot 的极简设计避免了臃肿,但它缺乏针对特定语言服务器问题的开箱即用修复方案。作者展示了如何利用 Emacs Lisp 的“无限可扩展性”来克服这些限制。关键策略包括: * **基于 Lisp 的变通方法:** 使用 `advice-add` 拦截 JSON-RPC 有效载荷,从而在不等待上游修复的情况下,即时修补漏洞(如 Kotlin 的空补全字符串问题)或修复 JAR 文件的 URI 转换错误。 * **性能优化:** 通过自定义标志微调 JVM 语言服务器(Metals, intellij-server),并禁用语义标记刷新(semantic token refreshing)等激进功能,以防止 UI 闪烁。 * **统一工作流:** 利用 `use-package` 实现一致的按键绑定和钩子,同时使用 Nix flakes 和 `direnv` 确保开发环境的可复现性与隔离性。 通过将 IDE 视为一组模块化工具的集合而非庞大的整体,作者实现了一种既快速、高度个性化,又稳定且可扩展的编码体验。Emacs 使开发者能够弥合协议优先的简洁性与现代 JVM 开发复杂需求之间的鸿沟。

这份 Hacker News 讨论聚焦于“全能型” JetBrains IDE(如 IntelliJ 等)与轻量级、高度可定制编辑器(如 Emacs 或 Neovim)之间的争论。 **对 IntelliJ 的批评:** 批评者认为 JetBrains IDE 变得臃肿、资源占用高且过于复杂。一些用户反馈出现了性能下降、索引缓慢的问题,并对近期加入的 AI 功能感到不满。此外,人们还对该公司的背景及其部分功能的闭源性质表示担忧,一些开发者更倾向于使用能够深入了解工具链的开源替代方案。 **对 IntelliJ 的辩护:** 支持者认为 JetBrains 仍然是 JVM 语言的行业标准,它提供了其他编辑器难以复制的卓越的开箱即用重构、代码导航和调试功能。许多人强调,这是一款值得订阅费用的专业级工具,并指出所谓的“臃肿”往往源于复杂的项目需求,而非软件本身。 **“Emacs/Neovim” 替代方案:** 转向 Emacs 的用户表示,他们渴望的是一个“真正可定制”的环境。尽管承认其学习曲线陡峭且配置现代 LSP 支持需要耗费精力,但他们认为从长远来看,这种方式效率更高。
相关文章

原文

When Emacs 29 made eglot the built-in, default Language Server Protocol (LSP) client, many of us rejoiced.

It is lightweight, fast, adheres strictly to Emacs philosophy, and doesn’t try to reinvent the wheel.

However, being minimal means that when an LSP server steps out of line or acts quirky, eglot doesn’t provide a million customizable toggles to fix it out-of-the-box. Instead, it expects you to leverage the power of Emacs Lisp.

In this post, I will dissect my production-ready eglot setup (part of my heks-emacs configuration) which I use in my day-to-day work, with Scala and Kotlin (and some Java).

For reference, find my full Eglot config here: https://codeberg.org/jjba23/heks-emacs/src/branch/trunk/src/modules/eglot.el

We will walk through basic language setups, specialized workspace configuration handling, and dive deep into some advanced JSON-RPC and advice-based workarounds for Scala (Metals) and Kotlin that make development truly seamless from Emacs and liberate you from IntelliJ ☺️.

It’s not perfect, but it’s pretty darn close to perfection if you ask me, and the developer experience and speed that it enables is just wild. Thank you Emacs, thank you GNU, thank you Eglot! 🐂


Before looking at the code, let’s talk about why we are doing this. For years, the conventional wisdom stated that if you write JVM languages, especially Scala or Kotlin, you must use IntelliJ IDEA. The narrative claimed that these languages are too complex for a standard text editor.

But what do you actually get with IntelliJ? A massive, monolithic Java application that frequently hogs 8GB+ of RAM, locks up your system while “indexing pre-built binaries,” and forces you into a closed proprietary ecosystem.

Emacs turns this paradigm on its head through three core strengths:

  • The Unix Philosophy of LSP: Instead of a single IDE trying to compile, index, and render your code simultaneously, Emacs splits these duties. Eglot acts as a lean, protocol-first transport layer that talks to dedicated language servers via JSON-RPC.
  • Infinite Hackability: If IntelliJ has a bug in how it auto-completes Kotlin code, you are stuck waiting for JetBrains to issue a patch. In Emacs, you can write a 10-line Lisp advice function to intercept the network payload and patch the bug live in your editor buffer.
  • Unified Interface: You use the same text-manipulation utilities, text-jumping tools (xref), and completion frameworks (corfu, company, etc.) whether you are adjusting a Nix expression, editing a Markdown file, or refactoring a massive Scala service.

Hooks, Keybindings, and Initial Configurations #

Let’s start with how eglot is initialized. I use Elpaca and use-package to manage the configuration, ensuring it doesn’t download an external package since it is built-in (:ensure nil). Then I add some hooks to automatically start the language server for certain modes.

(use-package eglot
  :ensure nil
  :hook ((scala-ts-mode . eglot-ensure)
         (sh-mode . eglot-ensure)
         (markdown-mode . eglot-ensure)
         (markdown-ts-mode . eglot-ensure)
         (nix-ts-mode . eglot-ensure)
         (html-mode . eglot-ensure)
         (css-mode . eglot-ensure)
         (css-ts-mode . eglot-ensure)
         (html-ts-mode . eglot-ensure)
         (js-mode . eglot-ensure)
         (js-ts-mode . eglot-ensure)
         (kotlin-ts-mode . eglot-ensure)
         (yaml-mode . eglot-ensure)
         (yaml-ts-mode . eglot-ensure)
                  (before-save . eglot-format-buffer))
      )
  • Eglot-Ensure Everywhere: I hook eglot-ensure into almost every programming mode I use, adapting both classic modes and modern Tree-sitter (*-ts-mode) alternatives.
  • Auto-Formatting: Adding eglot-format-buffer to before-save guarantees code style compliance automatically every time a file hits the disk.

My keybindings are nested under the C-c i prefix, keeping them memorable and consistent across languages. The mnemonic keyword is “IDE” .

:bind (("C-c i i" . eglot-find-implementation)
       ("C-c i e" . eglot)
       ("C-c i k" . eglot-shutdown-all)
       ("C-c i r" . eglot-rename)
       ("C-c i x" . eglot-reconnect)
       ("C-c i a" . eglot-code-actions)
       ("C-c i m" . eglot-menu)
       ("C-c i f" . eglot-format-buffer)
       ("C-c i h" . eglot-inlay-hints-mode))
:init
(setq eglot-autoshutdown t
      eglot-confirm-server-edits nil
      eglot-report-progress t
      eglot-extend-to-xref t
      eglot-sync-connect 1
      eglot-connect-timeout 60
      eglot-autoreconnect t)

Then with these :init settings:

  • eglot-autoshutdown cleans up language server processes as soon as the last buffer managed by them is killed.
  • eglot-extend-to-xref allows Emacs’ cross-referencing commands to smoothly transition into external library files outside your workspace directory.

Fine-Tuning Server Definitions and Workspaces #

Under the :config block, we begin optimizing specific language servers. For instance, removing default configurations before re-adding custom entries prevents collisions.

:config
(setopt eglot-code-action-indications nil) 
(setq eglot-server-programs (assq-delete-all 'scala-mode eglot-server-programs))
(setq eglot-server-programs (assq-delete-all 'scala-ts-mode eglot-server-programs))
(setq eglot-server-programs (assoc-delete-all 'scala-ts-mode eglot-server-programs))

(add-to-list 'eglot-server-programs `(scala-ts-mode . ("metals"
                                                       "-Xmx4G"
                                                       "-XX:+UseZGC"
                                                       "-Dmetals.http=true"
                                                       :initializationOptions (:isHttpEnabled t))))

(setq eglot-server-programs (assoc-delete-all 'kotlin-ts-mode eglot-server-programs))
(add-to-list 'eglot-server-programs '(kotlin-ts-mode . ("intellij-server" "--stdio")))

Why these changes?

  • Scala (Metals): I pass specific JVM tuning flags directly to Metals (allocating a comfortable 4GB heap and utilizing the Z Garbage Collector for minimal latency). Also, enabling Metals HTTP communication via initialization options lets us hook into specialized UI features if needed.
  • Kotlin: I swap out standard options for the IntelliJ-backed Kotlin Language Server (intellij-server --stdio).

Global Workspace Configurations #

eglot-workspace-configuration lets you pass customized variables downstream to your language servers. This section of my configuration acts like a universal settings.json:

(setq-default eglot-workspace-configuration
              '(
                :metals ( :autoImportBuild "all"
                          :isHttpEnabled t
                          :superMethodLensesEnabled t
                          :showInferredType t
                          :enableSemanticHighlighting t
                          :inlayHints ( :inferredTypes (:enable t )
                                        :implicitArguments (:enable nil)
                                        :implicitConversions (:enable nil )
                                        :typeParameters (:enable t )
                                        :hintsInPatternMatch (:enable nil ))
                          :bloopJvmProperties ["-Xmx4G"])
                :haskell (:formattingProvider "ormolu")
                :typescript (:format (:baseIndentSize 0
                                                      :convertTabsToSpaces t
                                                      :indentSize 2
                                                      :semicolons "remove"
                                                      :tabSize 2))
                :javascript (:format (:baseIndentSize 0
                                                      :convertTabsToSpaces t
                                                      :indentSize 2
                                                      :semicolons "remove"
                                                      :tabSize 2))
                :rust-analyzer (:check (:command "clippy")
                                       :cargo (:sysroot "discover"
                                                        :features "all"
                                                        :buildScripts (:enable t))
                                       :diagnostics (:disabled ["macro-error"])
                                       :procMacro (:enable t))

                :yaml ( :format (:enable t)
                        :validate t
                        :hover t
                        :completion t
                        :schemas (
                                  https://codeberg.org/jjba23/pop-test/raw/branch/trunk/resources/json-schema/pop-test.json ["golden-test.yaml" "golden-test.yml" "pop-test.yaml" "pop-test.yml"]
                                  https://raw.githubusercontent.com/Vandebron/gh-mpyl/refs/heads/main/src/mpyl/schema/project.schema.yml ["project.yml"]
                                  https://json.schemastore.org/yamllint.json ["/*.yml"])
                        :schemaStore (:enable t))
                :nil (:formatting (:command ["nixfmt"]))))

Notable Configurations here:

  • Metals: Granular inlay hints are activated specifically for inferred types and type parameters while muting implicit conversions to keep buffers readable. (more options here: https://scalameta.org/metals/docs/editors/user-configuration/)
  • YAML Schema Mapping: Maps distinct internet-hosted JSON schemas straight to patterns of YAML files automatically.

Deep Dive: The Workarounds #

This is where things get interesting. Sometimes servers violate standard LSP expectations, requiring custom Emacs Lisp logic to bridge the gap.

Fixing Eldoc Overload #

By default, eldoc can easily get flooded by different feedback mechanisms. This block prioritizes structural code diagnostics over generic hover data:

(add-hook 'eglot-managed-mode-hook
          (lambda ()
                        (setq eldoc-documentation-functions
                  (cons #'flymake-eldoc-function
                        (remove #'flymake-eldoc-function eldoc-documentation-functions)))
                        (setq eldoc-documentation-strategy #'eldoc-documentation-compose)))

Kotlin Source Navigation (Jar URI Translation) #

When traversing into a dependency library using Kotlin, the server returns file references formatted as jar:///path/to/library.jar!/File.kt. Emacs can’t resolve this scheme directly out of the box, throwing errors when you try to jump to definition.

By wrapping Eglot’s URI translators with advice, we can map this custom scheme into something Emacs understands (especially alongside companion extensions like jarchive):

(defun heks/eglot-uri-to-path-kotlin (orig-fn uri &rest args)
  (if (and (stringp uri) (string-prefix-p "jar:///" uri))
      (apply orig-fn (replace-regexp-in-string "^jar:///" "jar:file:///" uri) args)
    (apply orig-fn uri args)))

(defun heks/eglot-path-to-uri-kotlin (orig-fn path &rest args)
  (if (and (stringp path) (string-prefix-p "jar:file:///" path))
      (replace-regexp-in-string "^jar:file:///" "jar:///" path)
    (apply orig-fn path args)))

(if (fboundp 'eglot-uri-to-path)
    (progn
      (advice-add 'eglot-uri-to-path :around #'heks/eglot-uri-to-path-kotlin)
      (advice-add 'eglot-path-to-uri :around #'heks/eglot-path-to-uri-kotlin))
  (progn
    (advice-add 'eglot--uri-to-path :around #'heks/eglot-uri-to-path-kotlin)
    (advice-add 'eglot--path-to-uri :around #'heks/eglot-path-to-uri-kotlin)))

Intercepting the Kotlin Empty newText Auto-Completion Bug #

A notorious issue in certain Kotlin LSP releases occurs during auto-completion. The server reports matching candidates, but mistakenly attaches a textEdit field containing an empty string (newText: ""). This causes Eglot to wipe out the word you are completing entirely.

To solve this, I intercept the incoming JSON-RPC response payloads, both synchronous and asynchronous. If a Kotlin completion candidate returns an empty string edit, we strip the `textEdit` attribute completely, forcing Eglot to fall back gracefully to standard prefix matching.

(defun my-jsonrpc-request-kotlin-fix (orig-fn connection method params &rest args)
  "Fix kotlin-lsp empty newText bug by removing textEdit to trigger Eglot fallback."
  (let ((result (apply orig-fn connection method params args)))
    (when (and (eq method :textDocument/completion)
               (derived-mode-p 'kotlin-mode 'kotlin-ts-mode)
               result)
      (let ((items (if (vectorp result) result (plist-get result :items))))
        (seq-do (lambda (item)
                  (let ((text-edit (plist-get item :textEdit)))
                                                            (when (and text-edit (equal (plist-get text-edit :newText) ""))
                      (plist-put item :textEdit nil))))
                items)))
    result))

(defun my-jsonrpc-async-request-kotlin-fix (orig-fn connection method params &rest args)
  "Fix kotlin-lsp empty newText bug in asynchronous Eglot requests."
  (if (and (eq method :textDocument/completion)
           (derived-mode-p 'kotlin-mode 'kotlin-ts-mode))
      (let* ((orig-success (plist-get args :success-fn))
             (new-success (lambda (result)
                            (let ((items (if (vectorp result) result (plist-get result :items))))
                              (seq-do (lambda (item)
                                        (let ((text-edit (plist-get item :textEdit)))
                                          (when (and text-edit (equal (plist-get text-edit :newText) ""))
                                            (plist-put item :textEdit nil))))
                                      items))
                            (funcall orig-success result)))
             (new-args (plist-put (copy-sequence args) :success-fn new-success)))
        (apply orig-fn connection method params new-args))
    (apply orig-fn connection method params args)))

(advice-add 'jsonrpc-request :around #'my-jsonrpc-request-kotlin-fix)
(advice-add 'jsonrpc-async-request :around #'my-jsonrpc-async-request-kotlin-fix)

Silencing Metals Semantic Refresh Flickering #

Scala Metals aggressively forces full buffer semantic token refreshes. In large projects, this results in visual layout flickering and unnecessary CPU strain. Disabling this also can solve some startup issues for Metals.

(defun my/eglot-disable-metals-semantic-refresh (orig-fn server)
  (let* ((caps (funcall orig-fn server))
         (workspace (plist-get caps :workspace))
         (tokens (plist-get workspace :semanticTokens)))
    (when tokens
      (plist-put tokens :refreshSupport :json-false))
    caps))

(advice-add 'eglot-client-capabilities :around #'my/eglot-disable-metals-semantic-refresh)

Companion Packages: Java and Compressed Archives #

To complete the setup, I load complementary minor modes outside of Eglot’s core file, ensuring smooth operations for Java and deep navigation for packed jars:

(use-package eglot-java
  :ensure t
  :after (eglot)
  :hook ((java-mode . eglot-java-mode)
         (java-ts-mode . eglot-java-mode)))

(use-package jarchive
  :ensure t
  :config
  (jarchive-mode))
  • eglot-java: Provisions proper workspace configurations specifically for Eclipse JDT LS seamlessly.
  • jarchive: Works harmoniously alongside the Kotlin JAR-URI translation hack, opening zipped up source containers into regular, viewable Emacs buffers.

The way I like it on reproducibility #

I generally don’t use the “global” system wide JDK installation, but I use isolated development reproducible shells with Nix flakes.

I’ll eventually probably move to using Guix, but for now package availability isn’t quite there for JVM world so Nix it is.

This way you can easily work on the same machine with many environments and projects (e.g. different Java versions) and no need for SDKMan or version managers, but clean isolated per-project reproducible builds.

So I create a flake.nix and add it to Git.

Kotlin development flake (TODO intellij-server via Nix):

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    systems.url = "github:nix-systems/default";
  };
  outputs = { systems, nixpkgs, ... }:
    let
      eachSystem = f:
        nixpkgs.lib.genAttrs (import systems)
        (system: f nixpkgs.legacyPackages.${system});
    in {
      devShells = eachSystem (pkgs: {
        default = pkgs.mkShell {
          buildInputs = with pkgs; [
            ktfmt
            ktlint
            kotlin
            jdk25
            nil
            just
            yaml-language-server
          ];
        };
      });
    };
}

Scala development flake.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    systems.url = "github:nix-systems/default";
  };
  outputs = { systems, nixpkgs, ... }:
    let
      eachSystem = f:
        nixpkgs.lib.genAttrs (import systems)
        (system: f nixpkgs.legacyPackages.${system});
    in {
      devShells = eachSystem (pkgs: {
        default = pkgs.mkShell {
          buildInputs = with pkgs; [
            scala_2_13
            jdk25
            metals
            sbt
            scalafmt
            scalafix
            scala-cli
            yaml-language-server
            coursier
          ];
        };
      });
    };
}

Then I load the flake with direnv so I create a .envrc file .

use flake

This way and inside Emacs I can use emacs-direnv to dynamically switch contexts inside Emacs LSPs and have even multiple running.

I also plug direnv into my Bash shell configurations and thus complete the development environment.

Conclusion #

Eglot’s minimal, built-in design doesn’t mean you have to settle for sub-par language server behavior. After all, you are using Emacs, so the power is infinite!

By intercepting communication at the JSON-RPC level via advice-add, you can tailor client-server behaviors exactly to your liking.

Happy hacking! ✨

联系我们 contact @ memedata.com