Skip to content

PCL — the language

PCL (Punix Configuration Language) is a small, typed, declarative language whose surface deliberately fits in two pages of grammar. It's neither a DSL for one job nor a general-purpose language with packaging on the side — it's a record/inheritance calculus, syntax sugar over the MIXINv2 IR, with one merge rule and no escape hatches.

A 30-second tour

# A package module
module Curl {
  pname = "curl"
  version = "8.5.0"
  recipe = "std.autotools"
  source = {
    type = "url"
    url = "https://curl.se/download/curl-8.5.0.tar.xz"
    hash = "..."
    hashType = "sha256"
  }
  deps = [Openssl, Zlib]
}

# A service-stack module — same syntax, distinguished by having `backend`
module CurlAPI {
  backend = "systemd"
  storeModules = ["Curl"]
  services = [{
    name = "curl-api"
    package = "Curl"
    binary = "curl"
    args = ["--listen", "0.0.0.0:8080"]
    dependsOn = []
    environment = {
      API_TOKEN = { from_env = "API_TOKEN" }    # secret
      LOG_LEVEL = "info"                         # literal
    }
  }]
  configFiles = [
    { path = "/etc/curl-api.conf"
      content = "verbose = true\n" }
  ]
}

That's the whole surface for most uses. No imports (PCL programs are whole-tree composed; see Modules). No subtyping. No classes. No type variables. The IR can express more (the MIXINv2 core), but the PCL frontend pins what's reachable to "the smallest set that makes the examples work."

The type system

τ ::= Bool | Int | Str | Path | List<τ> | Rec<…> | Setting<τ>

Six base types. Records are structural (Rec<key1: τ1, key2: τ2, …>); equality is string-structural; List<*> unifies any List<τ>. There is no subtyping object. This isn't a simplification to revisit — the finite-tree decidability proof depends on it. (See concepts/eval-realise-seam for why.)

The type checker (punix check) is a single syntax-directed pass over a finite named tree. It cannot diverge. Every error is located:

hello.pcl:5:3: error: [E1] type mismatch in 'version': expected Str, got Int

→ See Reference: error codes E1–E6 for the full surface.

What flows through PCL

Every PCL value flows down the seam:

PCL source bytes
    │ parse  → typed AST (located E1–E6)
    │ check  → ScopeType assigned to every contribution
    │ lower  → canonical MIXINv2 IR
canonical derivation hash → store path

By the time a value reaches the IR it's:

  • Canonical: top-level fields sorted by key; list elements ordered; if/Setting distributed; SecretRef flattened to IrSecret(kind, name).
  • Order-independent w.r.t. source: permute the source PCL ⇒ byte-identical IR ⇒ byte-identical canonical derivation ⇒ byte-identical store path. A conformance test ships exactly this property.

What PCL deliberately doesn't have

Why not
Imports Tree composition. Every module is in scope of every other module within the same punix check/build invocation. See Modules.
String concatenation The frontend has no ++. Compose paths in code (f"{package_path}/bin/{binary}") at the deploy boundary. Keeps the IR simple.
Lambdas / higher-order Decidability anchor. The whole language is first-order over typed records.
Subtyping The decidability proof depends on no <:. Adding it is a feature-procedure violation, not a feature request.
First-class type members Same.
Removal "This contribution never existed" — modelled as data, not as an effect (move © in the feature-decision procedure).
IFD (import-from-derivation) Provably-host (b). Belongs below the seam, as a deploy-time fetch.

Reading further

  • Modules — how composition works; whole-tree scope; module / scenario / option.
  • Secrets in PCL{from_env=…} / {from_file=…} syntax and what the frontend does with them.
  • Recipes — the recipe = "std.shell" / "std.autotools" registry; what each recipe expects in recipeArgs.
  • Concepts: Eval/realise seam — why PCL is shaped this way.