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.

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.

3 Likes

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".

1 Like

Indeed this would be a better solution, and my compiler is already doing this in a rudimentary way (only overwriting the file if the output has changed, which is effectively the same except you eat the cost of reading the file - Surface also takes this approach).

However, the issue I was running into was that when I changed my config.exs to 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. I’m now beginning to wonder if that’s the case, though, because after posting I actually tracked down the lines that invoke mix compile and I’m not sure why it would present with that behavior.

As I write this I’m realizing maybe the lack of proper reloading is because I’m using a path dependency (on the compiler project) from another (Phoenix app) project to test it, and the LSP might not be properly recompiling the path dependency. I will have to test this further.

For the record I did implement the --return-errors fingerprint hack, and it works fine for the time being, but I’m not happy with it either.

This is a good point, I assumed it ran under dev. Maybe this has something to do with the path dependency not being recompiled, since it’s running in a different environment.

Thanks to you both for the replies! I’ll take a closer look at why the dependency wasn’t recompiling.

However, the issue I was running into was that when I changed my config.exs to 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-errors fingerprint 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.

1 Like

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!

3 Likes

This looks like a bug. Can you create a small repo that reproduces it?

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:

  1. Clone these repositories into the same directory.
    https://git.sr.ht/~garrisonc/exls_bug_config_merge
    https://git.sr.ht/~garrisonc/exls_bug_config_merge_parent
    
    Note that the latter has a relative path dependency on the former
  2. Open exls_bug_config_merge_parent/config/config.exs in an LSP-enabled editor.
  3. Note that a warning message 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.
  4. Change the config key in the parent’s config.exs to any other value.
  5. Note that the warning message now prints multiple keys, indicating that they have been merged.
  6. Repeat steps 4 and 5 indefinitely.

Tested in neovim on macos via nvim-lspconfig.

2 Likes

Thanks, I opened Config is merged and not reloade on rebuild · Issue #1030 · elixir-lsp/elixir-ls · GitHub for tracking

1 Like