packages feed

hwm-0.5.0: src/HWM/Integrations/Toolchain/Nix.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE NoImplicitPrelude #-}

module HWM.Integrations.Toolchain.Nix (syncNixFile) where

import qualified Data.Set as Set
import qualified Data.Text as T
import HWM.Core.Common (Name)
import HWM.Core.Formatting (Status (..), format, toCamelCase)
import HWM.Core.Options (Options (..))
import HWM.Core.Pkg (Pkg (..))
import HWM.Core.Sync (SyncMode (..))
import HWM.Core.Version (Era (eraNixpkgs), formatNixGhc, selectEra)
import HWM.Domain.Config (Config (..))
import HWM.Domain.ConfigT (ConfigT, Env (..))
import HWM.Domain.Environments (BuildEnvironment (..), Environments (envsDefault), TargetModes (..), getBuildEnvironment, getBuildEnvironments)
import HWM.Domain.Release (getArtifactEnvironments)
import HWM.Runtime.Files (syncFile)
import Relude
import System.Directory (doesFileExist)

enabled :: SyncMode -> Bool
enabled mode = mode /= SyncModeIgnore

syncNixFile :: SyncMode -> ConfigT Status
syncNixFile SyncModeSync = do
  Config {..} <- asks config
  ops <- asks options
  benv <- getBuildEnvironment (Just $ envsDefault cfgEnvironments)
  benvs <- filter (enabled . targetNix . buildTargets) <$> getBuildEnvironments
  releasePkgs <- maybe (pure []) getArtifactEnvironments cfgRelease
  syncFile (optionsNix ops) (deriveFlakeNix (Context releasePkgs cfgName (map (cfgName,) benvs)) (cfgName, benv) (map (cfgName,) benvs))
syncNixFile SyncModeCheck = do
  nixPath <- optionsNix <$> asks options
  exists <- liftIO $ doesFileExist nixPath
  pure $ if exists then Checked else Warning
syncNixFile SyncModeIgnore = pure Ignored

genName :: (Text, Text) -> Text
genName (name, subName) = toCamelCase (name <> T.toTitle subName <> "WorkspacePackages")

genNixName :: BCOntext -> Text
genNixName (name, BuildEnvironment {..}) = genName (name, format buildName)

type BCOntext = (Name, BuildEnvironment)

systems :: [Text]
systems =
  [ "x86_64-linux",
    "aarch64-linux",
    "x86_64-darwin",
    "aarch64-darwin"
  ]

type ReleasePkg = (Pkg, [BuildEnvironment])

type Release = [ReleasePkg]

data Context = Context
  { release :: Release,
    projectName :: Text,
    environments :: [BCOntext]
  }

deriveFlakeNix :: Context -> BCOntext -> [BCOntext] -> Text
deriveFlakeNix context ctx@(projectName, benv) ctxs =
  T.unlines
    $ braces
      ( [ "description = \"A Haskell " <> projectName <> " workspace generated by HWM(Haskell Workspace Manager)\";",
          "inputs = {",
          "  nixpkgs.url = \"github:NixOS/nixpkgs/" <> eraNixpkgs (selectEra (buildGHC benv)) <> "\";",
          "};",
          "outputs = { self, nixpkgs }:"
        ]
          <> letBlock
            ( [ "supportedSystems = [ " <> T.intercalate " " (map show systems) <> " ];",
                "forAllSystems = nixpkgs.lib.genAttrs supportedSystems;"
              ]
                -- OVERLAY
                <> genOverlay context
            )
            -- PACKAGES
            ( forAllSystems "packages" (nixPkgs <> artifactEngine) (genPackages context ctx ctxs)
                -- DEVSHELLS
                <> forAllSystems "devShells" nixPkgs (genDevShell True ctx <> concatMap (genDevShell False) ctxs)
                -- CHECKS
                <> ["checks = forAllSystems (system: self.packages.${system});"]
            )
            False
      )

-- OVERLAY
genOverlay :: Context -> [Text]
genOverlay ctx@Context {..} =
  scoped
    "haskellOverlay = final: prev:"
    (concatMap rendergOverlayItem environments <> concatMap (rendergOverlayStatic ctx) staticEnvs)
  where
    staticEnvs = Set.toList $ Set.fromList (concatMap snd release)

