Project builds#

A Nupp project is a nupp.lua manifest naming where source lives, what the project depends on, and which targets it produces. nupp build compiles the whole source set into the outputs those targets describe.

return {
   include = { "src" },
   build = {
      default = "app",
      targets = {
         app = {
            kind = "modules"
         }
      }
   }
}

Source sets#

Running nupp build without source arguments loads nupp.lua and compiles the project's source set: every .nupp under the manifest's include roots, minus the build output. Explicit source builds remain available as nupp build file.nupp.

A modules build executes nothing, so it needs no entry and compiles the whole source set. A bundle, binary or component requires entries to say where execution starts and which chunk becomes the body. Entries do not say what exists. A build compiles what the project is written in, the way a compiler compiles a source set, rather than walking require edges out from an entry.

Dive deeper

The walk answers three questions at once and gets two of them wrong:

  • a module nothing requires goes unchecked, so nupp check stops meaning "this project is well typed" and starts meaning "the part I could reach is";
  • a module reached only through require(name), where the name is computed, is absent from the build and therefore from any binary, and fails in front of a user with a filesystem search path that means nothing inside one file.

Removing unused code is a linker's job and should be invisible when it happens. Measured on this compiler, the walk was removing one module out of seventy-one and costing about sixteen kilobytes on a 1.6 MB binary.

Target source sets#

A module or bundle target may narrow the initial source set with sources. Files and directories use the same recursive expansion as a documentation target:

targets = {
   compiler = {
      kind = "bundle",
      entries = { "compiler.browser" },
      sources = { "src/compiler/browser.nupp", "src/runtime/portable" }
   }
}

The target checks and packages every selected .nupp, .g.nupp, and .lua file. It also adds dependencies reached through a constant require, even when they sit outside the selected paths. Omitting sources retains the whole project source set.

A computed require(name) cannot add a source during the build. Its eventual module must have been selected independently, or the built program reports an ordinary module-not-found error when that call runs. The build does not claim that every possible computed name is present.

Every source entry must match at least one compilable file inside the project. The normalized set participates in the target cache key.

Targets and outputs#

A target names its kind, where execution starts, and the inputs it needs:

build = {
   outDir = "build",
   default = "app",
   targets = {
      app = {
         kind = "modules",
         description = "Build the application",
         entries = { "app.main" },
         resources = { "src/app/*.d.nupp" },
         dependencies = { "fast", "codec" }
      }
   }
}

Entries are optional for a modules target and required for a bundle, binary, or component. They may be module names or .nupp paths. Generated Lua preserves module paths beneath outDir, so app.main becomes build/app/main.lua.

Dialect selection#

Every target resolves one source-lowering dialect. luajit is the default. luajit-compat lowers newer LuaJIT syntax while retaining LuaJIT's FFI, JIT, and native representations. It is for LuaJIT hosts whose runtime facilities are available but whose parser does not meet Nupp's default floor. lua51 may be selected on the build table, inherited by its targets, or overridden by one target:

build = {
   dialect = "lua51",
   targets = {
      portable = { entries = { "lib.main" } },
      native = {
         entries = { "app.main" },
         dialect = "luajit"
      }
   }
}

nupp build --dialect lua51 and nupp check --dialect lua51 override the selected target for that invocation and also work with explicitly named source files. The resolved value appears in build and check JSON and in nupp tasks. It is part of the cache key, so artifacts and checks from different dialects cannot satisfy one another.

The lua51 checker requires a supported target representation for every reached construct. Service facades resolve their implementations when required. See portable libraries for typed providers, target dependencies, and setup entry modules.

A target's dependencies are names, declared once at the top level of the manifest and shared by every target that lists them:

dependencies = {
   fast = {
      kind = "c",
      sources = { "native/fast.c" },
      bindings = { header = "native/include/fast.h" }
   },
   codec = {
      kind = "cargo",
      manifest = "native/codec/Cargo.toml",
      library = "codec"
   }
}

Each kind has its own keys: see C dependencies, Rust dependencies, rock dependencies, and type dependencies. Dependency acquisition and usage are separate: target dependencies ship with the target, compileDependencies are visible only while compiling, and a dependency selected by generators.*.using is a host tool. See Service Providers for generators, runtime service lookup, and the compatibility rule for ambient type dependencies. nupp test uses the default target and bundled runner. The optional test action names a different target to build first and command to run:

test = {
   build = "app",
   argv = { "nupp", "test-runner" }
}

See Testing for what that command is handed and how its results are reported.

Resource destinations#

Resource globs preserve paths relative to the nearest include root. A resource table gives one source an explicit target-relative destination when its runtime lookup is relative to another module:

resources = {
   {
      source = "src/public/schema.nupp",
      output = "app/data/schema.nupp"
   }
}

Manifest validation#

The manifest is validated before builds, checks, tests, and task queries. Validation covers dense string arrays, required target inputs, supported dependency kinds, named target and dependency references, and dependency cycles. A target's dialect is "luajit", "luajit-compat" or "lua51". Configuration errors name the invalid field before any build work starts.

Every table in the manifest takes a closed set of keys, and one that is not in it is refused by name, with the nearest spelling when there is one:

nupp: build.targets.site has no key "custmCss"; did you mean "customCss"?

