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.
PACKAGES = {
{ spec = "envy.python@r1", bundle = "envy",
options = { version = "3.13.14", release = "20260623", provide_python3 = true } },
}
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..nsequence. 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" },
}
| Constraint | Meaning |
|---|---|
required | Absent is an error. Anything not required is optional. There are no defaults, so handle nil in your verbs. |
type | One 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. |
range | For 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. |
choices | Allowed values. With type = "list", every element must be one of them. |
validate | A 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:
| Return | Result |
|---|---|
nil or true | Accepted. |
false | Rejected, with a generic message. |
| a string | Rejected, with that string as the message. Prefer this. It is the difference between "OPTIONS failed" and a list of the versions that exist. |
| anything else | An 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
- Anatomy of a Spec for where
OPTIONSsits among the globals. - Products for products computed from options.
- Package Entries for the manifest side.