Build plans and generated code.
Most packages need nothing but their .silk files and silk.toml. When a package needs more, such as a table built from a data file, a C shim, or a module written by a code generator, Silk offers a few tools. Each one does more than the last, so pick the first that fits:
| Need | Use |
|---|---|
| Link a library, compile C files, or use a prebuilt SDK | [native] keys in the manifest. See Build outputs and native linking |
| Embed a file of the package, or parse it while compiling | silk.build.source |
| Run a generator yourself and commit what it writes | [[generate]] and silk generate |
| Compute modules, sources and libraries from the package's files on every build | build.silk |
| Run a real program on every build, such as a code generator you cannot write in Silk | A tool step in build.silk |
None of them runs arbitrary code with your user's rights. There is no build script in the Cargo sense: a plan runs inside the compiler, and a tool step runs in an operating-system sandbox.
Reading package files#
silk.build.source reads a file of the calling package while the package compiles:
| Member | Result | Value |
|---|---|---|
Source.read(path) | &'static [u8] | The file's bytes |
Source.text(path) | string<'static> | The file's text, checked to be UTF-8 |
import silk.build.source { Source }
import silk.core.static_text { StaticText }
pub fn sceneShader() -> &'static [u8] {
return Source.read("shaders/scene.wgsl")
}
static fn composedShader() -> string<'static> {
return StaticText.concat(Source.text("shaders/blend.wgsl"), Source.text("shaders/compose.wgsl"))
}The path is relative to the directory that holds the package's
silk.toml, uses/, and has no empty,.or..segment, so a package reads only its own files.Write the path as a string literal in the call. The build finds the files each module reads from those literals.
Runtime code receives the file as a constant. A
static fncan parse it while the package compiles.A library's own
static fnreads the library's files, even when another package calls it.Editing the file rebuilds only the code that read it.
silk checkreads it too.
See Static evaluation in the reference for the exact rules.
Committed generators: silk generate#
A generator is a program you run yourself, such as a script that turns the Unicode data files into tables. Its output is committed, so a build never runs it. Declare each one in the manifest:
[[generate]]
run = ["scripts/colors", "colors.txt"]
outputs = ["src/colors.silk"]runis the program, then its arguments. A program spelled with a/is a path below the manifest's directory; a bare name is searched onPATH.outputslists the files it writes, below the manifest's directory. A*in the last segment matches any run of bytes, as insrc/generated_*.silk.A workspace manifest can declare entries too.
silk generate runs the entries in order, in the manifest's directory, with your environment plus SILK_GENERATE=1, and names each output that changed:
silk generateCreated src/colors.silk
Ran 1 generator: 1 changed, 0 unchangedAn output written with the same bytes keeps its file, modification time included, so a rerun that changes nothing invalidates no build cache:
Ran 1 generator: 0 changed, 1 unchangedsilk generate --check runs the generators but leaves the tree as it was, and exits 1 when a committed output is out of date. Run it in CI:
silk: src/colors.silk is stale
silk: generated output is stale; run `silk generate` and commit the resultA generator that fails, or that does not write an output declared without a *, leaves every output as it was, and silk generate exits 1. A manifest it cannot use exits 2.
Build plans: build.silk#
A build.silk beside silk.toml computes, on every build, what the manifest cannot say. It declares pub const PLAN: string, built with silk.build.plan from Plan.empty():
import silk.build.plan { Plan }
import silk.build.source { Source }
import silk.core.static_text { StaticText }
static fn plan() -> string<'static> {
let mut plan = Plan.empty()
plan = Plan.module(plan, "limits", StaticText.concat("pub const LIMIT: i32 = ", Source.text("limit.txt")))
plan = Plan.compileC(plan, "native/twice.c")
return plan
}
pub const PLAN: string = plan()The package's own code then imports the generated module like any other:
import limits { LIMIT }
unsafe extern "C" fn twice(value: i32) -> i32
pub fn main() -> i32 {
return unsafe twice(LIMIT / 2) - 42
}With 42 in limit.txt, silk run exits 0.
The plan is a constant, computed by static evaluation. It runs no process, reaches no network and reads only the package's own files. Every build and check evaluates each package's build.silk first, on its own: it can import only the standard library, not the package it builds. Its steps then join the package as if written there:
| Step | Effect |
|---|---|
Plan.module(plan, name, source) | Module name of the package, as if the file <name>.silk were in its source root |
Plan.compileC, compileObjectiveC, compileCpp | A [native].sources entry { c = path } (or objc, cpp) |
Plan.linkLibrary(plan, name) | A [native].link entry { library = name, mode = "dynamic" } |
Plan.linkFramework, linkArchive, linkObject | A [native].link entry { framework = ... }, { static-archive = ... }, { object = ... } |
Plan.tool and the tool... steps | A tool step; see below |
A dependency's plan reaches its dependents the way its manifest entries do. A module name must be one path segment, such as generated_tables, and a module the package's own files also define is refused.
A plan that goes wrong refuses the build with exit status 1, naming build.silk, after any diagnostics of its evaluation. A build.silk with no PLAN:
Checking tables (x86_64-unknown-linux-gnu)
load failed: 1 error 0.06s
silk: /home/you/tables/build.silk: build.silk computes no plan: its `PLAN` cannot be evaluated
[x86_64-unknown-linux-gnu]
build.silk:1:1: error[SEM0006] A present value name with no matching local declaration
Failed tables in 0.07sThe full API is on the silk.build.plan page of the Std lib API reference.
Tool steps#
A tool step runs a program of the package while it builds, for a job only a real program can do. It declares the program, its arguments, the files it reads and the files it writes:
import silk.build.plan { Plan }
static fn plan() -> string<'static> {
let mut plan = Plan.empty()
plan = Plan.tool(plan, "colors", "tools/gen.sh")
plan = Plan.toolInput(plan, "colors.txt")
plan = Plan.toolOutput(plan, "colors.silk")
plan = Plan.toolModule(plan, "colors", "colors", "colors.silk")
return plan
}
pub const PLAN: string = plan()#!/bin/sh
count=$(wc -l < "$SILK_PACKAGE_DIR/colors.txt")
echo "pub const COLOR_COUNT: i32 = $count" > colors.silkPlan.toolModule serves the output colors.silk as the package's module colors, so import colors { COLOR_COUNT } works. toolArgument, toolInput and toolOutput apply to the last Plan.tool before them.
How a step runs:
Before the package is analyzed, in an empty output directory,
<output-dir>/tools/<package>/<step>, which is its working directory.With
HOMEandTMPDIRinside that directory,SILK_TOOL=1,SILK_PACKAGE_DIRnaming the package directory, andPATH=/usr/local/bin:/usr/bin:/bin. Nothing else from your environment.It must exit 0 and write every declared output.
It runs again only when its program, arguments, inputs or outputs change. An unchanged build skips it.
A step that fails refuses the build and shows what it printed:
Compiling tables (x86_64-unknown-linux-gnu, debug)
load failed: 1 error 0.10s
silk: /home/you/tables/build.silk: build.silk computes no plan: its tool step `colors` failed with status 4:
no network
Failed tables in 0.10sA step that exits 0 without writing a declared output fails with did not write its declared output colors.silk.
The sandbox#
A tool step runs in an operating-system sandbox:
| Host | Sandbox | The step can |
|---|---|---|
| Linux 6.7 or newer | Landlock | Read and run files below /usr, /bin, /lib, /lib64, /etc, /dev and /proc, its program and its declared inputs. Write only its output directory. No TCP; where the kernel allows unprivileged user namespaces, no network interface but loopback |
| macOS | sandbox-exec with a profile | Run programs, read the system directories, its program and its inputs, read and write its output directory. No network |
| Anything else | none | Nothing: tool steps are refused |
A write outside the output directory fails inside the step, so the build reports it:
silk: /home/you/tables/build.silk: build.silk computes no plan: its tool step `colors` failed with status 2:
/home/you/tables/tools/gen.sh: 5: cannot create /home/you/tables/escape.txt: Permission deniedOn a host with no sandbox, the build refuses the step:
silk: /home/you/palette/build.silk: build.silk computes no plan: this host has no sandbox for build tools (Linux 6.7 with Landlock, or macOS); set [build].unsandboxed-tools = true in the root manifest to run them without oneWith unsandboxed-tools = true in the root manifest's [build], the step runs, and each build that runs one warns:
silk: warning: running build tool `colors` of palette without a sandbox ([build].unsandboxed-tools)Set SILK_NO_TOOL_SANDBOX to make a host behave as one without a sandbox.
Tool steps of dependencies#
The root package's steps always run. A dependency's steps run only when the root manifest names the dependency, so adding a dependency never starts a program you did not agree to:
silk: /home/you/palette/build.silk: build.silk computes no plan: its tool steps run only when the root manifest lists "palette" in [build].tools[dependencies]
palette = { path = "../palette" }
[build]
tools = ["palette"]See also#
The manifest, Build outputs and native linking, Dependencies; Static evaluation and Compilation profiles in the reference; silk.build.plan and silk.build.source in the Std lib API reference.