The sets cover the top level, the build section, every target, a docs target's pages, test, tasks, selfHost, fmt, and each dependency. A dependency is checked against the keys its own kind reads, since what a C build takes and what a rock takes have almost nothing in common. Keys beginning with _ are the build's own, folded in from a command's options; a manifest has no reason to write one.

Dive deeper

A key nothing reads would otherwise take effect silently, which is the one way a configuration file can lie to the person who wrote it: custmCss rendered a site with the default theme and exited cleanly. Refusing the whole manifest costs one error message on a typo and removes a class of bug whose only symptom is that a setting appears not to work.

Listing targets#

nupp tasks lists the manifest's build targets, default or configured test action, self-host and fixpoint action, and any named tasks entries, and marks the default build target. nupp tasks <name> prints the effective target configuration, including manifest-level defaults such as outDir. Both forms accept --format json, or --json, for build-tool integration; text is the default.

nupp tasks
nupp tasks app --json
nupp task docs-serve

A named task also runs with nupp task <name>. See tasks.md for the manifest shape a task takes.

Removing build output#

nupp clean removes the output paths of every configured target; nupp clean --target <name> limits removal to one target. --dry-run prints the paths without changing them. Clean rejects absolute paths, parent traversal, and the project root before removing anything.

A multi-platform binary also accepts nupp clean --target <name> --platform <triple>; --platform all and an omitted platform remove all platform outputs owned by that target. A platform option without a target is refused.

Compiler-native features#

Compiler-provided native APIs do not appear in dependencies. Their resolved uses record effects while Nupp checks the target's complete source set, and the build stages the matching providers automatically. For example, nupp.io.path spells and transforms paths in Nupp, while calls that reach its working-directory and canonicalization provider record native.path and select the generic filesystem feature in build/lib/nupp_native_v2; a target with no resolved native use does not build or retain the library at all. The global nupp standard-library namespace itself is always created by generated code.

Nested members use the same exact resolution. nupp.uuid.v4() selects UUID support, while an alias such as local uuid = nupp.uuid followed by uuid.v4() selects the same feature without also selecting JSON or UTF-8. Files and filesystem-backed path operations use the Rust-native provider; whole-file transfers and processes share its Tokio executor and use bounded queues. HTTP uses Reqwest over Tokio and Rustls, URI uses Rust's url parser, and UUID uses the Rust-native provider. Built-in message digests are written in Nupp and stage no native artifact. Installed digest services may bring their own declared native dependencies. The Rust facilities share the versioned build/lib/nupp_native_v2 sidecar. Generated or external C interop builds its own declared native dependencies; there is no unversioned compatibility provider beside the Rust provider. Each provider is built with the union of its selected features. Pure facilities such as buffers, checksums and nupp.math emit their Lua adapters but stage no native artifact. At -O1 and above the build recomputes these effects from the post-folding tree, so a use found only in a constant-dead branch or loop is removed with that code.

The registry also recognizes compiler-provided modules. require("lpeg") selects native LPeg 1.1, while require("re") selects the bundled official Lua frontend and implies LPeg. Every nupp.peg matcher also selects LPeg: Nupp may emit a faster kernel for a recognized static graph, but the typed matcher shell and general lowering share the same native feature. A local table named nupp, or a computed require, does not claim a compiler feature: only the resolved global path and literal module name do.

Feature overrides#

Detection is the default, not a requirement to configure every target. A target may override one answer when it deliberately supplies or forbids a provider: true forces inclusion, false forces removal, and an absent name keeps the detected answer.

nativeFeatures = {
   json = true,
   lpeg = true,
   uuid = true
}

The forceable binary feature names are json, lpeg, path, uri, uuid, files, process, workers, and http. The registered module effects include nupp.codec.json, native lpeg, and the Lua re module that requires it. Bundled LuaRock modules are checked too, so Lunamark contributes LPeg even when application source does not require it directly. Forced removal is an expert escape hatch: if reachable code still requires that provider, the resulting program fails at runtime in the usual way.

Platform builds#

A binary target may use stub = "nupp" to ask the source compiler to build its own host with exactly the resolved host features. A path-valued stub remains a prebuilt or third-party artifact and is never silently relinked. The same compiler-owned target can name catalog platforms:

platforms = {
   "x86_64-unknown-linux-gnu",
   "aarch64-apple-darwin",
   "x86_64-pc-windows-msvc"
}

Build one with nupp build --target app --platform <triple> or all in manifest order with --platform all. A multi-platform default output is <outDir>/<target>/<platform>/<target><executableSuffix>; platformOutputs may map configured triples to custom raw paths. POSIX platforms also own a deterministic .tar which records mode 0755.

An explicit macOS result is unsigned and build JSON reports distributionReady = false with a signing notice. Sign it on macOS with codesign --force --sign - <binary> for local execution. The release workflow uses Developer ID signing and notarization when its optional Apple credentials are configured; otherwise its macOS archive remains unsigned and says so.

Platform selection sets layoutTarget for that build and separates its cache and completion state. The selected compiler-owned catalog stub satisfies every resolved feature that has a host implementation, so files and process do not stage a current-machine sidecar beside a foreign executable. Sidecar-only features such as path, URI, UUID and HTTP are refused until the catalog has a provider artifact for that platform.