rendergOverlayItem :: BCOntext -> [Text]
rendergOverlayItem (name, BuildEnvironment {..}) =
  overlayFun
    (genName (name, buildName))
    ["haskell", "packages", formatNixGhc buildGHC]
    (map (renderPackageDef renderPackageBody) buildPkgs)

genStaticEnvName :: Text -> BuildEnvironment -> Text
genStaticEnvName projectName buildEnv = genName (projectName, format (buildName buildEnv) <> "-static")

rendergOverlayStatic :: Context -> BuildEnvironment -> [Text]
rendergOverlayStatic Context {..} env@BuildEnvironment {..} =
  overlayFun
    (genStaticEnvName projectName env)
    ["pkgsStatic", "haskell", "packages", formatNixGhc buildGHC]
    (map (renderPackageDef (stripExecutables . renderPackageBody)) buildPkgs)

overlayFun :: Text -> [Text] -> [Text] -> [Text]
overlayFun name extend = fun name (concatName $ ["prev"] <> extend <> ["extend"]) "hfinal: hprev:"

stripExecutables :: (Semigroup a, IsString a) => a -> a
stripExecutables x = "prev.haskell.lib.justStaticExecutables ( " <> x <> ")"

renderPackageDef :: (Pkg -> Text) -> Pkg -> Text
renderPackageDef body pkg = format (pkgName pkg) <> " = " <> body pkg <> ";"

renderPackageBody :: Pkg -> Text
renderPackageBody pkg = "hfinal.callCabal2nix \"" <> format (pkgName pkg) <> "\" ./" <> format (pkgDirPath pkg) <> " {}"

-- PACKAGES
genPackages :: Context -> BCOntext -> [BCOntext] -> [Text]
genPackages context ctx@(projectName, defaultEnv) allEnvs =
  map (\pkg -> "default = pkgs." <> genNixName ctx <> "." <> format (pkgName pkg) <> ";") defaultPkg
    <> map (\pkg -> format (pkgName pkg) <> " = pkgs." <> genNixName ctx <> "." <> format (pkgName pkg) <> ";") (buildPkgs defaultEnv)
    <> concatMap genEnvPackages allEnvs
    <> concatMap allEnvPackages allEnvs
    <> concatMap (genReleasePkgs context) (release context)
  where
    defaultPkg = filter ((projectName ==) . format . pkgName) (buildPkgs defaultEnv)

genPackage :: Text -> Text -> Pkg -> Text
genPackage overlay envName pkg = format (pkgName pkg) <> "-" <> envName <> " = pkgs." <> overlay <> "." <> format (pkgName pkg) <> ";"

genEnvPackages :: BCOntext -> [Text]
genEnvPackages ctx@(_, env) = map (genPackage (genNixName ctx) (toCamelCase (format (buildName env)))) (buildPkgs env)

allEnvPackages :: BCOntext -> [Text]
allEnvPackages ctx@(_, env) =
  genSymlinkJoin
    ("env-" <> toCamelCase (format (buildName env)) <> "-all")
    (format (buildName env) <> "-workspace")
    (map (\pkg -> "pkgs." <> genNixName ctx <> "." <> format (pkgName pkg)) (buildPkgs env))

genReleasePkgs :: Context -> ReleasePkg -> [Text]
genReleasePkgs ctx (pkg, envs) = map (genReleasePkg ctx pkg) envs

genReleasePkg :: Context -> Pkg -> BuildEnvironment -> Text
genReleasePkg ctx pkg env = name <> "-release = " <> genReleasePkgBody ctx pkg env <> ";"
  where
    name = format (pkgName pkg) <> "-" <> buildName env

genReleasePkgBody :: Context -> Pkg -> BuildEnvironment -> Text
genReleasePkgBody Context {..} pkg env = "mkReleaseArtifact \"" <> format (pkgName pkg) <> "\" pkgs." <> genNixName (projectName, env) <> "." <> format (pkgName pkg) <> " pkgs." <> genStaticEnvName projectName env <> "." <> format (pkgName pkg)

