Escape IntelliJ: Scala and Kotlin LSPs on Emacs Eglot

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)
;;
formatting
(before-save . eglot-format-buffer))
;;
………………
;;
more config
)
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)
;;
Cleans up Emacs 31 visual noise
;;
Clean slate for Scala and Kotlin
(
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
()
;;
Show flymake diagnostics first.
(
setq
eldoc-documentation-functions
(cons #’flymake-eldoc-function
(remove #’flymake-eldoc-function eldoc-documentation-functions)))
;;
Show all eldoc feedback.
(
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
)))
;;
If the server sent an empty newText, strip textEdit completely
;;
so Eglot falls back to replacing the actual prefix.
(
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! ✨