Skip to main content

Options

Options let one spec serve many needs. The manifest passes options = { ... }, the spec validates them, every verb receives them, and they become part of the package's identity.

envy.lua
PACKAGES = {
{ spec = "envy.python@r1", bundle = "envy",
options = { version = "3.13.14", release = "20260623", provide_python3 = true } },
}
python.lua
OPTIONS = {
version = { required = true },
release = { required = true },
provide_python = { type = "boolean" },
provide_python3 = { type = "boolean" },
}

FETCH = function(tmp_dir, opts)
return { source = url_for(opts.version, opts.release), sha256 = hash_for(opts) }
end

Validation happens during the spec fetch phase, before any download. A misspelled option fails in a second rather than after 400 MB.

Options are identity​

envy names the cache entry with a hash of the identity plus the serialized options, so two entries differing in one option are two packages:

PACKAGES = {
{ spec = "envy.python@r1", bundle = "envy",
options = { version = "3.13.14", release = "20260623", provide_python3 = true } },
{ spec = "envy.python@r1", bundle = "envy",
options = { version = "3.14.2", release = "20260623" } },
}

Both install, both coexist, and neither invalidates the other. The canonical key being hashed is the identity with its options appended, sorted by name:

envy.python@r1{["provide_python3"]=true,["release"]="20260623",["version"]="3.13.14"}

That string is what envy product prints in its provider column, and it works as a fully specific query.

Two consequences. Changing an option never mutates a package, it names a new one. And options have to hash, so an option envy cannot spell canonically is rejected:

  • A function anywhere in the table, at any depth.
  • A table, at any depth, that is neither all-string-keyed nor a contiguous 1..n sequence. A sparse array or a mixed table has no one spelling, and dropping the odd key would collide two option sets on one cache entry.

envy 0.3.1 changed the spelling of the key itself, from {version="4.4.0"} to {["version"]="4.4.0"}, so that a key needing quotes survives the round trip. Packages carrying options therefore rebuild once on the upgrade, and a saved full-canonical-key query needs the new spelling.

The platform is not an option. It is a separate component of the cache entry path, so one option set on macOS and the same option set on Windows are two entries that never collide. A spec should therefore branch on envy.PLATFORM rather than take a platform option, and an option that only makes sense somewhere can be validated per platform inside the function form.

Shape 1: a schema table​

OPTIONS = {
version = { required = true, type = "semver", range = ">=3.9.0 <4.0.0" },
tools = { required = true, type = "list" },
optimize = { type = "string", choices = { "size", "speed", "debug" } },
jobs = { type = "int", range = ">=1 <=64" },
strict = { type = "boolean" },
}
ConstraintMeaning
requiredAbsent is an error. Anything not required is optional. There are no defaults, so handle nil in your verbs.
typeOne of string, boolean, int, float, table, list, semver. list means a sequential array, so gaps are rejected. semver parses the value as a semantic version.
rangeFor semver, a semver range such as ">=1.2 <2". Otherwise a numeric range: space-separated >=, >, <=, <, == terms, for example ">=1 <=64". Strings that parse as numbers are accepted.
choicesAllowed values. With type = "list", every element must be one of them.
validateA function receiving the value. Return nil or true to accept, false to reject, or a string to reject with that message.

Undeclared options are rejected, and the error lists what the spec accepts, so a misspelled verison is caught immediately.

Use validate when the rule is about the value's shape:

OPTIONS = {
version = {
required = true,
validate = function(v)
if not v:find("%.") then
return "version must contain a dot, for example '9.12' rather than '912'"
end
end,
},
packages = {
required = true,
type = "list",
validate = function(v)
if #v == 0 then return "'packages' must be a non-empty array" end
for i, name in ipairs(v) do
if type(name) ~= "string" or name == "" then
return string.format("package at index %d must be a non-empty string", i)
end
end
end,
},
}

Shape 2: a function​

Use a function when the valid set is computed, most often from the spec's own fingerprint table. The versions it accepts are then the versions it has hashes for. Call envy.options(schema) for the normal per-field validation, then add whatever cross-field checks the spec needs:

local hashes -- "<version>+<release>" -> triple -> sha256, filled in below

local function pin(opts) return opts.version .. "+" .. opts.release end

OPTIONS = function(opts)
envy.options({
version = { required = true },
release = { required = true },
provide_python = { type = "boolean" },
provide_python3 = { type = "boolean" },
})

if not hashes[pin(opts)] then
return "unrecorded version+release '" .. pin(opts) ..
"'; recorded: " .. table.concat(recorded_pins(), ", ")
end
end

Return values:

ReturnResult
nil or trueAccepted.
falseRejected, with a generic message.
a stringRejected, with that string as the message. Prefer this. It is the difference between "OPTIONS failed" and a list of the versions that exist.
anything elseAn error about OPTIONS itself.

Shape 3: omitted​

With no OPTIONS global there is no validation. Whatever the manifest passes reaches the verbs untouched, and unknown keys are not caught. That is fine for a spec with no options, or a private one-off. A published spec should declare a schema, because it is the only description of what the spec accepts that cannot drift.

Options in verbs​

Every verb receives the validated table as its last argument:

FETCH = function(tmp_dir, opts)
STAGE = function(fetch_dir, stage_dir, tmp_dir, opts)
BUILD = function(install_dir, stage_dir, fetch_dir, tmp_dir, opts)
INSTALL = function(install_dir, stage_dir, fetch_dir, tmp_dir, opts)
SETUP.<pair>.CHECK = function(pkg_dir, opts)

One file, one set of verbs, and every project's variation expressed as data in the manifest.

PRODUCTS = function(opts) is the exception. It runs before OPTIONS, so it receives the manifest entry's options exactly as written, with no validation against the schema. The table is always present — empty if the entry declared no options — but any individual key may be nil:

-- Wrong: paths[nil] is nil, and indexing that throws "attempt to index a nil
-- value" with no traceback. Marking `target` required does not help, because
-- OPTIONS has not run yet.
PRODUCTS = function(opts) return { tool = paths[opts.target] .. "/tool" } end

-- Right: guard the read.
PRODUCTS = function(opts)
local dir = opts.target and paths[opts.target]
return dir and { tool = dir .. "/tool" } or {}
end

OPTIONS still produces its own error for a bad target, but only if PRODUCTS returns first.

Telling the instances apart​

One spec instantiated several times produces several rows with the same [identity], differing only in their options, which are not shown. Since envy 0.4.2 a spec can label each one with DISPLAY, a string or a function of the validated options:

DISPLAY = function(options) return options.repo end
[acme.github@r0] libusb/hidapi 42% [========> ] 4.10MB/9.77MB
[acme.github@r0] cesanta/mongoose 17% [===> ] 1.55MB/9.12MB

It is read once, right after OPTIONS validates, and appears on the outcome line as well as the live row, including in a redirected log where four identical identities would otherwise be four identical lines.

One other thing to get right before instantiating a spec several times: product names are a project-wide registry, so every instance needs its own. A PRODUCTS table with a fixed name makes the second instance an error. Compute the names from the options instead.

See also​