Standalone native binaries#

A compiler-owned binary can relink its host with the target's static native closure before stamping the payload:

build = {
   kind = "binary",
   stub = "nupp",
   standalone = true,
   entries = { "app.main" },
   dependencies = { "image" }
}

nupp build --target app --standalone enables the same mode for one build. Nupp compiles source C dependencies as static archives, rewrites their generated bindings to the executable's process namespace, emits native AOT as a static archive, and force-loads the complete archive closure into the host. The result does not carry those libraries under lib.

An explicitly shared C dependency is refused in this mode; use linkage = "static" or "both". Native facilities which only have a sidecar implementation are refused for the same reason. A path-valued third-party stub cannot be relinked and therefore cannot select standalone.

Static AOT components#

An embedded host that owns its final executable can consume a component's AOT code as an archive instead of carrying a shared-library sidecar:

build = {
   kind = "component",
   entries = { "game.main" },
   aot = "require",
   aotLinkage = "static",
}

aotLinkage is "shared" by default. That route writes a shared AOT library beside the component and the generated binding opens it at runtime. "static" writes an AOT archive under outDir/lib for the embedding application's link; the generated binding instead resolves ordinary kernels from the process C namespace. It does not make Nupp produce or relink an executable.

Choose "shared" for a file-based application or a component that must be updated independently of its host. Choose "static" only when the host owns the final link and deliberately has no dynamic-loader dependency. The host must force-link and retain the archive, export its AOT symbols to the embedded LuaJIT default namespace, and use the same target ABI as the component. Static archives are source-qualified because every linked component shares one C namespace.

A component whose AOT entry constructs Lua tables or strings also carries a Lua C-module registrar. Its host registers that archive before it loads the component; see Embedding Nupp. Pure numeric and span kernels need no registrar. A static component cannot make a sidecar-only native provider available: its native dependencies must already be linked into, or otherwise supplied by, the host.

A static build writes two more things beside the archive. outDir/aot/archive.c defines one exported probe, ks_aot_archive_<component>, returning a number derived from the archive's contents, and every rewritten module checks it before anything else it declares. A name that does not resolve reports that the archive was not linked into the host; a value that disagrees reports that the archive which was linked is a different build of the component. Both are load errors naming the cause, in place of the dlsym failure an unretained archive otherwise produces on whichever kernel happened to be declared first.

outDir/aot/link.json is the handoff for whoever performs that link. It names the component and target, the archive, the probe symbol and its expected value, every exported kernel and registrar symbol, the builder registrations the host owes, and the retain and export flags the target's linker takes, with <archive> standing for wherever the archive ends up:

{
  "schemaVersion": 1,
  "component": "gameScripts",
  "target": "aarch64-apple-darwin",
  "archive": "lib/libgameScripts_aot.a",
  "fingerprint": {"symbol": "ks_aot_archive_a91c2e...", "value": 54690558639838},
  "retain": {
    "forceLoad": ["-Wl,-force_load,<archive>"],
    "export": ["-Wl,-export_dynamic"]
  }
}

Each entry in builders names both a key and the symbols behind it, because they are not the same string: the host calls a tier-spelled registrar symbol and registers what it returns under the unsuffixed key, which is what the generated wrapper looks the table up under.

A target whose VM uses a vendor static symbol registry instead of a linker option has no retain flags to give; symbols is then the list that registry must contain. aot = "emit-c" with static linkage writes the same C units, probe, and link manifest without compiling any of them.

Target capability profiles#

A target profile says what a destination admits, as distinct from what its pointers are: whether it has a dynamic loader, a tracing JIT, working FFI callbacks, a VM that resolves default-namespace symbols out of the process image, and a toolchain that produces static AOT archives. Every publicly modelled triple has a built-in profile and answers yes to all of them, except wasm32-unknown-emscripten, which has no tracing JIT and no native archive.

The profile is what static linkage is checked against. A build that selects aotLinkage = "static" for a target whose profile does not produce static archives, or whose VM does not resolve symbols out of the process image, is refused with that reason rather than producing an archive nothing can use.

Three source constructs are refused the same way, as NUPP2904. @jit asserts that a tracing contract exists, so a target with no trace compiler rejects it instead of quietly rereading it as advice. A cdef ... from "name" asks the platform to load a library, so a target with no loader rejects it; the same C is reachable through the default namespace, which is what a static link puts there. An ffi.cast to a function type asks the VM for a callback trampoline, so a target that allocates none rejects it — ahead of the unsafe question, because unsafe says the author accepts what a callback costs rather than that the destination can make one, and unlike the jit-callback lint beside it this cannot be waved away with @allow. A target nothing describes refuses nothing.

A vendor describes a private target in its compiler pack, so nothing about a confidential platform has to reach the public distribution catalog. pack.json takes an optional profile:

"profile": {
  "layoutModel": "aarch64-unknown-linux-gnu",
  "os": "linux",
  "capabilities": {
    "dynamicLoader": false,
    "tracingJit": false,
    "ffiCallbacks": false,
    "staticSymbolResolver": true,
    "staticAot": true
  },
  "link": {"forceLoad": [], "export": []}
}

