Simple per-project setup

I was doing another pass to this idea and checking how the vscode-haskell plugin and ghcup default behaviors with minimal tweaks will work.

I browse vscode-haskell plugin, hie-bios, haskell-language-server and cabal to have a better understanding of the current interaction of the pieces.

The vscode-haskell plugin has some sensible defaults. One should able to set a specific ghc version via cabal.project. If no ghc is specified, the default is to use the recommended. If hls is unspecified, the latest hls version that supports the project required ghc version is picked. If cabal is unspecified the latest cabal is picked.

For this the vscode plugin might install latest ghc, cabal, hls to resolve via hie-bios the expected ghc version and then downgrade the selection (see findHaskellLanguageServer in hlsBinaries.ts).

There is a known issue haskell-language-server#2800 since 2022 that is a stopper for cabal.project to specify the ghc version, unless that specific ghc version is already installed. Related, in cabal#10188 it’s argued that using with-compiler to infer the ghc version is not actually the right way since that is a path which might not reflect the version in it’s name. So if the executable is not found using it as an heuristic for the ghc version is not 100% right. So the with-compiler is not actually a desired way of indicating the ghc compiler version.

Letting the bug aside and issue aside, reading the source code of vscode-haskell plugin I noticed that the default toolchain behavior is true only on fresh installs. If a new version of cabal is released and if the environment is not fresh then the already installed cabal is picked. Similar, if a version happen to be set via ghcup, the default cabal is the one set.

I think we have different treatment on the toolchain at this point:

  1. By default the ghc is not pinned, every time the plugin loads it will try to get the recommended ghc. cabal init does not produce a cabal.project with a with-compiler.
  2. If I want to bump cabal or hls I need to manually tweak something. Once a cabal or hls is installed that will not be upgraded.

This also means that switching between machines / sharing code can end up with different toolchain setup as it depends on temporal conditions or history. Example:

  1. Today I have a determined ghc and cabal based on defaults
  2. Some months later I will get an updated ghc but cabal will still be pinned to the one originally installed.
  3. On a new machine I get the updated ghc and also a new cabal.

Part of the proposal was creating the notion of “ghc & toolchain project version”. This seems aligned with the concept of the “the ghc of the project” the vscode plugin has. Digging into the vscode-haskell plugin I noticed that such functionality is implemented in haskell-language-server --project-ghc-version. I’m surprised that this logic is not actually part of either cabal or ghcup. After reading hie-bios readme I understand why it shouldn’t be part of cabal. But why not promote that to ghcup?

One of my pain points with “simpler haskell” setup is also to have a single source of truth that can be used in CI. I though that ghcup run would be a way to get around that, but there logic of picking the right tool is in the vscode plugin itself. So we can’t use it in CI. There is no “find the project’s ghc” feature in ghcup. Extracting that from a cabal.project requieres hls/hie-bios and yet is not a desired way to specify the ghc version.

Another example of why all this is a pain point for beginners is that in some CS carrers students might use Haskell in a couple of courses. Unless they become proficient in the tooling or reinstall Haskell at the beginning of the course there si no way the teachers could provide a simple template that will work with a specific version of ghc (As a reminder I do want this to work with ghc and cabal without stack, sorry!).

I think that having a .ghcup file in the project that will specify tool versions will be an improvement:

  • The .ghcup file can be interpreted by ghcup
  • The vscode plugin will not need to use latest hls to potentially downgrade it’s selection, ghcup can provide the expected versions of the tools, the plugin will do the popup validation to install
  • CI can leverage the same toolchain specification
  • It does not use path as in with-compiler.

It pushes ghcup to be a bit more responsible (or be able to) setup the environment, which according to hie-bios it should be each project to setup the environment.

On the proposal of adding hooks to cabal to address this it still bothers me that we need to through phases to have a complete environment: ghcup, install cabal, use cabal to find ghc. If we have a description we can download and install in parallel.

Related issues/PRs

2 Likes

I’m afraid I think that’s bad DevX to update the entire toolchain just because you restarted your VSCode.

If you truly want that, you can achieve that easily via configuration:

{
  "haskell.toolchain": {
    "hls": "latest",
    "cabal": "latest",
    "stack": "latest"
  }
}

this will still figure out the ghc version corresponding to the project.

See https://github.com/haskell/vscode-haskell?tab=readme-ov-file#setting-a-specific-toolchain

I guess you could set up another of those popup windows that will warn you there are new hls/cabal/etc. versions and whether you want to upgrade. But tbh, that’s one of the reasons I don’t use VSCode, because I feel I’m playing arcade with closing windows.

It should be fine if the compiler is project-specific and each project specifies the required compiler version.