-- genReleaseGroup :: BCOntext -> [Pkg] -> [Text]
-- genReleaseGroup ctx pkgs =
--   genSymlinkJoin
--     "release"
--     "release-artifacts"
--     (map (\pkg -> "(" <> genReleasePkgBody ctx pkg <> ")") pkgs)

-- DEVSHELL
genDevShell :: Bool -> BCOntext -> [Text]
genDevShell _ (_, BuildEnvironment {buildPkgs = []}) = [] -- Handle empty workspace
genDevShell isDefault ctx@(_, BuildEnvironment {buildTargets = TargetModes {..}, ..}) =
  [ name <> " = " <> devShellPackageName ctx <> ".shellFor {",
    "  packages = p: [ " <> renderPackageList buildPkgs <> " ];",
    "  buildInputs = with " <> devShellPackageName ctx <> "; ["
  ]
    <> map ("    " <>) libs
    <> [ "  ];",
         "};"
       ]
  where
    name = if isDefault then "default" else toCamelCase (format buildName)
    libs = ["cabal-install", "hlint"] <> ["stack" | enabled targetStack] <> ["haskell-language-server" | enabled targetHie]
    renderPackageList = T.intercalate " " . map (\pkg -> "p." <> format (pkgName pkg))

devShellPackageName :: BCOntext -> Text
devShellPackageName ctx = "pkgs." <> genNixName ctx

artifactEngine :: [Text]
artifactEngine =
  [ "mkReleaseArtifact = pkgName: basePkg: staticPkg:",
    "  if pkgs.stdenv.hostPlatform.isLinux then",
    "    # LINUX: Return the fully static (musl), zero-dependency executable",
    "    staticPkg",
    "  else if pkgs.stdenv.hostPlatform.isDarwin then",
    "    # MACOS: We \"Bundle\" the dynamic dependencies since strict static is hard on Mac.",
    "    pkgs.runCommand \"${pkgName}-macos-bundle\" {",
    "      nativeBuildInputs = [ pkgs.macdylibbundler pkgs.darwin.autoSignDarwinBinariesHook ];",
    "    } ''",
    "      mkdir -p $out/bin",
    "",
    "      # 1. Copy the fast, dynamically linked binary (stripping docs/libs first)",
    "      cp ${pkgs.haskell.lib.justStaticExecutables basePkg}/bin/${pkgName} $out/bin/${pkgName}",
    "      chmod 755 $out/bin/${pkgName}",
    "",
    "      # 2. Run dylibbundler to pull all Nix store .dylibs into the local folder",
    "      dylibbundler -b --no-codesign -x $out/bin/${pkgName} -d $out/bin -p '@executable_path'",
    "",
    "      # 3. Re-sign the binary so Apple Silicon (M1/M2/M3) allows it to run natively",
    "      signDarwinBinariesInAllOutputs",
    "    ''",
    "  else",
    "    # Fallback for unexpected systems",
    "    basePkg;"
  ]

-- SYNTAX

concatName :: [Text] -> Text
concatName = T.intercalate "."

nixPkgs :: [Text]
nixPkgs = ["pkgs = import nixpkgs { inherit system; overlays = [ haskellOverlay ]; };"]

forAllSystems :: Text -> [Text] -> [Text] -> [Text]
forAllSystems system h body = [system <> " = forAllSystems (system:"] <> letBlock h body True

letBlock :: [Text] -> [Text] -> Bool -> [Text]
letBlock h body end = ["let"] <> indent h <> ["in", "{"] <> indent body <> if end then ["});"] else ["};"]

braces :: [Text] -> [Text]
braces body = ["{"] <> indent body <> ["}"]

scoped :: Text -> [Text] -> [Text]
scoped name body = [name <> " {"] <> indent body <> ["};"]

fun :: Text -> Text -> Text -> [Text] -> [Text]
fun varName name args body = [varName <> " = " <> name <> " (" <> args <> " {"] <> indent body <> ["});"]

genSymlinkJoin :: Text -> Text -> [Text] -> [Text]
genSymlinkJoin varName name pkgs =
  scoped
    (varName <> " = pkgs.symlinkJoin")
    (["name = \"" <> name <> "\";", "paths = ["] <> indent pkgs <> ["];"])

indent :: [Text] -> [Text]
indent = map ("  " <>)