Every capability must be stated: an omitted one is one nobody verified, and defaulting it to the permissive answer is the mistake profiles exist to prevent. layoutModel names an already modelled triple, so admitting a target does not also open the set of layout models. A descriptor wins over the built-in answer for the same triple, because a vendor port of a public triple is still that vendor's port.

Current-platform source builds use the repository toolchain driver. Installed and cross-target builds use a compiler pack selected by host and target triple. Installed distributions discover packs under lib/nupp/compiler-packs beside their bin directory. NUPP_COMPILER_PACK_DIR overrides that location with a pack tree containing <host>/<target>/pack.json; Nupp verifies the recorded size and SHA-256 of its compiler, archiver, and host linker before running them. An explicit NUPP_NATIVE_CC or dependency cc remains the expert override and the ambient compiler search remains the compatibility fallback when no pack directory is configured.

Tagged Linux x86-64 and Windows x86-64 archives carry their matching native pack under lib/nupp/compiler-packs, and the release publishes the same tree as a separate pack archive for an existing installation. Nupp finds the bundled tree both after a conventional bin/lib installation and while the release archive is being run directly. Release CI poisons ambient compiler names and requires the installed pack to build and run one standalone target containing both generated C FFI and AOT code before either archive is published.

The macOS arm64 release does not carry a compiler pack. Apple does not permit a release to redistribute the Xcode SDK that a complete pack would require, so a macOS standalone native source build currently uses locally installed Xcode command-line tools. Ordinary stamped binaries and target-indexed prebuilt static C artifacts do not acquire that source-build requirement. Cross-target packs are not yet release artifacts.

pack.json has schemaVersion = 1, host, target, version, authenticated cc and ar tool records, optional cxx and linkHost records, and compileFlags/linkFlags arrays for its sysroot. Each tool record contains a pack-relative path, sha256, and size. linkHost, when present, accepts FEATURES OUTPUT ARCHIVE... -- LINK_FLAG...; it owns one retained Rust application-host archive containing the entry point, VM, exact-feature native provider, and platform SDK linkage. Platforms force-load that archive where safe; Windows selects its one-codegen-unit export surfaces normally after the application archives so unused Rust and system import objects stay unlinked. {pack} inside a compile or link flag expands to the selected pack directory, so a sysroot remains relocatable after the archive is installed elsewhere.

Native artifacts#

Native artifacts are sidecars for modules targets and ordinary prebuilt stubs. Ship the target's lib directory with a binary unless its selected stub links the provider itself; a Lua payload cannot embed a shared library. A one-file bundle target rejects providers that need sidecars. Host-supplied modules such as LPeg remain ordinary require dependencies and must be available in the runtime that loads the bundle. See Distribution for what a stamped binary can and cannot carry.

Documentation targets#

A kind = "docs" target runs the parse-only documentation generator through the same nupp build --target interface:

docs = {
   kind = "docs",
   sources = { "src" },
   format = "both", -- site, markdown, or both
   outDir = "build/docs",
   title = "Project API",
   includePrivate = false,
   github = "https://github.com/example/project",
   logo = "images/project.svg",
   public = "docs/public",
   customCss = "docs/site.css",
   constructorPattern = "^new",
   pages = {
      { glob = "docs/**.md" }
   }
}

The keys the target itself reads:

Key Effect
sources Roots the API reference is read from
format site, markdown, json, or both
outDir Where the rendered output is written
title The site's own title
name Brand name beside the logo, when it differs from title
description One line for the home page and the llms.txt index
github Repository link in the header
logo Image for the header brand, replacing the default mark
favicon Icon linked from every page
public Directory copied to the output root, for images and downloads
customCss Stylesheet appended after the default theme
lexers Directory of project Scintillua lexers, searched before the bundled ones
includePrivate Renders the declarations privacy rules hide
constructorPattern Lua pattern a constructor's last name segment matches
pages Handwritten pages: a glob over a tree, a directory, or one source at one path
diagnostics The generated diagnostic index, and the page it is appended to
stdlib The generated LuaJIT standard library page
dependencies Rocks to install before rendering, lunamark among them

The appended customCss overrides the documented --nuppdoc-* custom properties without changing other documentation targets. A page entry also takes redirects for the routes it used to answer at, and layout = "home" for a landing page, whose hero and feature showcase are written in the page's own Markdown. See doc.md for doc comments, page syntax, privacy rules, and what each output format writes.

Page trees#

A page entry may name a glob instead of a path and a source. It then stands for every Markdown file the pattern matches, each published where it sits: docs/learn/projects/build.md answers at guides/build, and an index.md names the directory holding it rather than a route ending in index. base is the directory routes are named from, defaulting to the fixed part the pattern opens with, and exclude drops files the tree holds and the site does not publish.

{
   glob = "docs/**.md",
   exclude = { "docs/style.md" }
}

What a path cannot say, the page says in a frontmatter block of key: value lines between --- fences:

Field Effect
order Where the page sits in the navigation
title What navigation calls it, when its heading is not what to call it
redirects Routes it used to answer at, separated by commas
layout The layout it renders under, home being the one that differs

