garrison
How can I prevent the LSP from invoking my custom Mix compiler?
Background
So I’ve been hacking away at a simple styled component library for Phoenix (source here if you’re curious - not done!) which uses a custom Mix compiler to compile the stylesheet.
I’d been dealing with a weird issue where the stylesheet gets recompiled multiple times (triggering multiple live reloads in the console), and I hadn’t been able to track down the root cause. I figured I would get around to making a post about it eventually.
Fast-forward to today, I was testing some config changes when I noticed that the compiler was writing a stylesheet even though it was disabled (via config). Obviously something strange was going on, so I investigated further, but after thoroughly instrumenting the code with IO.inspects (as one does) I was unable to figure out what was invoking the compiler - it wasn’t printing anything, but it was still writing the output!
At this point fully convinced my compiler was haunted, I tried killing the Phoenix server and inspecting the output stylesheet to see if it was still compiling. Which it was. Obviously I had a phantom process somewhere, so I checked the process list and, sure enough, there were a couple extra beam processes floating around.
I stared at the process list for about 10 seconds before it hit me. The LSP!
Question
Is there a canonical way to stop the LSP (elixir-ls in this case) from invoking my Mix compiler?
One way to do this which I think would work is to fingerprint the LSP calls via the args that they pass into the compiler’s run/1 function. In particular, a bit of logging reveals that the LSP calls the compiler with the --return-errors flag (and a couple other unusual flags), which I presume is uncommon in normal usage. But this feels like a hack.
Also, does anyone know if and how Surface solves this problem with their compiler? A quick look through their code/issues didn’t reveal anything pertaining to the LSP.
Most Liked
lukaszsamson
ElixirLS invokes the equivalent of mix compile (Mix.Task.run("compile", opts)) whenever one of the open files gets saved or changes in the watched files get detected. Instead of unreliable tracking of options passed to the task (which can change) I’d advise you to make your compiler detect changes and recompile only when needed. You can return :noop if e.g. the hash of files did not change since last successful compilation.
garrison
I took some time to properly debug what was going on here. I first nuked my .elixir_ls directories and upgraded to the latest version (0.17.10). Coincidentally, I see you released this version while I was typing up my last post!
I have found the source of the behavior I was seeing. It’s very specific to the way I happened to be testing my code, so I’ll explain in detail.
The corp_style project is meant to be used as a library (it generates scoped css styles for heex components). The output path (and a couple other things) are configured in a manner similar to the esbuild and tailwind packages that ship with Phoenix.
The config is placed under a “profile” with an arbitrary key, though currently I only support one such key (default). It looks like this:
config :corp_style,
default: [
out_path: "some/path/to.css",
]
Then the compiler loads the config by the usual method, Application.get_env(:corp_style, :default). If the result is truthy, it compiles the CSS. If the result is nil (i.e. the library has not been configured), it does nothing.
While testing, I renamed the key from default to something else to disable the compiler. However, the compiler was still running, which is the bug that led to me opening this thread.
I now see what is happening here is that elixir-ls is, in fact, correctly reloading the config and recompiling, but the config is merged instead of overwritten on reload. So, when I rename default to something else and elixir-ls recompiles, the default key still exists, and so my compiler still runs.
I find this merging behavior very surprising. I understand that Elixir’s config deep-merges keys, but I assumed (and I think most others would assume) that when you remove a key from your config that it’s actually gone. Perhaps this is why Phoenix enforces a server restart after a config change?
Anyway, now that I know what was actually causing the behavior I was seeing I can work around it more easily. Thank you for your help!
garrison
I put together a minimal reproduction. The bug only seems to occur with a dependency (note that I only tested a path dependency, not git or hex dependencies).
Steps to reproduce:
- Clone these repositories into the same directory.
Note that the latter has a relative path dependency on the formerhttps://git.sr.ht/~garrisonc/exls_bug_config_merge https://git.sr.ht/~garrisonc/exls_bug_config_merge_parent - Open
exls_bug_config_merge_parent/config/config.exsin an LSP-enabled editor. - Note that a
warningmessage has been printed to the LSP log with a dump of the config keys for the child project. This message comes from the Mix compiler task. - Change the config key in the parent’s
config.exsto any other value. - Note that the
warningmessage now prints multiple keys, indicating that they have been merged. - Repeat steps 4 and 5 indefinitely.
Tested in neovim on macos via nvim-lspconfig.
christhekeele
Additionally, IIRC ElixirLS runs with MIX_ENV=“test” by default. You could always check the value of Mix.env() in your mix.exs and not add your compiler to the def project, do: [compilers: ...] list when it is "test".
lukaszsamson
However, the issue I was running into was that when I changed my
config.exsto disable the compiler it was still being invoked by the LSP. I assumed at the time that the LSP was hot reloading the code similar to the Phoenix server, and so had to be restarted to re-read the config.
Config reload happens in elixir-ls/apps/language_server/lib/language_server/build.ex at 2d6c4b8784762e1c8183d0b73c0eeb1f93a6779e · elixir-lsp/elixir-ls · GitHub
For the record I did implement the
--return-errorsfingerprint hack, and it works fine for the time being, but I’m not happy with it either.
If you need to check it via fingerprint hacks, then setting an environment variable in ElixirLS configuration may be a better option.







