Escape IntelliJ: Scala and Kotlin LSPs on Emacs Eglot

Lobsters Hottest Tools

Summary

A detailed guide on setting up Emacs Eglot for Scala and Kotlin development, offering a lightweight alternative to IntelliJ IDEA with custom LSP configurations.

<p><a href="https://lobste.rs/s/ivtjcv/escape_intellij_scala_kotlin_lsps_on">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 07/21/26, 10:38 AM

# Emacs Eglot for Scala and Kotlin (JVM) Source: [https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html) 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](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](https://www.jetbrains.com/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](https://www.gnu.org/software/emacs/manual/html_node/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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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/](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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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\)[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#intercepting-the-kotlin-empty-codenewtextcode-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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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](https://direnv.net/)so I create a`\.envrc`file \. ``` use flake ``` This way and inside Emacs I can use[emacs\-direnv](https://github.com/wbolster/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[\#](https://jointhefreeworld.org/blog/articles/emacs/emacs-eglot-scala-kotlin/index.html#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\! ✨

Similar Articles

Lisp in Vim (2019)

Hacker News Top

A detailed comparison of Slimv and Vlime, two Vim plugins for interactive Lisp programming, covering setup, features, and recommendations.

Is anyone still using Emacs?

Lobsters Hottest

A personal reflection on the author's decades-long relationship with Emacs, including switching to VSCode and IntelliJ, and ultimately returning to Emacs for its unique features.

Transpiling from Python into Lisp

Lobsters Hottest

LispE is an open-source Lisp dialect by NAVER that combines functional and array language features with support for AI libraries like PyTorch, llama.cpp, and MLX. It runs as both a native application and WebAssembly library with thread support and modern functional programming capabilities.

De‐bloating Javascript

Lobsters Hottest

LispE is a compact Lisp dialect developed by NAVER that combines functional and array language features, with support for AI libraries like PyTorch and llama.cpp.