The sidebar follows every segment of a route, in the order their first page appears, so one order per page settles the order of sections and pages at each level. A page that names none follows the pages that do, in the order the directory lists them. A page that names no title and carries no heading is titled by the module it documents, which is what a module overview wants and how it stays correct when the module is renamed.

A page whose route a directory entry already publishes is left to that entry, and so is the file the diagnostics index opens with. Sweeping a tree does not publish either one twice.

Page directories#

A page entry may name a directory instead of a source. The entry then stands for every .md file under that directory, published at path followed by the file name without its extension, plus an index generated at path itself. A document is published by existing, so nothing has to be added to the manifest when one is written.

{
   path = "neps",
   title = "NEPs",
   directory = "docs/neps"
}

Each document may open with a frontmatter block of key: value lines between --- fences. title names the document, falling back to its first heading and then to its file name. Every other field is rendered under the heading, so a status: line appears on the page and in the generated index without being written into the prose. A value may be quoted, and the quotes are dropped.

A file name beginning with digits and a hyphen, such as 0001-process.md, carries that number as the document's identity. The number is shown without its padding and prefixed with the entry's title made singular, so a collection titled NEPs titles its first document NEP 1.

index.md is the collection's own page rather than a document in it. Its prose opens the index and the table of documents is generated below it. Links between documents are written as ordinary relative Markdown links and are resolved to routes like links in any other handwritten page.

Only the index appears in the navigation; its documents are reached from it. A collection may therefore sit inside an existing section, so path = "reference/neps" puts one under Reference without filling that section's sidebar with every document it holds.

Cache and failure behavior#

Build state is JSON in outDir/.nupp-state.json. Cache keys cover source content, configuration, compiler artifacts, native tool versions, flags, target settings, and dependency inputs. Generated files are rewritten only when their content changes. A missing or malformed state file causes a cold build.

Warm builds reuse checked module records and generated Lua across processes. A source edit checks and generates that module; dependents are only invalidated when its exported interface fingerprint changes. Changes to project-wide type declarations invalidate the project index, while body-only edits preserve it. Deleting the state file, changing compiler or configuration inputs, or modifying an emitted artifact safely falls back to the required cold work.

The optimization level is among those keys. nupp build -O2 reaches the configuration before it is hashed, so changing it invalidates every artifact built with the old optimizer contract rather than leaving a project half compiled under each. Switching therefore costs a cold build, and cannot produce a mixture. -O0 is the default and performs no rewrite; see the performance guide for what the levels above it do.

Compiler identity#

"Changing the compiler" means changing the part of it that computes the answer being reused, not changing any part of it. Module artifacts are keyed on what compiling a module reaches; parsed headers on the parser; formatting verdicts on the formatter; comptime type blueprints on the checker. Each is the digest of that module and everything it requires, read off the compiler's own tree, so a new command, a language-server change, or an edit to a diagnostic's prose leaves all four reusable. Anything that cannot be read that way, such as a compiler that is one bundled file or a require naming a computed module, falls back to the digest of the whole compiler, which invalidates more than it has to and never less.

Shared content caches#

Two of these stores hold answers about content rather than about a project: a file's header and its formatting verdict are the same answers wherever the file is. NUPP_CACHE_DIR names one directory for them, which is what a run making many small projects wants, the test suite being one that makes a project per case, so the second project starts from what the first worked out.

NUPP_CACHE_DIR=/tmp/nupp-cache nupp check

The build state is not moved by it: its records are keyed by module name, so two projects sharing them would read each other's modules.

Compiled modules#

A modules build also leaves <outDir>/.bytecode: each module it wrote, compiled once, so that starting the program parses none of them. For the compiler's own build that is a hundred and eighty files and about forty milliseconds off every command.

Entries sit under a directory named for the LuaJIT that wrote them, because a checkout can be worked on by more than one -- one on the path and one the toolchain built -- and neither should be able to take the other's cache away. Within it an entry is named by the digest of the module it holds, and the index beside them maps a module name to a digest and records the directory they were written for. Nothing about a build depends on any of it: an index that is missing, damaged, or written for a tree that has since moved is ignored, and the modules are parsed the way they always were. What the index does not name is removed on the next build, so the directory holds one entry per module rather than one per edit.

The recorded directory matters because a dump carries the chunk name it was given, and the compiler reads that name back off itself to find its declarations and its native library. Entries name the tree they were written for, so a copied build directory is ignored until a build rewrites it.

nupp clean removes it with the rest of the build.

Write ordering#

The checker and generator finish before module outputs are changed. Each file is written through a sibling temporary file, state is saved after the artifacts, and .nupp-complete is written last. bin/nupp reads that marker to decide whether the compiler in build/ has seen every edit and needs rebuilding before the command it was asked for.

It is not how the compiler to run is chosen. A build removes the marker before it writes anything, so for as long as a build takes there is a working compiler in build/ and no marker beside it; bin/nupp runs the compiler that is there, and fetches the stage zero only when there is none. One build at a time writes a tree -- a build takes a lock for as long as it runs, and a command that has to read the compiler waits for it.

Build progress and timing#

A build run from a terminal names the module it is working on, on one line it rewrites in place, and finishes with how long it took, where that time went, and which modules cost the most of it:

