Multi-package Haskell project with file dispersion done in Cue

5 min read Original article ↗

…also known as synergized orchestration of federated functional paradigms via lattice-theoretic constraint-based asset dissemination ;-)

or “how I generate X YAML files with Cue to keep my sanity intact”.


I like modularity. When working on my project I made every diagram (or parts of it) into separate module. E.g. something like this:

Unfortunately - Haskell’s Stack gets confused and then (for example) testing mod2 makes it rebuild whole application; slow and annoying.

There is known solution for this problem and it’s called multi-package project. It removes main package.yaml file from the root and instead it puts one in every single sub-directory (which is package in itself).

Tis’ all great and dandy but when it means I have 8 package.yaml 1 files to maintain which is no longer that great. Plus, shifting between structures is hard, isn’t it?


Thankfully, Cuelang excels at working with multiple files, so I thought about it immediately (especially since I was already managing my single package.yaml with it..).

Cue has this feature called “cue command” that makes it somewhat of a command runner. The cool thing about it is that it actually can write files in parallel which makes it great for this particular case.

It requires specific “tool” syntax, which is not very discoverable, but hopefully you can make some sense out of it after reading it:

// make_tool.cue
package main

import (
  "tool/file"
  "tool/exec"
)

// Command is called make
command: make: {
  // packages are defined in other file
  // but it has structure [name]: packageData
  for k, v in packages {

    // Inform that package is to processed
    b = "\(k)-start": exec.Run & {
      cmd: "echo \(k) START."
    }
    // Run export for specified package
    // AFAIK there is no easier way to export
    // to YAML than this
    c = "\(k)-cnt": exec.Run & {
      $after: b
      cmd:    "cue export --out yaml -e packages.\(k)"
      stdout: string
    }
    // Create specific files
    // Note that I'm using previous step (c) stdout as input
    f = "\(k)-file": file.Create & {
      filename: "packages/\(k)/package.yaml"
      contents: c.stdout
    }
    // This is required after creating files because
    // there might be need to recreate Cabal configuration file
    //
    // Could be done in Makefile but since we're at it already...
    s = "\(k)-setup": exec.Run & {
      $after: f
      cmd:    "stack setup"
      dir:    "packages/\(k)"
    }
    // Finally notify about processing end
    "\(k)-done": exec.Run & {
      $after: s
      cmd:    "echo \(k) DONE."
    }
  }
}

In case it’s your first encounter with Cuelang: "\(k)-something" is a string interpolation, $after is weirdly named attribute that has meaning in context of Cue commands and this “c equals …” is assigning alias so I don’t have to break my keyboard when referencing previous task.

As mentioned in the header comment, packages struct is in a separate file under same package name (main). It’s accessible to the tool like it’d be same file, but isolated code wise and thus (in my opinion) readability friendly.

Now for the meat, the definitions:

// package.cue

package main

import (
  "list"
)

// Note: In Cue order doesn't matter, I've shuffled blocks for
//       reading experience

// Most important part - it takes already defined packages, iterate
// over them and...

packages: {
  for key, _ in packages {
    "\(key)": {
      // ...enriches with "default" struct
      default
      // ...and sets the default name
      // (star-prefix means that this value is default in none given)
      name: *"diagrams-\(key)" | string
    }
  }
}

// Default mixin, it doesn't allow overrides by default
default: {
  version:    "0.1.0.0"
  license:    "OtherLicense"
  author:     "Przemysław Alexander Kamiński"
  maintainer: "[email protected]"

  // Why write something twice if I can interpolate?
  copyright:  "2025 \(author)"

  // Underscore-started vars are hidden, i.e. they won't be exported
  _base_deps: [
    "base >= 4.7 && < 5",
    "text",
    "bytestring >= 0.12",
    // ... rest of the dependencies
    ]
    // Most apps needs diagrams-internal anyway, but that's default
  _extra_deps: *["diagrams-internal"] | _

  // Final dependencies concats _base_deps (shared)
  // and _extra_deps (per package)
  dependencies: list.Concat([_base_deps, _extra_deps])

  "ghc-options": [
    "-Wall",
    "-Wcompat",
    // ... rest of GHC options
  ]
  library: "source-dirs": ["src"]
  executables: {}
  verbatim: {}
}

// Another partial to use for building apps
execOpts: {
  "source-dirs": "app"
  "ghc-options": [
    "-threaded",
    "-rtsopts",
    "-with-rtsopts=-N",
  ]
}

// Finally - manual definitions. In my file this is the first block.
// Not all packages are specified.
packages: {
  // This is diagrams-internal, it cannot depend on itself
  diagrams: {
    name: "diagrams-internal"
    _extra_deps: []
  }
  piano: {} // Defaults only
  main: {}
  // Timeline package has extra test specs"
  timeline: {
    tests: spec: {
      main:          "Spec.hs"
      "source-dirs": "test"
      "ghc-options": [
        "-threaded",
        "-rtsopts",
        "-with-rtsopts=-N",
      ]
      dependencies: [
        diagrams.name,
        "diagrams-timeline",
        "QuickCheck",
        "hspec",
      ]
    }
  }
  // Webserver
  web: {
    // Needs diagrams
    _extra_deps: [
      "diagrams-internal",
      "diagrams-piano",
      "diagrams-timeline",
    ]
    // Builds executable
    executables: {
      "server": execOpts & {
        execOpts
        main: "Webserver.main"
        dependencies: [
          diagrams.name, // it finds nearest "diagrams" to resolve,
                         // i.e. "diagrams-internal"
          "diagrams-piano",
          "diagrams-timeline",
          "diagrams-web",
        ]
      }
    }
    "extra-source-files": [
      "README.md",
      "CHANGELOG.md",
      "assets/*.scss",
    ]
  }

}

I didn’t type-check it because it was a quickie and I’m the only person at the project. I’m un-fireable by structure ;) When this becomes multi-million-in-revenue project I’ll add types, I promise.

For running this I also have Makefile (which is slightly more convenient to use than Cue command which, as presented, is boilerplate heavy). Make calls cue cmd make which - in turn - generates 8 package.yaml in sub-directories files with consistent naming and dependencies.

Because it is Haskell, process was fairly painless: build, check errors, fix dependencies, rinse and repeat. In fact it took around 30 minute to change between structures with 8 packages, 4 tests executable, 3 executable executables). I believe it’d be approx. 3 hours if I had to make every single package.yaml file by hand.

Cue is hard, but I don’t know any other solution that would keep things AS consistent with so minimal effort 2.