Emacs does not need LSP
Language-server-protocol (lsp) is standard to communicate information about a
current programming project to an editor integration, powered by a “language
server” for the specific programming language. Emacs has two major
implementations of this editor functionality: eglot (built-in) and lsp-mode.
Getting this set up is usually one of the first things that new users will want
to do, but in my experience one can get by for a surprisingly long time using
Emacs without needing to reach for eglot or lsp-mode. That is because many of
the functions that lsp-servers provide can be provided by Emacs through other
means.
As someone who quite often moves between different programming languages, I find the need to set up each lsp-server to a bit clunky. Despite being presented as a simple one-stop solution, you often need to research exactly what server to use, how to set it up, and to tailor it to your machine in some ways. For me, this only makes sense in a few languages that I use often, and even then it might not be the first thing I take care of.
In-buffer completion
The most obvious functionality is auto-complete (aka in-buffer completion). In
Emacs this is provided on multiple levels, which is sometimes confusing for new
users. There are many ways to show in-buffer completions, but they are all
powered by the same back-end functionality: completion-at-point-functions
(capf). This is a list of functions that run in order, only returning a value if
there is something to complete.
Some major modes (most notable perhaps lisp-interaction-mode) come with their
own buffer-local values of capf1 that provide buffer-specific completions.
completion-at-point will then move on to the global value if the buffer-local
value of capf includes the value t. It is therefore this global value that we
are often interested in modifying.
For this we can use the cape-package — standing for “Completion At Point
Functions”. Specifically, we can make liberal use of the cape-dabbrev-function.
This capf makes use of Emacs’ built-in dabbrev-functionality. Dabbrev will scan
all the words in the buffers of your choice and give you completions for words
that you have already used. This means that, while working on your project, you
have quick and easy access to all the keywords and function names in your
currently opened buffers.
This also works as a form of autocomplete for writing prose, and so we can
combine these two using cape-wrap-super:
(defun cape-language (&optional interactive) (interactive (list t)) (if interactive (cape-interactive #'cape-language) (cape-wrap-super #'cape-dabbrev #'cape-dict)))
This will combine the output cape-dabbrev and cape-dict2 into one capf that
can run at the same time (and therefore not block each other). For a simpler
contribution we also use the cape-keyword capf that comes with a number of
pre-configured programming language3 keywords. This is a bit of a pre-lsp
solution, where each editor provided support for each programming language. To
set up cape, simple add something like this to your init file:
(add-hook 'completion-at-point-functions #'cape-history) (add-hook 'completion-at-point-functions #'cape-keyword) (add-hook 'completion-at-point-functions #'cape-file) (add-hook 'completion-at-point-functions #'cape-language) (add-hook 'completion-at-point-functions #'cape-abbrev)
This is the order in which cape will try your capfs, with the first matching
blocking the rest. As mentioned earlier, locally bound capfs will supersede
these global values, and so python-completion-at-point will run before any of
these in python-mode buffers.
Another improvement we can do is to change the value of
cape-dabbrev-buffer-function. The value of this variable is the function that
returns the buffers to scan for dabbrev results. By default this is
cape-same-mode-buffers, meaning that only buffers in the same mode (org-mode,
python-mode, et cetera) will be used for results. This is a good default, but I
would rather have always have some relevant completion candidate than nothing.
So personally I set this value to cape-text-buffers, which will return all
buffers in text-mode or prog-mode buffers. This means extra niceties like
referring to practically anything you are working on when writing a commit
message in git.
Jump to definition and references
A second major feature of lsp-servers is “jump to definition” that allows you to go to the code that creates a function (its definition) to inspect its inner workings. There are a few different ways to do this.
The first is the package named dumb-jump that uses ag, rg, or simply grep to
find definition-matching strings. This works similarly to cape-keyword in that
it uses a pre-configured set of language-syntax elements to match
function-defining cases.4 I however do not personally use this.
Instead I use another of Minad’s wonderful additions to the Emacs’ ecosystem;
consult. I must confess that I slept on consult for far too long. I had it in
my config for a long time, but the number of (largely stand-alone) functions
made it a bit overwhelming to learn.5 What Consult offers is a suite of
improved versions of preëxisting Emacs utilities (and a few new ones building
upon said features), largely based on the completing-read.
Emacs has a built-in feature called imenu that scans the buffer’s structure for
top-level features (Org-mode and markdown headers, or in this case function
definitions) and allows you to quickly move to them. Consult of course expands
on this by instantly showing you the place you will move to, but it also offers
an extension of this feature: consult-imenu-multi (as opposed to the regular
consult-imenu). This will not only scan the current buffer, but also all other
same-mode buffers. Better yet, it integrates with Emacs’ project.el, and so only
checks buffers that belong to the same project (exempli gratia git repository).
As I usually have the relevant buffers open in a project I am working on, this
works very well as an alternative to dumb-jump, and the preview and subsequent
confirmation mean that I am not worried about jumping to the wrong place —
although of course at the cost of speed.
Then we of course have the venerable xref built into Emacs. This works a bit
differently depending on the programming language backend, and plugs into an
lsp-server automatically if it is available. Use xref-find-references6
Otherwise we can use etags-regen to automatically create and refresh the TAGS
file that keeps track of the project structure:
(use-package etags-regen :config (setq etags-regen-ignores '("*.pyc" ".git" ".venv" "venv" "node_modules")) (etags-regen-mode 1))
Consult can also be used to improve upon this as well by replacing the ui interaction component:
(setq xref-show-xrefs-function #'consult-xref
xref-show-definitions-function #'consult-xref)
The reason why I usually prefer using imenu is because it requires no trip to
inspect the state of any files on the system — everything is already loaded and
available, and perhaps 90% of the time it includes the things that I need (since
they are things I am working on).
Syntax checking
Here we can use the built-in flymake or the standalone flycheck. Both of these
make use of external syntax-checking tools for each language, and so do require
some more setup on the host machine than the above solutions. In fact, Flycheck
can even act as its own lsp-server just for syntax checking. But my experience
has still been that this is easier to deal with than lsp-servers that need to be
configured, started, reconnected, that crash, or have some other issue that need
to be dealt with; A simple unix-style shell command that prints text (and that
is then processed by Flymake or Flycheck) rarely needs much setup or maintenance
in my experience. This is a matter that I really just want out of my mind.
Conclusion
There are only so many things that can be done merely through these static solutions. For example, neither of our in-buffer completion or jump-to-definition solution will complete external libraries; only buffers that have already been opened. There is no hover information.
I am not ideologically opposed to the use of language servers, and I will sometimes intentionally start one if I expect to be working on a larger code base for a longer amount of time. But tools like the ones above are nevertheless incredibly useful because they work in all buffers and contexts.
One of the strengths of Emacs is how it acts as a sort of force-multiplier for
any task interacting with text, and how it builds upon improvements in one area
throughout the entire editor. Having some of the most critical lsp-like
abilities available anywhere you want without any extra effort is incredibly
powerful. This is especially noteworthy for things like cape-dabbrev since it
has made me so spoiled for autocompletion for everything I write.
I am sure that there are lots of different solutions and tools that I have missed that would work to replace even more lsp-based ones, and probably solutions that work better and/or are more elegant than the ones I use — the answer may even be to just use lsp. But honestly I only think about the matter of language server when I am talking with others. In day to day life I rarely feel the need for them at all. ❦
Footnotes:
For lisp-interaction-mode the value is (elisp-completion-at-point
t).
The results of cape-dict are decided by the value of the
cape-dict-file variable.
Here is the list of programming languages supported:
C++, C, Caml, Crystal, C#, D, Elixir, Erlang, f90, Go, Java, Javascript, Kotlin, Lua, Nim. Objective C, Perl, php, Purescript, Python, Ruby, Rust, Scala, Scheme, Swift, Julia, Thrift, sh.
This supposedly misidentifies things sometimes; as expected, it is “dumb” after all.
If you are using the built-in switch-to-buffer command (by default bound to
C-x b) I highly recommend switching it to consult-buffer instead. Instant
“previews” is super useful, and I often just spam C-n (N being right next to B)
to cycle through my open buffers instead of starting to write the exact name
that I want.
By default bound to M-?. To run xref-go-back press M-,.