Not really, since there’s no guarantee that latest HLS will work with your project GHC. Every HLS version only supports a limited range of GHCs.

Even blindly updating cabal can lead you into severe regressions.

It was previously said that the main thing to pin is ghc. Using newer cabal as they become available should not break things.

I think that the smallest change that would be an improvement is to have a .ghc-version file.

The vscode plugin could use that directly if available and avoid the discovery via with-compiler.

Then it would be good to move the project toolchain algorithm from the vscode plugin to ghcup directly where other editors and CI could leverage it.

This assumes that using latest cabal and latest compatible hls is fine. Which I am comfortable with. Even if we will get a “there is a new cabal version should we update?” Message on vscode.

Finally cabal init could give the option to generate a .ghc-version file.

If there are no red flags I can try to make progress in this direction. I think it will offer better DX for newcomers and intermediate experienced Haskellers.

1 Like

This is not the reality.

A recent regression with latest cabal is Cabal 3.16.0.0 chooses discovered ghc-pkg binary over user-provided binary, leading to version mismatch when doing WASM builds with GHCup-managed GHC · Issue #11373 · haskell/cabal · GitHub

In 5 years of updating cabal in ghcup, I’ve seen many regressions.

And updating HLS to latest can break LSP support.

so you would rather pin cabal also per project? That would be more conservative and aligned with the current vscode plugin logic that prefer whatever cabal is already installed. Pinning it (maybe with a .cabal-version :see_no_evil_monkey:) would bring the benefit of same environment across devices.

On HLS i said latest compatible, to avoid breaking. This will not get rid of regression, i had experienced those to. But I think is reasonable to treat HLS as ever green / auto update by default as it’s more used interactively.

I don’t like to clutter top level files and using .{tool}-version for everything seems messy. Maybe a .ghcup would be cleaner but I liked the agnostic nature of the former.

I’m having a hard time following your use case.

When I work on my own projects, then yes, I rarely update cabal if it works, because there’s a chance it will stop working after an update. I update it only when I absolutely must.

So I’m not really thinking about this in terms of “pinning per project”. I’m deciding that for my whole machine in general which cabal version to use. And I want to carry out the update myself, not have my editor pull tricks in the background.

This already exists, as I have linked you earlier: GitHub - haskell/vscode-haskell: VS Code extension for Haskell, powered by haskell-language-server

VScode has project/workspace specific settings. E.g. you can create .vscode/settings.json and add the following:

{
  "haskell.toolchain": {
    "hls": "1.6.1.1",
    "cabal": "latest",
    "ghc": "9.6.5",
    "stack": null
  },
  "haskell.manageHLS": "GHCup"
}

What we indeed don’t have is a configuration that says "latest HLS compabitle with the specified GHC.

That seems like a reasonable feature request for vscode-haskell.

Using the vscode settings to pin the ghc version has some limitations.

  • As an intermediate user with a handful of projects I might need different ghc versions. Usually vscode settings are per machine. Using profiles or per project settings is a workaround but not the most common thing.
  • The pin is only usable by vscode. A terminal will not have access to it. For beginners this means that they need to set the installed version in ghcup and make it system wide. In the initial comment I showed a Makefile that pins it, but the pin is in two places.
  • CI, other editors, other collaborators won’t read the pin on vscode machine settings
  • As a teacher it would be great to offer a project template that only requires ghcup in path.

Ok so you prefer manual update cabal. It matches the logic of prioritizing the installed cabal that vscode plugin does.

I think that exists, is the behavior you get if you pin ghc and not include hls in the vscode settings.


My current proposal would be

  1. Make vscode plugin read .ghc-version (if exist) as a way to discover the project required ghc. Otherwise fallback to the current logic that leverage with-compiler.
  2. Add ”auto” as a possible value in the vscode setting that matches the default behavior. This is equivalent to not having the key/value for a tool actually, but more explicit. The meaning will differ per tool:
    • for ghc: lookup per project, otherwise latest installed or recommended.
    • for cabal/stack: latest installed or latest.
    • for hls: latest compatible with ghc. Might trigger update
  3. Allow ghcup run to use auto in each tool.
  4. Find a way for vscode plugin to leverage the version resolution in ghcup directly. Might need some additions to ghcup and checking ghcup version in the plugin since we will need to prompt the user the version to be installed.
  5. Allow cabal init to generate .ghc-version

Yes. That’s an entirely different use case and has nothing to do with VSCode.

I’m not really sure what you’re trying to achieve. It appears you want something like direnv. I passionately dislike such solutions.

