Published:
Last Edited:
n/a

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:

1

For lisp-interaction-mode the value is (elisp-completion-at-point t).

2

The results of cape-dict are decided by the value of the cape-dict-file variable.

3

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.

4

This supposedly misidentifies things sometimes; as expected, it is “dumb” after all.

5

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.

6

By default bound to M-?. To run xref-go-back press M-,.

Tags: emacs technology