built compiler in 18.9s: 164 compiled, 0 reused
  check 16.1s  generate 952ms
  slowest
    nupp.compiler.gen            1.9s
    nupp.mem.heap                699ms
    nupp.compiler.check.calls    664ms

Time is accounted as a timeline: one activity is current at any moment, so switching closes the one before it and the activities add up to the run rather than overlapping it. They are scan, which is deciding what can be reused, check, generate, write, persist, dependencies, native, bundle, and other for what is left over. A warm build that compiles nothing still reports check: deciding a module can be reused consults the exported call guarantees of the modules it depends on, and answering that is a check.

Per-module numbers are exclusive. A module's check reaches its imports through the query graph, so the time those take is charged to them rather than to whichever module reached them first. Otherwise the slowest module would be whichever one the build happened to start with.

Nothing is written unless standard error is a terminal, so a build driven by a script is as quiet as it has always been. --progress=always reports anyway, -q reports nothing, and NUPP_PROGRESS says the same thing with always, never or auto for the builds nothing passes a flag to, including the rebuild bin/nupp runs before every other command. nupp build --json carries the same numbers in a timing object rather than writing a report.

Type dependencies#

kind = "types" supplies checker-only declarations. A type dependency is ambient for the whole project, rather than named by a build target: Nupp reads it while checking, but never executes it, adds it to package.path, or copies it to the output.

love = {
   kind = "types",
   format = "luacats",
   source = {
      git = "https://github.com/LuaCATS/love2d.git",
      rev = "<full commit id>",
   },
   path = "library",
}

The first supported format is luacats. Its Lua files are parsed as annotated declarations, so a definition of a global such as love becomes available to Nupp source without a local adapter. The Git revision is mandatory and must be a full commit id. Nupp caches the checkout under .nupp/deps/<name>, and nupp check, nupp build and the language server all resolve the pinned revision before checking, so an editor and a build agree about what the project declares.

Future type providers use the same kind = "types" lifecycle and choose their reader through format, so a Teal importer does not need a separate dependency system.

C dependencies#

kind = "c" supports local sources, pkgConfig, compiler and linker flags, include directories, exact-revision Git sources, and shared or static linkage:

zstd = {
   kind = "c",
   source = {
      git = "https://github.com/facebook/zstd.git",
      rev = "<full commit id>"
   },
   path = "lib",
   sources = { "*.c" },
   bindings = { header = "zstd.h" }
}

pkgConfig may be one package name or an array when a native library uses several installed APIs, such as { "libpng", "zlib" }. The packages are resolved together so their compiler and linker flags reach the same build.

Fetched Git trees live under .nupp/deps and require an explicit revision. C dependencies default to linkage = "shared" and emit a .so, .dylib, or .dll under outDir/lib. linkage = "static" emits lib<name>.a instead, while linkage = "both" emits both from one set of compiled objects:

image = {
   kind = "c",
   linkage = "both",
   sources = { "native/*.c" },
   bindings = { header = "native/image.h" }
}

The shared artifact remains the one a generated FFI binding loads. A static artifact is a linker input: another C dependency can name this dependency and will link the archive into its own shared library. A static archive becomes part of a binary only when that target selects standalone; ordinary binary targets retain the shared-library sidecar path.

out renames the sole artifact for shared or static; with both, it names the shared artifact. staticOut independently renames the archive and is most useful with both. Both default paths are under outDir/lib.

A configured header is passed through import-c, and the resulting Nupp module is placed under outDir/generated so it participates in normal module resolution. A static-only binding without a bindings.library, load, or pkgConfig override declares symbols in the process namespace for a host that links the archive; an ordinary Nupp-produced sidecar should use shared or both so the binding has a loadable library.

sources and headers are path globs: * and ? stay inside one component, while **/ matches zero or more directories. pkg-config output honors shell quotes and backslash escapes, but is never expanded or executed by a shell. Static linkage asks pkg-config --static for the transitive flags that must travel with an archive.

Target-indexed prebuilt C artifacts#

A library publisher can provide authenticated shared, static, or paired artifacts per target triple instead of asking an application build to compile the library again:

image = {
   kind = "c",
   linkage = "both",
   bindings = { header = "include/image.h" },
   artifacts = {
      ["aarch64-apple-darwin"] = {
         shared = {path = "prebuilt/macos/libimage.dylib", sha256 = "<64 hex>", size = 12345},
         static = {path = "prebuilt/macos/libimage.a", sha256 = "<64 hex>", size = 23456}
      }
   }
}

Selection uses the build's layoutTarget, or the modeled host triple for an ordinary host build. The file is read from the dependency root, authenticated, and staged under the same output name a source build would use. A standalone target selects static; an ordinary generated binding selects shared. Sources remain a fallback for triples without a matching artifact set. Prebuilt artifacts cannot request a generated macro/inline bridge, since that bridge must have been compiled into the published library already.

Header-only C dependencies#

An API made entirely from static inline functions and function-like macros has no native symbol for LuaJIT to load. Opt its binding into a generated bridge; no empty .c source is required:

