From 29b858e42fc0abd88382581ed835f614dbd3e894 Mon Sep 17 00:00:00 2001 From: Andrei Dziahel Date: Fri, 31 Jul 2026 00:04:59 +0200 Subject: [PATCH] feat: add devcontainer feature and template for Haskell toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an official devcontainer feature (`haskell-toolchain`) and template (`haskell`) to close #123. Feature: - Installs GHC 9.14.1, cabal 3.16.1.0, Stack 3.11.1, HLS 2.14.0.0 via ghcup - Curated formatters: ormolu 0.8.2.0, fourmolu 0.20.0.0 (the only linters compatible with GHC 9.14/base 4.22; hlint, stylish-haskell, cabal-fmt are documented as incompatible and available via globalPackages escape hatch) - 8 options: version, installStack, installHLS, installStackGHCupHook, adjustBash, installLinters, globalPackages, globalLibraries - Versions pinned in versions.ncl, which imports from 9.14/bookworm.ncl and 9.14/_globals.ncl — single source of truth shared with the Docker images - Non-root install via sudo -iu _REMOTE_USER, mirroring devcontainers-extra - install.sh rendered from install.sh.tpl.ncl via Nickel - Multistage Dockerfile.test for cached local testing with podman - 5 scenario tests + autogenerated test.sh Template: - Clone-and-go cabal helloworld project on debian-12 base - VS Code extensions (haskell.haskell, haskell-lang-server) - Format-on-save with fourmolu CI: - feature-test.yml: 5-job matrix (debian/ubuntu × amd64/arm64) - feature-publish.yml: tag-triggered OCI publish to ghcr.io - template-lint.yml: PR validation - feature-versions-sync.yml: weekly drift check --- .github/workflows/feature-publish.yml | 31 +++ .github/workflows/feature-test.yml | 91 ++++++++ .github/workflows/feature-versions-sync.yml | 39 ++++ .github/workflows/template-lint.yml | 27 +++ README.md | 19 ++ .../src/haskell-toolchain/Dockerfile.test | 27 +++ .../src/haskell-toolchain/LICENSE | 21 ++ .../src/haskell-toolchain/README.md | 103 +++++++++ .../devcontainer-feature.json | 52 +++++ .../src/haskell-toolchain/install.sh | 190 +++++++++++++++++ .../src/haskell-toolchain/install.sh.tpl.ncl | 196 ++++++++++++++++++ .../src/haskell-toolchain/versions.ncl | 13 ++ .../_default/devcontainer.json | 6 + .../haskell-toolchain/_default/scenario.sh | 16 ++ .../escape-hatch/devcontainer.json | 9 + .../escape-hatch/scenario.sh | 18 ++ .../no-hls/devcontainer.json | 8 + .../test/haskell-toolchain/no-hls/scenario.sh | 18 ++ .../no-linters/devcontainer.json | 8 + .../haskell-toolchain/no-linters/scenario.sh | 21 ++ .../no-stack/devcontainer.json | 8 + .../haskell-toolchain/no-stack/scenario.sh | 18 ++ .../test/haskell-toolchain/test.sh | 28 +++ .../haskell/.devcontainer/devcontainer.json | 15 ++ devcontainer-template/src/haskell/.gitignore | 7 + .../src/haskell/.vscode/extensions.json | 6 + .../src/haskell/.vscode/settings.json | 9 + devcontainer-template/src/haskell/LICENSE | 21 ++ devcontainer-template/src/haskell/README.md | 55 +++++ .../src/haskell/devcontainer-template.json | 8 + .../src/haskell/helloworld.cabal | 12 ++ devcontainer-template/src/haskell/src/Main.hs | 4 + devcontainer-template/test/haskell/test.sh | 30 +++ 33 files changed, 1134 insertions(+) create mode 100644 .github/workflows/feature-publish.yml create mode 100644 .github/workflows/feature-test.yml create mode 100644 .github/workflows/feature-versions-sync.yml create mode 100644 .github/workflows/template-lint.yml create mode 100644 devcontainer-feature/src/haskell-toolchain/Dockerfile.test create mode 100644 devcontainer-feature/src/haskell-toolchain/LICENSE create mode 100644 devcontainer-feature/src/haskell-toolchain/README.md create mode 100644 devcontainer-feature/src/haskell-toolchain/devcontainer-feature.json create mode 100755 devcontainer-feature/src/haskell-toolchain/install.sh create mode 100644 devcontainer-feature/src/haskell-toolchain/install.sh.tpl.ncl create mode 100644 devcontainer-feature/src/haskell-toolchain/versions.ncl create mode 100644 devcontainer-feature/test/haskell-toolchain/_default/devcontainer.json create mode 100755 devcontainer-feature/test/haskell-toolchain/_default/scenario.sh create mode 100644 devcontainer-feature/test/haskell-toolchain/escape-hatch/devcontainer.json create mode 100755 devcontainer-feature/test/haskell-toolchain/escape-hatch/scenario.sh create mode 100644 devcontainer-feature/test/haskell-toolchain/no-hls/devcontainer.json create mode 100755 devcontainer-feature/test/haskell-toolchain/no-hls/scenario.sh create mode 100644 devcontainer-feature/test/haskell-toolchain/no-linters/devcontainer.json create mode 100755 devcontainer-feature/test/haskell-toolchain/no-linters/scenario.sh create mode 100644 devcontainer-feature/test/haskell-toolchain/no-stack/devcontainer.json create mode 100755 devcontainer-feature/test/haskell-toolchain/no-stack/scenario.sh create mode 100755 devcontainer-feature/test/haskell-toolchain/test.sh create mode 100644 devcontainer-template/src/haskell/.devcontainer/devcontainer.json create mode 100644 devcontainer-template/src/haskell/.gitignore create mode 100644 devcontainer-template/src/haskell/.vscode/extensions.json create mode 100644 devcontainer-template/src/haskell/.vscode/settings.json create mode 100644 devcontainer-template/src/haskell/LICENSE create mode 100644 devcontainer-template/src/haskell/README.md create mode 100644 devcontainer-template/src/haskell/devcontainer-template.json create mode 100644 devcontainer-template/src/haskell/helloworld.cabal create mode 100644 devcontainer-template/src/haskell/src/Main.hs create mode 100755 devcontainer-template/test/haskell/test.sh diff --git a/.github/workflows/feature-publish.yml b/.github/workflows/feature-publish.yml new file mode 100644 index 00000000..7bc9be2c --- /dev/null +++ b/.github/workflows/feature-publish.yml @@ -0,0 +1,31 @@ +name: Feature Publish + +on: + push: + tags: + - 'feature-v*' + +jobs: + publish: + runs-on: ubuntu-24.04 + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm install -g @devcontainers/cli@0.88.0 + - name: Render install.sh from template + working-directory: devcontainer-feature/src/haskell-toolchain + run: | + nickel export --format text <<'Nickel' > install.sh + let tpl = import "install.sh.tpl.ncl" in + tpl.install_sh + Nickel + chmod +x install.sh + - name: Package and publish feature + working-directory: devcontainer-feature + run: | + devcontainer features package src --output-dir /tmp/feature-output + devcontainer features publish /tmp/feature-output/haskell-toolchain \ No newline at end of file diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml new file mode 100644 index 00000000..883a6788 --- /dev/null +++ b/.github/workflows/feature-test.yml @@ -0,0 +1,91 @@ +name: Feature Test + +on: + pull_request: + branches: + - master + paths: + - 'devcontainer-feature/**' + - 'devcontainer-template/**' + - '9.14/_globals.ncl' + - '9.14/bookworm.ncl' + - '.github/workflows/feature-test.yml' + push: + branches: + - master + paths: + - 'devcontainer-feature/**' + - 'devcontainer-template/**' + - '9.14/_globals.ncl' + - '9.14/bookworm.ncl' + +jobs: + test: + name: Test feature (${{ matrix.distro }}/${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - distro: debian:bookworm + tag: debian-bookworm + arch: amd64 + runner: ubuntu-24.04 + - distro: debian:bookworm + tag: debian-bookworm + arch: arm64 + runner: ubuntu-24.04-arm + - distro: ubuntu:24.04 + tag: ubuntu-2404 + arch: amd64 + runner: ubuntu-24.04 + - distro: ubuntu:24.04 + tag: ubuntu-2404 + arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm install -g @devcontainers/cli@0.88.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build toolchain layer (cached) + uses: docker/build-push-action@v6 + with: + context: devcontainer-feature + file: devcontainer-feature/src/haskell-toolchain/Dockerfile.test + target: toolchain + load: true + tags: haskell-feature-toolchain:${{ matrix.tag }}-${{ matrix.arch }} + cache-from: type=gha,scope=toolchain-${{ matrix.tag }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=toolchain-${{ matrix.tag }}-${{ matrix.arch }} + build-args: | + BASE_IMAGE=${{ matrix.distro }} + _REMOTE_USER= + _REMOTE_USER_HOME= + + - name: Run test.sh against cached toolchain + run: | + docker run --rm \ + -e _REMOTE_USER= \ + -e _REMOTE_USER_HOME= \ + -v "$(pwd)/devcontainer-feature/test/haskell-toolchain/test.sh:/tmp/test.sh" \ + haskell-feature-toolchain:${{ matrix.tag }}-${{ matrix.arch }} \ + bash -c 'chmod +x /tmp/test.sh && /tmp/test.sh' + + shellcheck: + name: Shellcheck + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Run shellcheck on all shell scripts + run: | + shellcheck -x \ + devcontainer-feature/src/haskell-toolchain/install.sh \ + devcontainer-feature/test/haskell-toolchain/test.sh \ + devcontainer-feature/test/haskell-toolchain/*/scenario.sh \ + devcontainer-template/test/haskell/test.sh \ No newline at end of file diff --git a/.github/workflows/feature-versions-sync.yml b/.github/workflows/feature-versions-sync.yml new file mode 100644 index 00000000..59c19db6 --- /dev/null +++ b/.github/workflows/feature-versions-sync.yml @@ -0,0 +1,39 @@ +name: Feature Versions Sync + +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +jobs: + check-drift: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Check for version drift + run: | + curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh + source ~/.ghcup/env + + GHC_PINNED=$(grep 'ghc_version' devcontainer-feature/src/haskell-toolchain/versions.ncl | grep -oP '"[^"]+"' | tr -d '"') + HLS_PINNED=$(grep 'hls_version' devcontainer-feature/src/haskell-toolchain/versions.ncl | grep -oP '"[^"]+"' | tr -d '"') + GHCUP_PINNED=$(grep 'ghcup_version' devcontainer-feature/src/haskell-toolchain/versions.ncl | grep -oP '"[^"]+"' | tr -d '"') + + GHC_LATEST=$(ghcup list -t available 2>&1 | grep '^ ghc' | grep 'recommended' | awk '{print $2}') + HLS_LATEST=$(ghcup list -t available 2>&1 | grep '^ hls' | grep 'recommended' | awk '{print $2}') + GHCUP_LATEST=$(ghcup list -t available 2>&1 | grep '^ ghcup' | grep 'recommended' | awk '{print $2}') + + echo "GHC: pinned=$GHC_PINNED latest=$GHC_LATEST" + echo "HLS: pinned=$HLS_PINNED latest=$HLS_LATEST" + echo "ghcup: pinned=$GHCUP_PINNED latest=$GHCUP_LATEST" + + DRIFT=0 + [ "$GHC_PINNED" != "$GHC_LATEST" ] && DRIFT=1 && echo "GHC drifted: $GHC_PINNED -> $GHC_LATEST" + [ "$HLS_PINNED" != "$HLS_LATEST" ] && DRIFT=1 && echo "HLS drifted: $HLS_PINNED -> $HLS_LATEST" + [ "$GHCUP_PINNED" != "$GHCUP_LATEST" ] && DRIFT=1 && echo "ghcup drifted: $GHCUP_PINNED -> $GHCUP_LATEST" + + if [ "$DRIFT" = "1" ]; then + echo "::warning::Version drift detected — update versions.ncl" + exit 1 + fi + echo "All versions in sync." \ No newline at end of file diff --git a/.github/workflows/template-lint.yml b/.github/workflows/template-lint.yml new file mode 100644 index 00000000..0e058d34 --- /dev/null +++ b/.github/workflows/template-lint.yml @@ -0,0 +1,27 @@ +name: Template Lint + +on: + pull_request: + branches: + - master + paths: + - 'devcontainer-template/**' + - '.github/workflows/template-lint.yml' + +jobs: + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Validate template metadata + run: | + # Validate that devcontainer-template.json is valid JSON and has required fields. + for f in devcontainer-template/src/*/devcontainer-template.json; do + echo "Validating $f..." + jq -e '.id, .name, .description' "$f" > /dev/null + done + # Validate devcontainer.json files. + for f in devcontainer-template/src/*/.devcontainer/devcontainer.json; do + echo "Validating $f..." + jq -e '.image or .build.dockerfile, .features' "$f" > /dev/null + done \ No newline at end of file diff --git a/README.md b/README.md index bcdc865e..a339104b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,25 @@ Only the latest patch release in each actively maintained minor line receives ro For actively supported GHC versions, Cabal and Stack should be updated when new versions are released. +## Devcontainers + +An official [devcontainer feature](devcontainer-feature/src/haskell-toolchain) and +[template](devcontainer-template/src/haskell) live alongside this image. + +- **Feature** — `ghcr.io/haskell/docker-haskell/devcontainer-features/haskell-toolchain:1.0.0` + installs GHC, cabal, Stack, HLS, and formatters (`ormolu`, `fourmolu`) via `ghcup` + on any Debian/Ubuntu base image. Tool versions are pinned in + [`versions.ncl`](devcontainer-feature/src/haskell-toolchain/versions.ncl), which + imports from the same Nickel data files as the Docker images — bumping a version + in one place updates both. +- **Template** — "Haskell (cabal)" in the devcontainer registry; a clone-and-go + starter with a `Hello, Haskell!` cabal project, VS Code extensions, and + format-on-save configured. + +See [`devcontainer-feature/src/haskell-toolchain/README.md`](devcontainer-feature/src/haskell-toolchain/README.md) +and [`devcontainer-template/src/haskell/README.md`](devcontainer-template/src/haskell/README.md) +for details. + ## Maintenance ### Generating Dockerfiles diff --git a/devcontainer-feature/src/haskell-toolchain/Dockerfile.test b/devcontainer-feature/src/haskell-toolchain/Dockerfile.test new file mode 100644 index 00000000..a48a1354 --- /dev/null +++ b/devcontainer-feature/src/haskell-toolchain/Dockerfile.test @@ -0,0 +1,27 @@ +# Multistage Dockerfile for local testing of the haskell-toolchain feature. +# Stage 1 ("toolchain"): runs install.sh — expensive, cached by buildx/podman. +# Stage 2 ("test"): copies test scripts, runs them — cheap, re-runs on change. +# +# Usage (from repo root): +# podman build -t haskell-feature-test -f devcontainer-feature/src/haskell-toolchain/Dockerfile.test devcontainer-feature +# podman build --build-arg BASE_IMAGE=ubuntu:24.04 -t haskell-feature-test-ubuntu -f devcontainer-feature/src/haskell-toolchain/Dockerfile.test devcontainer-feature + +# --- Stage 1: install the toolchain (cached unless install.sh changes) --- +ARG BASE_IMAGE=mcr.microsoft.com/devcontainers/base:debian-12 +FROM ${BASE_IMAGE} AS toolchain + +ARG _REMOTE_USER=vscode +ARG _REMOTE_USER_HOME=/home/vscode +ENV _REMOTE_USER=${_REMOTE_USER} +ENV _REMOTE_USER_HOME=${_REMOTE_USER_HOME} + +COPY src/haskell-toolchain/install.sh /tmp/dev-container-features/haskell-toolchain/install.sh +RUN chmod +x /tmp/dev-container-features/haskell-toolchain/install.sh && \ + /tmp/dev-container-features/haskell-toolchain/install.sh && \ + rm -rf /tmp/dev-container-features + +# --- Stage 2: run the test scripts (fast, re-runs on every test.sh change) --- +FROM toolchain AS test + +COPY test/haskell-toolchain/test.sh /tmp/test.sh +RUN chmod +x /tmp/test.sh && /tmp/test.sh \ No newline at end of file diff --git a/devcontainer-feature/src/haskell-toolchain/LICENSE b/devcontainer-feature/src/haskell-toolchain/LICENSE new file mode 100644 index 00000000..b7e5664f --- /dev/null +++ b/devcontainer-feature/src/haskell-toolchain/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 Darin Morrison, Christopher Biscardi, Peter Salvatore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/devcontainer-feature/src/haskell-toolchain/README.md b/devcontainer-feature/src/haskell-toolchain/README.md new file mode 100644 index 00000000..78d09bf1 --- /dev/null +++ b/devcontainer-feature/src/haskell-toolchain/README.md @@ -0,0 +1,103 @@ +# Haskell Toolchain (ghcup) + +Installs the Haskell toolchain via [ghcup](https://www.haskell.org/ghcup/): +GHC, cabal-install, Stack, the Haskell Language Server, and a curated set of +formatters (`ormolu`, `fourmolu`). + +Tool versions are **pinned** in [`versions.ncl`](versions.ncl), which imports +from the `9.14/bookworm.ncl` data file in this same repository. This keeps the +feature in lockstep with the official `haskell` Docker images — bumping a +version in one place updates both. + +## Example usage + +```jsonc +"features": { + "ghcr.io/haskell/docker-haskell/devcontainer-features/haskell-toolchain:1.0.0": { + "version": "9.14" + } +} +``` + +## Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `version` | string | `9.14` | GHC version (ghcup shorthand: `9.14`, `recommended`, `latest`, …). | +| `installStack` | boolean | `true` | Install Stack. | +| `installHLS` | boolean | `true` | Install the Haskell Language Server. | +| `installStackGHCupHook` | boolean | `true` | Force Stack to use ghcup-installed GHC (no separate Stack GHC downloads). | +| `adjustBash` | boolean | `true` | Prepend ghcup to `PATH` in `~/.bashrc`. | +| `installLinters` | boolean | `true` | Install curated formatters (`ormolu`, `fourmolu`). | +| `globalPackages` | string | `""` | Extra `cabal install` packages (space-separated, unpinned). Escape hatch. | +| `globalLibraries` | string | `""` | Extra `cabal install --lib` libraries (space-separated, unpinned). Escape hatch. | + +## Pinned versions (v1.0.0) + +| Tool | Version | Source | +|---|---|---| +| GHC | 9.14.1 | `9.14/bookworm.ncl` | +| cabal-install | 3.16.1.0 | `9.14/_globals.ncl` | +| Stack | 3.11.1 | `9.14/_globals.ncl` | +| HLS | 2.14.0.0 | `versions.ncl` (LTS pair with GHC 9.14.1) | +| ghcup | 0.2.6.2 | `versions.ncl` | +| ormolu | 0.8.2.0 | `versions.ncl` | +| fourmolu | 0.20.0.0 | `versions.ncl` | + +## Why only ormolu and fourmolu? + +`hlint`, `stylish-haskell`, and `cabal-fmt` are **not** included in the curated +set because their latest Hackage releases constrain `ghc < 9.13` or +`base < 4.22`, making them incompatible with GHC 9.14.1 (which ships +`base-4.22.0.0`). They depend on `ghc-lib-parser 9.12.x`, which has not been +updated for GHC 9.14's `base` at the time of writing. + +When upstream releases versions compatible with GHC 9.14, they will be added +back to the curated set. Until then, users can attempt to install them via the +`globalPackages` escape hatch with `--allow-newer`: + +```jsonc +"features": { + "ghcr.io/haskell/docker-haskell/devcontainer-features/haskell-toolchain:1.0.0": { + "installLinters": false, + "globalPackages": "hlint stylish-haskell cabal-fmt" + } +} +``` + +(Note: `globalPackages` does not pass `--allow-newer`; users needing it should +install manually in a `postCreateCommand`.) + +## Supported base images + +Debian and Ubuntu only in v1.0.0. Alpine support is tracked by +[issue #22](https://github.com/haskell/docker-haskell/issues/22) and will land +in lockstep with the Alpine base images. + +## Regenerating `install.sh` + +`install.sh` is **generated** from `install.sh.tpl.ncl` via Nickel. Do not edit +the `.sh` file directly; edit the template and re-render: + +```bash +cd devcontainer-feature/src/haskell-toolchain +nickel export --format text <<'Nickel' > install.sh +let tpl = import "install.sh.tpl.ncl" in +tpl.install_sh +Nickel +chmod +x install.sh +``` + +The `feature-publish.yml` workflow re-renders automatically on tag. + +## Install-user behavior + +Mirrors [`devcontainers-extra/features/src/haskell`](https://github.com/devcontainers-extra/features/tree/main/src/haskell): + +- When `_REMOTE_USER` is set (devcontainer / Codespaces build context), + ghcup is installed as that user via `sudo -iu "$_REMOTE_USER"`. +- When `_REMOTE_USER` is unset and the script runs as root (plain Dockerfile + `RUN`), ghcup is installed under `/root`. +- Non-root invocation without `_REMOTE_USER` hard-fails. + +`GHCUP_USE_XDG_DIRS=1` is set in both paths. \ No newline at end of file diff --git a/devcontainer-feature/src/haskell-toolchain/devcontainer-feature.json b/devcontainer-feature/src/haskell-toolchain/devcontainer-feature.json new file mode 100644 index 00000000..1424c4bb --- /dev/null +++ b/devcontainer-feature/src/haskell-toolchain/devcontainer-feature.json @@ -0,0 +1,52 @@ +{ + "id": "haskell-toolchain", + "version": "1.0.0", + "name": "Haskell Toolchain (ghcup)", + "description": "Installs GHC, cabal, Stack, HLS, and curated formatters (ormolu, fourmolu) via ghcup. Versions are pinned in lockstep with the haskell/docker-haskell image matrix.", + "documentationURL": "https://github.com/haskell/docker-haskell/tree/master/devcontainer-feature/haskell-toolchain", + "options": { + "version": { + "type": "string", + "proposals": ["latest", "recommended", "9.14", "9.12", "9.10", "9.8", "9.6"], + "default": "9.14", + "description": "GHC version to install (ghcup shorthand, e.g. 9.14, recommended, latest)." + }, + "installStack": { + "type": "boolean", + "default": true, + "description": "Install Stack." + }, + "installHLS": { + "type": "boolean", + "default": true, + "description": "Install the Haskell Language Server." + }, + "installStackGHCupHook": { + "type": "boolean", + "default": true, + "description": "Prevent Stack from installing its own GHC; force it to use ghcup's." + }, + "adjustBash": { + "type": "boolean", + "default": true, + "description": "Prepend ghcup to PATH in ~/.bashrc." + }, + "installLinters": { + "type": "boolean", + "default": true, + "description": "Install curated formatters (ormolu, fourmolu)." + }, + "globalPackages": { + "type": "string", + "proposals": ["", "hlint", "hlint pandoc-cli"], + "default": "", + "description": "Extra packages to `cabal install`. Space-separated. Unpinned; adds significant build time." + }, + "globalLibraries": { + "type": "string", + "proposals": ["", "hspec hspec-contrib QuickCheck HUnit"], + "default": "", + "description": "Extra libraries to `cabal install --lib`. Space-separated. Unpinned; adds significant build time." + } + } +} \ No newline at end of file diff --git a/devcontainer-feature/src/haskell-toolchain/install.sh b/devcontainer-feature/src/haskell-toolchain/install.sh new file mode 100755 index 00000000..8b620721 --- /dev/null +++ b/devcontainer-feature/src/haskell-toolchain/install.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Haskell Toolchain devcontainer feature +# Versions are pinned by versions.ncl (imported from 9.14/bookworm.ncl + globals). + +GHC_VERSION="${GHCVERSION:-"9.14.1"}" +CABAL_VERSION="${CABALVERSION:-"3.16.1.0"}" +HLS_VERSION="2.14.0.0" +GHCUP_VERSION="0.2.6.2" +STACK_VERSION="3.11.1" + +INCLUDE_STACK="${INSTALLSTACK:-"true"}" +INCLUDE_HLS="${INSTALLHLS:-"true"}" +INSTALL_STACK_GHCUP_HOOK="${INSTALLSTACKGHCUPHOOK:-"true"}" +ADJUST_BASHRC="${ADJUSTBASH:-"true"}" +INSTALL_LINTERS="${INSTALLLINTERS:-"true"}" +GLOBAL_PACKAGES="${GLOBALPACKAGES:-""}" +GLOBAL_LIBRARIES="${GLOBALLIBRARIES:-""}" + +ORMOLU_VERSION="0.8.2.0" +FOURMOLU_VERSION="0.20.0.0" + +# --- Resolve install user (mirrors devcontainers-extra/features/src/haskell) --- +if [ "$(id -u)" -ne 0 ]; then + echo 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' >&2 + exit 1 +fi + +if [ -n "${_REMOTE_USER:-}" ] && [ "$_REMOTE_USER" != "root" ]; then + INSTALL_AS="$_REMOTE_USER" + NEED_SUDO=1 +else + INSTALL_AS="root" + NEED_SUDO=0 + export HOME=/root +fi + +# --- System dependencies (Debian/Ubuntu only in v1.0.0; Alpine tracked by issue #22) --- +rm -rf /var/lib/apt/lists/* +apt-get update -y +if [ "${NEED_SUDO}" = "1" ]; then + apt-get install -y --no-install-recommends sudo +fi +apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + build-essential \ + libffi-dev \ + libgmp-dev \ + libncurses-dev \ + libtinfo-dev \ + xz-utils \ + zlib1g-dev \ + git +rm -rf /var/lib/apt/lists/* + +# --- Bootstrap ghcup + install tools as the install user --- +# When running as root without _REMOTE_USER, execute directly (no sudo needed). +# When _REMOTE_USER is set (devcontainer/Codespaces), run via sudo -iu so that +# ghcup installs into the remote user's home directory with XDG dirs. +# The unquoted heredoc lets the outer shell expand the pinned values; +# \$-escapes protect what must expand inside. +if [ "${NEED_SUDO}" = "1" ]; then + sudo -iu "${INSTALL_AS}" </dev/null 2>&1; then + stack config set system-ghc --global true >/dev/null 2>&1 || \ + echo 'Warning: failed to configure Stack to use ghcup-installed GHC.' >&2 + fi +fi + +if [ '${INSTALL_LINTERS}' = 'true' ]; then + cabal update + cabal install -j\$(nproc) --semaphore \ + ormolu-'${ORMOLU_VERSION}' \ + fourmolu-'${FOURMOLU_VERSION}' \ + --overwrite-policy=always +fi + +if [ -n '${GLOBAL_PACKAGES}' ]; then + echo '${GLOBAL_PACKAGES}' | xargs cabal install +fi + +if [ -n '${GLOBAL_LIBRARIES}' ]; then + echo '${GLOBAL_LIBRARIES}' | xargs cabal install --lib +fi + +rm -rf ~/.ghcup/tmp || true +INSTALL_EOF +else + # Running as root directly — no sudo needed. + bash </dev/null 2>&1; then + stack config set system-ghc --global true >/dev/null 2>&1 || \ + echo 'Warning: failed to configure Stack to use ghcup-installed GHC.' >&2 + fi +fi + +if [ '${INSTALL_LINTERS}' = 'true' ]; then + cabal update + cabal install -j\$(nproc) --semaphore \ + ormolu-'${ORMOLU_VERSION}' \ + fourmolu-'${FOURMOLU_VERSION}' \ + --overwrite-policy=always +fi + +if [ -n '${GLOBAL_PACKAGES}' ]; then + echo '${GLOBAL_PACKAGES}' | xargs cabal install +fi + +if [ -n '${GLOBAL_LIBRARIES}' ]; then + echo '${GLOBAL_LIBRARIES}' | xargs cabal install --lib +fi + +rm -rf ~/.ghcup/tmp || true +INSTALL_EOF +fi + +apt-get clean || true +rm -rf /var/lib/apt/lists/* || true + +echo "Haskell toolchain installed: GHC ${GHC_VERSION}, cabal ${CABAL_VERSION}, HLS ${HLS_VERSION}" \ No newline at end of file diff --git a/devcontainer-feature/src/haskell-toolchain/install.sh.tpl.ncl b/devcontainer-feature/src/haskell-toolchain/install.sh.tpl.ncl new file mode 100644 index 00000000..33000e37 --- /dev/null +++ b/devcontainer-feature/src/haskell-toolchain/install.sh.tpl.ncl @@ -0,0 +1,196 @@ +let v = import "versions.ncl" in + +let rendered = m%" +#!/usr/bin/env bash +set -euo pipefail + +# Haskell Toolchain devcontainer feature +# Versions are pinned by versions.ncl (imported from 9.14/bookworm.ncl + globals). + +GHC_VERSION="${GHCVERSION:-"%{v.ghc_version}"}" +CABAL_VERSION="${CABALVERSION:-"%{v.cabal_version}"}" +HLS_VERSION="%{v.hls_version}" +GHCUP_VERSION="%{v.ghcup_version}" +STACK_VERSION="%{v.stack_version}" + +INCLUDE_STACK="${INSTALLSTACK:-"true"}" +INCLUDE_HLS="${INSTALLHLS:-"true"}" +INSTALL_STACK_GHCUP_HOOK="${INSTALLSTACKGHCUPHOOK:-"true"}" +ADJUST_BASHRC="${ADJUSTBASH:-"true"}" +INSTALL_LINTERS="${INSTALLLINTERS:-"true"}" +GLOBAL_PACKAGES="${GLOBALPACKAGES:-""}" +GLOBAL_LIBRARIES="${GLOBALLIBRARIES:-""}" + +ORMOLU_VERSION="%{v.linters.ormolu}" +FOURMOLU_VERSION="%{v.linters.fourmolu}" + +# --- Resolve install user (mirrors devcontainers-extra/features/src/haskell) --- +if [ "$(id -u)" -ne 0 ]; then + echo 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' >&2 + exit 1 +fi + +if [ -n "${_REMOTE_USER:-}" ] && [ "$_REMOTE_USER" != "root" ]; then + INSTALL_AS="$_REMOTE_USER" + NEED_SUDO=1 +else + INSTALL_AS="root" + NEED_SUDO=0 + export HOME=/root +fi + +# --- System dependencies (Debian/Ubuntu only in v1.0.0; Alpine tracked by issue #22) --- +rm -rf /var/lib/apt/lists/* +apt-get update -y +if [ "${NEED_SUDO}" = "1" ]; then + apt-get install -y --no-install-recommends sudo +fi +apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + build-essential \ + libffi-dev \ + libgmp-dev \ + libncurses-dev \ + libtinfo-dev \ + xz-utils \ + zlib1g-dev \ + git +rm -rf /var/lib/apt/lists/* + +# --- Bootstrap ghcup + install tools as the install user --- +# When running as root without _REMOTE_USER, execute directly (no sudo needed). +# When _REMOTE_USER is set (devcontainer/Codespaces), run via sudo -iu so that +# ghcup installs into the remote user's home directory with XDG dirs. +# The unquoted heredoc lets the outer shell expand the pinned values; +# \$-escapes protect what must expand inside. +if [ "${NEED_SUDO}" = "1" ]; then + sudo -iu "${INSTALL_AS}" </dev/null 2>&1; then + stack config set system-ghc --global true >/dev/null 2>&1 || \ + echo 'Warning: failed to configure Stack to use ghcup-installed GHC.' >&2 + fi +fi + +if [ '${INSTALL_LINTERS}' = 'true' ]; then + cabal update + cabal install -j\$(nproc) --semaphore \ + ormolu-'${ORMOLU_VERSION}' \ + fourmolu-'${FOURMOLU_VERSION}' \ + --overwrite-policy=always +fi + +if [ -n '${GLOBAL_PACKAGES}' ]; then + echo '${GLOBAL_PACKAGES}' | xargs cabal install +fi + +if [ -n '${GLOBAL_LIBRARIES}' ]; then + echo '${GLOBAL_LIBRARIES}' | xargs cabal install --lib +fi + +rm -rf ~/.ghcup/tmp || true +INSTALL_EOF +else + # Running as root directly — no sudo needed. + bash </dev/null 2>&1; then + stack config set system-ghc --global true >/dev/null 2>&1 || \ + echo 'Warning: failed to configure Stack to use ghcup-installed GHC.' >&2 + fi +fi + +if [ '${INSTALL_LINTERS}' = 'true' ]; then + cabal update + cabal install -j\$(nproc) --semaphore \ + ormolu-'${ORMOLU_VERSION}' \ + fourmolu-'${FOURMOLU_VERSION}' \ + --overwrite-policy=always +fi + +if [ -n '${GLOBAL_PACKAGES}' ]; then + echo '${GLOBAL_PACKAGES}' | xargs cabal install +fi + +if [ -n '${GLOBAL_LIBRARIES}' ]; then + echo '${GLOBAL_LIBRARIES}' | xargs cabal install --lib +fi + +rm -rf ~/.ghcup/tmp || true +INSTALL_EOF +fi + +apt-get clean || true +rm -rf /var/lib/apt/lists/* || true + +echo "Haskell toolchain installed: GHC ${GHC_VERSION}, cabal ${CABAL_VERSION}, HLS ${HLS_VERSION}" +"% in + +{ install_sh = rendered } \ No newline at end of file diff --git a/devcontainer-feature/src/haskell-toolchain/versions.ncl b/devcontainer-feature/src/haskell-toolchain/versions.ncl new file mode 100644 index 00000000..8c14713f --- /dev/null +++ b/devcontainer-feature/src/haskell-toolchain/versions.ncl @@ -0,0 +1,13 @@ +let bookworm = import "../../../9.14/bookworm.ncl" in +let globals = bookworm in +{ + ghc_version = globals.ghc.version, + cabal_version = globals.cabal_install.version, + stack_version = globals.stack.version, + hls_version = "2.14.0.0", + ghcup_version = "0.2.6.2", + linters = { + ormolu = "0.8.2.0", + fourmolu = "0.20.0.0", + }, +} \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/_default/devcontainer.json b/devcontainer-feature/test/haskell-toolchain/_default/devcontainer.json new file mode 100644 index 00000000..31bbfdf6 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/_default/devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:debian-12", + "features": { + "haskell-toolchain": {} + } +} \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/_default/scenario.sh b/devcontainer-feature/test/haskell-toolchain/_default/scenario.sh new file mode 100755 index 00000000..25283cc4 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/_default/scenario.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -e + +echo "Checking default install (all tools)..." +# shellcheck source=/dev/null +source ~/.bashrc 2>/dev/null || true +export PATH="$HOME/.local/share/ghcup/bin:$HOME/.cabal/bin:$PATH" + +ghc --version +cabal --version +stack --version +haskell-language-server-wrapper --version +ormolu --version +fourmolu --version + +echo "All default tools present." \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/escape-hatch/devcontainer.json b/devcontainer-feature/test/haskell-toolchain/escape-hatch/devcontainer.json new file mode 100644 index 00000000..8a478c99 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/escape-hatch/devcontainer.json @@ -0,0 +1,9 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:debian-12", + "features": { + "haskell-toolchain": { + "installLinters": false, + "globalPackages": "ormolu" + } + } +} \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/escape-hatch/scenario.sh b/devcontainer-feature/test/haskell-toolchain/escape-hatch/scenario.sh new file mode 100755 index 00000000..b01370ca --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/escape-hatch/scenario.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -e + +echo "Checking escape-hatch (globalPackages=ormolu, curated formatters off)..." +# shellcheck source=/dev/null +source ~/.bashrc 2>/dev/null || true +export PATH="$HOME/.local/share/ghcup/bin:$HOME/.cabal/bin:$PATH" + +ghc --version +cabal --version +ormolu --version + +if command -v fourmolu >/dev/null 2>&1; then + echo "FAIL: fourmolu should not be installed (curated formatters off)" + exit 1 +fi + +echo "Escape hatch works: ormolu present via globalPackages, fourmolu absent." \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/no-hls/devcontainer.json b/devcontainer-feature/test/haskell-toolchain/no-hls/devcontainer.json new file mode 100644 index 00000000..92f53357 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/no-hls/devcontainer.json @@ -0,0 +1,8 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:debian-12", + "features": { + "haskell-toolchain": { + "installHLS": false + } + } +} \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/no-hls/scenario.sh b/devcontainer-feature/test/haskell-toolchain/no-hls/scenario.sh new file mode 100755 index 00000000..e4675ea4 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/no-hls/scenario.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -e + +echo "Checking no-hls install..." +# shellcheck source=/dev/null +source ~/.bashrc 2>/dev/null || true +export PATH="$HOME/.local/share/ghcup/bin:$HOME/.cabal/bin:$PATH" + +ghc --version +cabal --version +stack --version + +if command -v haskell-language-server-wrapper >/dev/null 2>&1; then + echo "FAIL: HLS should not be installed" + exit 1 +fi + +echo "HLS correctly absent." \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/no-linters/devcontainer.json b/devcontainer-feature/test/haskell-toolchain/no-linters/devcontainer.json new file mode 100644 index 00000000..22b67f40 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/no-linters/devcontainer.json @@ -0,0 +1,8 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:debian-12", + "features": { + "haskell-toolchain": { + "installLinters": false + } + } +} \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/no-linters/scenario.sh b/devcontainer-feature/test/haskell-toolchain/no-linters/scenario.sh new file mode 100755 index 00000000..f6a8b245 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/no-linters/scenario.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -e + +echo "Checking no-linters install..." +# shellcheck source=/dev/null +source ~/.bashrc 2>/dev/null || true +export PATH="$HOME/.local/share/ghcup/bin:$HOME/.cabal/bin:$PATH" + +ghc --version +cabal --version +stack --version +haskell-language-server-wrapper --version + +for tool in ormolu fourmolu; do + if command -v "$tool" >/dev/null 2>&1; then + echo "FAIL: $tool should not be installed" + exit 1 + fi +done + +echo "All formatters correctly absent." \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/no-stack/devcontainer.json b/devcontainer-feature/test/haskell-toolchain/no-stack/devcontainer.json new file mode 100644 index 00000000..327305a5 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/no-stack/devcontainer.json @@ -0,0 +1,8 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:debian-12", + "features": { + "haskell-toolchain": { + "installStack": false + } + } +} \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/no-stack/scenario.sh b/devcontainer-feature/test/haskell-toolchain/no-stack/scenario.sh new file mode 100755 index 00000000..ed04b522 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/no-stack/scenario.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -e + +echo "Checking no-stack install..." +# shellcheck source=/dev/null +source ~/.bashrc 2>/dev/null || true +export PATH="$HOME/.local/share/ghcup/bin:$HOME/.cabal/bin:$PATH" + +ghc --version +cabal --version +haskell-language-server-wrapper --version + +if command -v stack >/dev/null 2>&1; then + echo "FAIL: stack should not be installed" + exit 1 +fi + +echo "stack correctly absent." \ No newline at end of file diff --git a/devcontainer-feature/test/haskell-toolchain/test.sh b/devcontainer-feature/test/haskell-toolchain/test.sh new file mode 100755 index 00000000..88559013 --- /dev/null +++ b/devcontainer-feature/test/haskell-toolchain/test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -e + +# The devcontainer test harness runs test.sh as root. The feature may have +# installed under _REMOTE_USER's home (devcontainer/Codespaces) or under /root +# (plain Dockerfile RUN). Detect which by checking where ghcup env lives. +if [ -n "${_REMOTE_USER:-}" ] && [ "$_REMOTE_USER" != "root" ] && [ -f "${_REMOTE_USER_HOME:-/home/$_REMOTE_USER}/.local/share/ghcup/env" ]; then + REMOTE_HOME="${_REMOTE_USER_HOME:-/home/$_REMOTE_USER}" +else + REMOTE_HOME="/root" +fi + +# shellcheck source=/dev/null +source "$REMOTE_HOME/.bashrc" 2>/dev/null || true +if [ -f "$REMOTE_HOME/.local/share/ghcup/env" ]; then + # shellcheck source=/dev/null + source "$REMOTE_HOME/.local/share/ghcup/env" +fi +export PATH="$REMOTE_HOME/.local/bin:$REMOTE_HOME/.cabal/bin:$PATH" + +echo "Checking Haskell toolchain (using $REMOTE_HOME)..." +ghc --version +cabal --version +stack --version +haskell-language-server-wrapper --version +ormolu --version +fourmolu --version +echo "All tools present." \ No newline at end of file diff --git a/devcontainer-template/src/haskell/.devcontainer/devcontainer.json b/devcontainer-template/src/haskell/.devcontainer/devcontainer.json new file mode 100644 index 00000000..5818ff3e --- /dev/null +++ b/devcontainer-template/src/haskell/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +{ + "name": "Haskell Helloworld", + "image": "mcr.microsoft.com/devcontainers/base:debian-12", + "features": { + "ghcr.io/haskell/docker-haskell/devcontainer-features/haskell-toolchain:1.0.0": { + "version": "9.14" + } + }, + "customizations": { + "vscode": { + "extensions": ["haskell.haskell", "haskell-lang-server"] + } + }, + "postCreateCommand": "cabal update && cabal build" +} \ No newline at end of file diff --git a/devcontainer-template/src/haskell/.gitignore b/devcontainer-template/src/haskell/.gitignore new file mode 100644 index 00000000..6bd1740b --- /dev/null +++ b/devcontainer-template/src/haskell/.gitignore @@ -0,0 +1,7 @@ +dist-newstyle/ +dist/ +.ghc.environment.* +*.hi +*.o +*.prof +cabal.project.local \ No newline at end of file diff --git a/devcontainer-template/src/haskell/.vscode/extensions.json b/devcontainer-template/src/haskell/.vscode/extensions.json new file mode 100644 index 00000000..784f3402 --- /dev/null +++ b/devcontainer-template/src/haskell/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "haskell.haskell", + "haskell-lang-server" + ] +} \ No newline at end of file diff --git a/devcontainer-template/src/haskell/.vscode/settings.json b/devcontainer-template/src/haskell/.vscode/settings.json new file mode 100644 index 00000000..03f029ac --- /dev/null +++ b/devcontainer-template/src/haskell/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "haskell.manageHLS": "GHCup", + "haskell.serverExecutablePath": "haskell-language-server-wrapper", + "[haskell]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "haskell.haskell" + }, + "haskell.formattingProvider": "fourmolu" +} \ No newline at end of file diff --git a/devcontainer-template/src/haskell/LICENSE b/devcontainer-template/src/haskell/LICENSE new file mode 100644 index 00000000..b7e5664f --- /dev/null +++ b/devcontainer-template/src/haskell/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 Darin Morrison, Christopher Biscardi, Peter Salvatore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/devcontainer-template/src/haskell/README.md b/devcontainer-template/src/haskell/README.md new file mode 100644 index 00000000..f505e2d0 --- /dev/null +++ b/devcontainer-template/src/haskell/README.md @@ -0,0 +1,55 @@ +# Haskell (cabal) + +A minimal Haskell project using [cabal-install](https://cabal.readthedocs.io/), +with the full toolchain (GHC, cabal, Stack, HLS, ormolu, fourmolu) installed via +the [`haskell-toolchain` devcontainer feature](../src/haskell-toolchain). + +## What's included + +- **GHC 9.14.1** (LTS) + **HLS 2.14.0.0** via ghcup +- **cabal-install 3.16.1.0** + **Stack 3.11.1** +- **Formatters**: ormolu, fourmolu +- **VS Code extensions**: `haskell.haskell`, `haskell-lang-server` +- **Format on save**: fourmolu + +## Getting started + +1. Use this template to create a new repository. +2. Open in a devcontainer (VS Code: `Reopen in Container`, or GitHub Codespaces). +3. The `postCreateCommand` runs `cabal update && cabal build` automatically. +4. Run the project: `cabal run helloworld` + +## Project structure + +``` +. +├── .devcontainer/ +│ └── devcontainer.json # feature config + VS Code customizations +├── .vscode/ +│ ├── extensions.json # recommended extensions +│ └── settings.json # HLS + formatter settings +├── src/ +│ └── Main.hs # putStrLn "Hello, Haskell!" +├── helloworld.cabal # cabal package spec +└── .gitignore +``` + +## Customizing the GHC version + +Edit `.devcontainer/devcontainer.json` and change the `version` option: + +```jsonc +"features": { + "ghcr.io/haskell/docker-haskell/devcontainer-features/haskell-toolchain:1.0.0": { + "version": "9.12" // or "9.10", "9.8", "latest", "recommended" + } +} +``` + +## Adding linters (hlint, stylish-haskell) + +The curated formatter set (ormolu, fourmolu) is compatible with GHC 9.14.1. +`hlint` and `stylish-haskell` are not included because their latest Hackage +releases don't support `base-4.22` (shipped with GHC 9.14). See the +[feature README](../src/haskell-toolchain/README.md#why-only-ormolu-and-fourmolu) +for details. \ No newline at end of file diff --git a/devcontainer-template/src/haskell/devcontainer-template.json b/devcontainer-template/src/haskell/devcontainer-template.json new file mode 100644 index 00000000..0c7e7e2e --- /dev/null +++ b/devcontainer-template/src/haskell/devcontainer-template.json @@ -0,0 +1,8 @@ +{ + "id": "haskell", + "name": "Haskell (cabal)", + "description": "Minimal Haskell project using cabal-install on Debian, with GHC, HLS, and formatters via ghcup.", + "publisher": "haskell", + "tags": ["haskell", "cabal", "ghc", "ghcup", "hls"], + "fileCount": 7 +} \ No newline at end of file diff --git a/devcontainer-template/src/haskell/helloworld.cabal b/devcontainer-template/src/haskell/helloworld.cabal new file mode 100644 index 00000000..e214cbd6 --- /dev/null +++ b/devcontainer-template/src/haskell/helloworld.cabal @@ -0,0 +1,12 @@ +cabal-version: 3.0 +name: helloworld +version: 0.1.0.0 +synopsis: Hello, Haskell! +license: BSD-3-Clause +build-type: Simple + +executable helloworld + main-is: Main.hs + build-depends: base ^>=4.22 + default-language: GHC2021 + hs-source-dirs: src \ No newline at end of file diff --git a/devcontainer-template/src/haskell/src/Main.hs b/devcontainer-template/src/haskell/src/Main.hs new file mode 100644 index 00000000..5a8036ee --- /dev/null +++ b/devcontainer-template/src/haskell/src/Main.hs @@ -0,0 +1,4 @@ +module Main (main) where + +main :: IO () +main = putStrLn "Hello, Haskell!" \ No newline at end of file diff --git a/devcontainer-template/test/haskell/test.sh b/devcontainer-template/test/haskell/test.sh new file mode 100755 index 00000000..9a8271c2 --- /dev/null +++ b/devcontainer-template/test/haskell/test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -e + +# Verify the template's cabal project builds and runs. +REMOTE_USER="${_REMOTE_USER:-vscode}" +REMOTE_HOME="${_REMOTE_USER_HOME:-/home/$REMOTE_USER}" + +# shellcheck source=/dev/null +source "$REMOTE_HOME/.bashrc" 2>/dev/null || true +if [ -f "$REMOTE_HOME/.local/share/ghcup/env" ]; then + # shellcheck source=/dev/null + source "$REMOTE_HOME/.local/share/ghcup/env" +fi +export PATH="$REMOTE_HOME/.local/bin:$REMOTE_HOME/.cabal/bin:$PATH" + +cd /workspace + +echo "Building helloworld..." +cabal build + +echo "Running helloworld..." +OUTPUT=$(cabal run helloworld 2>/dev/null) +echo "Output: $OUTPUT" + +if [ "$OUTPUT" = "Hello, Haskell!" ]; then + echo "PASS: helloworld outputs 'Hello, Haskell!'" +else + echo "FAIL: expected 'Hello, Haskell!', got '$OUTPUT'" + exit 1 +fi \ No newline at end of file