GHcup has a ghcup run command that allows you to execute a command with a specific toolchain visible (that’s in fact what the VSCode extension uses too). You can implement your needs on top of that with direnv:

  • direnv sets variables such as GHCUP_GHC_VER etc.
  • have a bash alias or script that does something like ghcup run --ghc $GHCUP_GHC_VER -- sh
1 Like

I am failing :sweat_smile:.

I want the simplest setup possible for newcomers and intermediate Haskellers.

  • as little dependencies as possible: ghcup, vscode or whatever editor user might want.
  • Single source of truth for editor and terminal at project agnostic of the editor. So each project that might come from templates can declare the ghc version, and the users are not bound to use the same editor and find how to configure them. Terminal scripts/make can also leverage the configuration.
  • Intermediate Haskellers that want CI can leverage and keep configuration in sync. Bumping the ghc can happen in a single place.

I know and use in some context direnv, nix, etc. But they are not entry level. Beginners struggle to set environment variables properly.

1 Like

Well, now we’re getting closer. So it has in fact nothing to do with VSCode and we should stop talking about VSCode.

The next question then is when do those “behind the scenes updates” happen? When you start the shell? When you start the editor? When the users says so explicitly? When a configuration file changes?

The way I can see this work is basically:

  • you configure direnv to add a custom PATH when you enter the project directory (it could be relative, e.g. <project-dir>/.ghcup-path
  • you configure direnv to add a custom command var, such as TOOLCHAIN_RESOLVE="cabal configure --with-compiler=9.6.7 && ghcup run --ghc 9.6.7 --cabal latest --hls latest --path=<project-dir>/.ghcup-path"
  • we’d have to teach ghcup the --path=dir option, which means it will drop the ghc/hls/cabal symlinks there
  • then the user can invoke $TOOLCHAIN_RESOLVE

The remaining question then is:


Edit:

To find the latest supported HLS version with jq (I ran this question through claude, because jq’s interface is awful) you’d do something like:

curl --silent https://raw.githubusercontent.com/haskell/ghcup-metadata/refs/heads/develop/hls-metadata-0.0.1.json | jq -r --arg ghc "9.6.6" --arg
 arch "A_64" --arg platform "Linux_UnknownLinux" '
    to_entries
    | map(select(.value[$arch][$platform] // [] | contains([$ghc])))
    | map(.key)
    | last
  '

Oh! I didn’t realize that. It definitely feels to me that there is valuable logic trapped in the vscode extensions it will be useful for other editors. And I see ghcup a good place for that logic to live.

I thought that the hls-powered information in ghcup tui was using that metadata.

There are many flavors and options. I am vouching to only need ghcup, vscode and maybe make. I find that more minimal.

My reasoning is that

  • ghcup already has latest and recommended as toolchain options.
  • ghcup run already gives you an environment with the toolchain setup.
  • How can we move the logic from vscode to ghcup so we can use the same logic? auto could be that option, reading from the current directory the .ghc-version as input information if available.

With that the vscode plugin will not own the logic, ghcup will. It is easier to reuse it in CI, other editors and terminal.

For terminals you can use direnv to setup PATH to expose the chosen toolchain. But if not a Makefile can do the trick for more beginners users. ghcup run --ghc auto --cabal auto -- cabal run. At the beginning I shared this that is more or less what I’ve been using

If we aim for something that is ergonomic for direnv good, but I would like to find something that also works without that.

If the auto option is not liked we can maybe have a separate command toolchain-resolve that might output exact versions in a way vscode can use it for prompts, and also suitable for ghcup run something like ghcup run $(ghcup toolchain-resolve) -- cabal run.

1 Like

hls-powered is related to the currently active HLS. Otherwise basically all recent GHC versions could be considered HLS powered, because there’s some HLS version out there that does. Then what’s the point of that tag?

I’m vouching for the unix principle.

GHCup is an installer and nothing else. Cabal is a build tool. GHC is a compiler, HLS is an LSP server.

I don’t want to add bloat to GHCup to accomodate complicated use cases that can be implemented in other ways.

That said, there’s this ticket: `ghcup provision` (or `ghcup setup-env`) etc. · Issue #989 · haskell/ghcup-hs · GitHub

I want all new features to be idiomatic and in some way composable. So that means:

  • behavior of ghcup install will not change
  • behavior of ghcup run will not change
  • if we want some “read this file and do stuff” it will have to be a dedicated new command, such as ghcup provision

What I also don’t want is ghcup starting to manage the environment (setting env vars etc.). It must remain predictable.

@bcardiff I’m going to cook something up this weekend wrt `ghcup provision` (or `ghcup setup-env`) etc. · Issue #989 · haskell/ghcup-hs · GitHub …let’s collaborate on that and move the discussion there!

1 Like