image = {
   kind = "c",
   includeDirs = { "native" },
   headers = { "native/**/*.h" },
   cflags = { "-std=c11", "-Wall", "-Werror" },
   cppflags = { "-DIMAGE_FAST=1" },
   bindings = {
      header = "native/image.h",
      bridge = true,
      macros = {
         IMAGE_CLAMP = {
            parameters = { "int32", "int32", "int32" },
            result = "int32"
         },
         IMAGE_IGNORE = {
            parameters = { "int32" }
         }
      }
   }
}

The binding keys have separate jobs:

Key Effect
header Header to preprocess and import; required for generated bindings
library Override the library name or path written into generated cdef declarations
out Override the generated Nupp module path
bridge Wrap eligible named static inline definitions from header
macros Wrap only the listed function-like macros using explicit signatures

Each macro recipe requires a dense parameters array. result is optional; omit it for a void wrapper. The accepted value forms are boolean, float, number, integer, int8 through int64, and uint8 through uint64. Recipes do not accept pointer forms, varargs, or arbitrary C declarator text. bridge controls inline discovery; a macros table can request macro wrappers independently.

The header above is then consumed under the dependency name:

local image = require("image")

local tripled = image.image_triple(14)
local clamped = image.IMAGE_CLAMP(20, 2, 8)
image.IMAGE_IGNORE(clamped)
print(tripled) -- 42

For the default outDir, a macOS build writes:

build/generated/image.nupp
build/generated/image_bridge.c
build/lib/libimage.dylib

Linux uses libimage.so; Windows uses the platform DLL name. The generated binding names that library @lib/libimage.dylib: a leading @ is resolved against the module that loads it rather than handed to the platform loader, so a copied or moved output tree still finds it. A kind = "bundle", "binary" or "component" target is one file someone carries somewhere, so the build puts a copy of the library beside the artifact, the way it already does for compiled @aot code. A bindings.library override, a load naming a library already installed, and a pkgConfig package are written through unchanged, since none of them are part of what the build ships.

The generated translation unit includes the original header and exports only deterministic private wrapper symbols. It is compiled with the dependency's cc, includeDirs, cflags, cppflags, package flags, and linker inputs, then installed into the same shared library as any ordinary sources. A dependency containing only bridge wrappers still produces the library.

The dependency cache includes the header, source and bridge bytes, macro recipes, compiler identity, flags, package flags, child dependencies, and manifest configuration. A changed header or recipe regenerates both the module and bridge. The binding's named header is tracked automatically; list its local include closure under headers as above so a transitive header edit also invalidates the native artifact. Disabling bridge and removing macros produces no bridge source, compiler invocation, or bridge-only library.

Macro arity and type recipes are validated before a generated binding is installed. The C compilation then validates the original macro expansion and inline bodies under the selected flags. Either failure stops the dependency build; compiler-failed generated source remains inspectable, but no successful target may consume it. See Calling C safely for a complete header, standalone bridge emission, inspection output, ownership refinements, and the supported boundary.

Rust dependencies#

kind = "cargo" delegates package resolution and locking to Cargo. The provider builds a cdylib into an isolated target directory and copies the platform library into outDir/lib. Cargo.lock is enforced by default; locked = false is available for newly created projects, and offline = true passes Cargo's offline policy through. target, profile, and features are part of the dependency cache key.

When bindings.cbindgen is enabled, the provider runs cbindgen in the crate directory before passing its header through import-c. command can override the cbindgen executable, for example when a project pins a wrapper around a particular cbindgen release.

The header describes the ABI, not ownership. A Rust Box<T> becomes a raw pointer in cbindgen output, so name its policy explicitly when the caller owns it. returns maps a constructor to its cleanup function and takes marks the parameter positions that cleanup consumes:

bindings = {
   cbindgen = true,
   ownership = {
      returns = { codec_create = "codec_destroy" },
      takes = { codec_destroy = { 1 } }
   }
}

The generated binding represents codec_create as affine(Codec*, codec_destroy). This changes checking and lexical cleanup, not the C ABI. An ownership mapping also asserts the returned pointer is non-null: that is correct for Box<T>, but not for a nullable factory. See ownership.md for what the checker then enforces at the boundary.

The copy in outDir/lib is named the way a C dependency's library is, so a generated binding says @lib/libtiny_rust.dylib and a copied or moved output tree still finds it. A single-artifact target carries it beside the artifact for the same reason.

Rock dependencies#

kind = "luarocks" installs a Lua library with LuaRocks. Nothing is built and nothing is generated: what the provider produces is a populated tree and the two search-path entries that reach it.

dependencies = {
   -- From the LuaRocks server, at the version named here.
   lunamark = {
      kind = "luarocks",
      version = "0.6.0-1"
   },
   -- From a rockspec in the project, for a library upstream does not publish.
   scintillua = {
      kind = "luarocks",
      rockspec = "rocks/scintillua-6.7-1.rockspec"
   },
   -- From sources that ship with the project: `luarocks make`, no fetch.
   tinyrock = {
      kind = "luarocks",
      rock = "tinyrock",
      path = "vendor/tinyrock",
      rockspec = "vendor/tinyrock/tinyrock-1.0-1.rockspec"
   }
}

A rock must be pinned by one of those three, a version, a rockspec, or a path, and a manifest that pins none of them is refused before any build work starts. Naming both a version and a rockspec that declares a different one is refused too. A rock does not list dependencies of its own: LuaRocks resolves what a rock needs, which is the reason to use it.

rockDependencies = false passes --deps-mode=none when an upstream rockspec's dependency list is deliberately unsuitable. The target must name separately pinned rock dependencies before that rock; Nupp does not infer or replace what was disabled. This is an escape hatch for taking ownership of a dependency boundary, not an unpinned-install mode.

Field Meaning
rock The rock's name, when it differs from the dependency's
version The exact version to install
rockspec A rockspec in the project to install from
path A directory to build in place with luarocks make
bundle Globs naming what a bundle or binary carries
tree Where to install, .rocks by default
luaVersion The tree's Lua version, 5.1 by default
luaDir Where the Lua headers and libraries live
server An additional rocks server to fetch from
luarocks The LuaRocks executable, luarocks by default

Rocks install into .rocks in the project root, a tree the project owns rather than the one the user's account owns, so two checkouts can hold different versions of a library without either able to break the other's build by upgrading something. LuaJIT is Lua 5.1, and a C rock compiled against another 5.1 loads into a VM that cannot call it, which is what luaVersion pins. The headers a C rock compiles against are found from the running interpreter's own module path; luaDir, or the NUPP_LUA_DIR environment variable, names them instead.

A pinned rock already installed at the version asked for is left alone, so a warm build reaches for nothing. A rock built from path is remade whenever its sources change, which is what the fingerprint is for.

The tree is added to the search path of the build that installed it, so a target's own dependencies are loadable the moment they are installed, and nupp doc installs its renderer and renders with it in one command. nupp test puts the tested target's trees on LUA_PATH and LUA_CPATH for the test command, ahead of what is already there and without replacing it. Anything else that runs outside the build reads the tree the way LuaRocks trees are always read.

An installed rock may also carry typed module declarations in its versioned nupp/ directory. See LuaRocks for authoring, packing, testing, and publishing that layout.

Carrying a rock into a bundle#

A bundle and a binary are one file, and one file cannot bring a rock tree along. bundle names what goes in with it, as globs over the tree the rock installs into:

lunamark = {
   kind = "luarocks",
   version = "0.6.0-1",
   bundle = { "lunamark.lua", "lunamark/**.lua", "cosmo.lua", "cosmo/**.lua",
      "re.lua" }
}

Each selected file becomes a package.preload entry under the name require would have found it by in the tree, so lunamark/writer/html.lua becomes lunamark.writer.html, and a foo/init.lua becomes foo. The same require therefore resolves in a checkout, in a bundle, and in a stamped binary, and the program cannot tell which it is running in.

Named rather than swept, because a rock tree also holds test scripts, command-line programs and documentation that nothing will ever ask for. A rock with no bundle is installed and not carried, which is the right answer for anything only the build itself uses.

A rock's C libraries cannot ride in a payload, because a .so is not a Lua chunk, so a binary that needs one needs a stub linked against it. Nupp's own stub links the three its commands cannot run without; see Distribution for that boundary.

Self-hosting#

A self-hosted compiler cannot compile its own sources until some other compiler has, so selfHost says where that other one comes from. selfHost.bootstrap names a command, run from the project root, whose last line of output is the path of the stage-zero compiler; selfHost.target is the target that rebuilds the compiler, and selfHost.bootstrapTarget composes the bundle a new stage zero is published from.

nupp fixpoint runs the command, then builds three times: stage zero builds stage one, stage one builds stage two, and stage two builds stage three. Stages two and three must be byte identical.

Three stages rather than two, because the first one is not a claim about anything. Stage zero is a different compiler -- an earlier release, on purpose -- so stage one is these sources as that compiler emits them. Stage two is the first tree emitted by code the current sources describe, and stage three is stage two doing it again. Their being identical says the compiler is a fixed point of itself rather than of whatever happened to build it. The working compiler is updated only after they match.

nupp fixpoint --emit-stage0 PATH also writes the stage-zero bundle the verified compiler composes, which is the artifact a release publishes for the next checkout to start from. Nothing reads it back: a stage zero is meant to differ from the compiler in the tree, and nothing compares the two.

This repository's selfHost.bootstrap is scripts/toolchain stage0, which fetches the release pinned in scripts/toolchain.pins and verifies it against the digest committed beside it. The cost of that is a rule on these sources: they may only use language features the pinned release already understands, and a feature reaches them a release later. NEP 28 records why that trade was made.

The build system's own implementation lives under the internal nupp.compiler.build.* namespace in src/nupp/compiler/build/: project owns orchestration, hash owns cache digests, and process owns argv-based subprocess execution.

FAQ#

Why does the build compile a module nothing requires?#

Because the source set is what a project is written in, and a module left out of the build is a module nothing checked. See Source sets for what the alternative costs.

Why did one edit rebuild the whole project?#

An edit to an exported type declaration invalidates the project index, where an edit to a function body invalidates one module. nupp check --json reports timing.compiledModules and timing.slowest, so a run says how much it redid rather than leaving that to be inferred from how long it took.

Can a one-file bundle carry a native library?#

No. A bundle contains Lua chunks. It can require a host-supplied module such as LPeg, but cannot embed a shared library. A binary that uses native providers ships their sidecars or links them into its host. See Distribution for the whole boundary.