-
Notifications
You must be signed in to change notification settings - Fork 4
llar Formula
An LLAR Formula describes how a versioned upstream project becomes an installable LLAR module. It connects four pieces of information:
- which upstream repository and versions the Formula covers;
- which direct dependencies each requested version uses;
- how the source is built and installed;
- how another project can consume and verify the installed result.
A Formula is stored in a _llar.gox file. The file uses XGo syntax, but most of
its content is ordinary Go plus a small LLAR-specific surface such as id,
fromVer, onRequire, onBuild, and onTest.
Here is the overall shape:
id "example/libalpha"
fromVer "v1.2.0"
onRequire (proj, deps) => {
// Read dependency information from the requested upstream version.
}
onBuild ctx => {
// Build the source and install it into ctx.outputDir.
}
onTest ctx => {
// Build and run a consumer test against ctx.outputDir.
}onBuild is the only required hook. Dependency discovery, matrix selection,
and installed-output tests are added when the project needs them.
XGo is a superset of Go, so normal Go declarations, expressions, control flow, imports, and calls remain valid. Formulae commonly use a few shorter XGo forms:
data := os.readFile(path)! // os.ReadFile(path), panic on error
installDir := ctx.outputDir // zero-argument OutputDir call
ctx.setMetadata flags // command-style method call
onBuild ctx => { ... } // lambda passed to onBuildThe lowercase spelling changes only the first letter of an exported Go name,
so os.readFile resolves to os.ReadFile and ctx.setMetadata resolves to
ctx.SetMetadata. The original Go spelling is also valid.
The Formula file itself is a classfile. LLAR supplies the generated Go type,
entrypoint, and base class, so the file has no package main, main function,
or explicit Formula struct. Imports, types, constants, fields, and helper
functions appear before the first Formula statement, normally before id.
The rest of this page follows the order in which a Formula usually takes shape.
An LLAR module id is the upstream GitHub repository path without the host name.
The fictional repository github.com/example/libalpha therefore has the
module id example/libalpha:
id "example/libalpha"Formula work begins from an exact upstream tag. Its build files show which build system, dependencies, install rules, package metadata, and consumer tests actually exist for that version. Those source files are the basis for the Formula; settings from another project are not assumed to apply.
A module directory contains versions.json and one or more Formula versions:
example/libalpha/
versions.json
v1.2.0/
Libalpha_llar.gox
v2.0.0/
Libalpha_llar.gox
The filename before the first underscore becomes the generated class name. It
must be a valid Go identifier, which is why the example uses
Libalpha_llar.gox.
fromVer marks the first upstream version handled by that Formula:
fromVer "v1.2.0"If the next Formula starts at v2.0.0, the first Formula covers every selected
version from v1.2.0 up to, but not including, v2.0.0. fromVer is therefore
a range boundary rather than a declaration for one exact version. Its value is
a string literal because LLAR reads it while selecting the Formula.
This range is important when dependencies are described: a build recipe can remain unchanged across several releases even though upstream changes a dependency version.
onRequire provides the direct dependencies for the exact upstream version
being requested. The proj value reads files from that version, while deps
collects the dependencies found there:
import "strings"
onRequire (proj, deps) => {
data, err := proj.readFile("dependency-version.txt")
if err != nil {
return
}
version := strings.trimSpace(string(data))
if version != "" {
deps.require "example/libbeta", version
}
}Suppose v1.2.0 records v3.1.0 in that file and v1.8.0 records v3.4.0.
Both upstream versions can use the same Formula, while onRequire still
returns the dependency chosen by each upstream release. A new Formula is only
needed when the build recipe or another Formula boundary actually changes.
Only direct dependencies belong here. LLAR resolves their transitive dependencies separately. When upstream uses a build-system name instead of an LLAR module id, the Formula contains the explicit mapping between those names; LLAR does not infer it.
Dynamic discovery is not always possible. A dependency file may be absent from
an older source release, or it may name a dependency without giving a version.
versions.json provides a conservative fallback for those cases:
{
"path": "example/libalpha",
"deps": {
"v1.2.0": [
{"path": "example/libbeta", "version": "v3.0.0"}
],
"v1.8.0": [
{"path": "example/libbeta", "version": "v3.0.0"}
]
}
}The fallback does not need to follow the newest upstream dependency. In this
example, v3.0.0 is useful because it is known to work throughout the Formula
range. onRequire still returns a newer version whenever the requested source
records one.
Fallback entries are keyed by exact upstream version:
- when
onRequirereturns usable versioned dependencies, that result is used; - when it returns a dependency with an empty version, the matching entry for that source version can fill the version;
- when it returns no usable dependencies, the complete entry for that source version becomes the fallback.
versions.json is present even when all current versions can be discovered
dynamically. Its deps object can be empty when no fallback is needed.
Many projects build only one way and need no matrix code. When a build changes
with the environment or a package choice, the selected values are available
through target.
target.require contains environment requirements that participate in
dependency resolution, such as os, arch, ABI, libc, or toolchain.
target.options contains choices owned by this package, such as static versus
shared output or an optional feature.
For example, a package with a shared option can provide a default and read
the selected value during the build:
import "slices"
defaults {
"shared": "OFF",
}
onBuild ctx => {
shared := slices.contains(target.options["shared"], "ON")
// The selected value is passed to the verified upstream build option.
}defaults initializes package options; it does not define their legal values
and does not set environment requirements. If only ON and OFF are valid,
filter can reject other selections before dependency discovery or building:
filter => {
for _, value := range target.options["shared"] {
if value != "ON" && value != "OFF" {
return false
}
}
return true
}The same selected target is visible to filter, onRequire, and onBuild.
That allows an option to affect both the dependency set and the build command
without maintaining two separate configurations.
onBuild turns the selected upstream source into an installed result. Its
context provides the two directories that define this boundary:
-
ctx.SourceDiris the temporary checkout of the requested upstream tag; -
ctx.outputDiris the install directory assigned to the current module.
Build files can live under ctx.SourceDir, but the headers, libraries, tools,
and package metadata that make up the final result are installed under
ctx.outputDir.
The CMake helper models configure, build, and install as one workflow:
onBuild ctx => {
installDir := ctx.outputDir
buildDir := ctx.SourceDir + "/_build"
c := cmake.new(ctx.SourceDir, buildDir, installDir)
for _, dep := range ctx.Proj.Deps {
c.use ctx.outputDir(dep)
}
c.configure
c.build
c.install
}c.use adds an installed dependency to the build search paths. The dependency
directory always comes from ctx.outputDir(dep), so the Formula is independent
of LLAR's cache layout.
The current CMake methods return no value. They panic when configuration, building, or installation fails, and LLAR turns that panic into a Formula error. Build-system settings such as generators, policy versions, build types, toolchains, tests, or shared-library switches appear only when the selected upstream source requires them.
Common CMake calls are:
| Call | Meaning |
|---|---|
cmake.new(source, build, install) |
create a CMake workflow |
c.use(root) |
add an installed dependency to search paths |
c.buildType(name) |
select a CMake build type |
c.toolchain(path) |
set CMAKE_TOOLCHAIN_FILE
|
c.define(key, value) |
add a string cache definition |
c.defineBool(key, value) |
add a boolean cache definition |
c.configure(args...) |
configure with optional extra arguments |
c.build(args...) |
build with optional extra arguments |
c.install(args...) |
install with optional extra arguments |
The equivalent Autotools flow uses an out-of-source build directory and the same assigned install directory:
onBuild ctx => {
installDir := ctx.outputDir
buildDir := ctx.SourceDir + "/_build"
a := autotools.new(ctx.SourceDir, buildDir, installDir)
for _, dep := range ctx.Proj.Deps {
a.use ctx.outputDir(dep)
}
a.configure
a.build
a.install
}a.configure supplies --prefix=<installDir>, followed by any extra arguments.
Like the CMake helper, configure, build, and install panic on failure.
Steps such as autoreconf are separate commands when the selected source
actually requires them.
Formulae inherit gsh command execution. An unresolved command-style name is treated as an executable:
codegen! "--output", generatedDirThis is equivalent in shape to exec.Command("codegen", "--output", generatedDir). Arguments remain separate strings; there is no shell to expand
pipes, redirects, wildcards, command substitutions, or &&.
exec is useful when the executable name is stored in a value, contains
punctuation, or collides with an XGo name:
exec "build-tool", "--generate", generatedDir
lastErr!Every command updates lastErr. The ! form turns a non-nil error into a
panic, which reaches the Formula boundary. When a command is checked manually,
lastErr is read immediately because the next command replaces it.
After installation, LLAR also needs to know how another project consumes the
result. ctx.setMetadata stores that information. For a C or C++ library this
usually means verified compiler and linker flags, preferably obtained from
metadata installed by the project itself.
The following fragment asks the installed package-config file for its public flags:
import "strings"
c.use installDir
capout => {
exec "pkg-config", "--cflags", "--libs", "libalpha"
}
lastErr!
ctx.setMetadata strings.trimSpace(output)This code belongs inside onBuild, after c.install. capout captures stdout
as output; command failure is still reported through lastErr.
Metadata describes the current package's public consumer interface. It is not
a dependency list or a build log. Dependency metadata is added only when the
current package's public interface actually exposes that dependency. In that
case, ctx.buildResult(dep) returns the dependency's build result and
result.metadata() reads its consumer metadata.
onTest answers a different question from onBuild: can a consumer use what
was installed? A useful test compiles or links a small consumer against
ctx.outputDir and then runs or loads the result.
onTest ctx => {
installDir := ctx.outputDir
testSource := ctx.SourceDir + "/consumer"
testBuild := ctx.SourceDir + "/_consumer_build"
tc := cmake.new(testSource, testBuild, "")
tc.use installDir
for _, dep := range ctx.Proj.Deps {
tc.use ctx.outputDir(dep)
}
tc.configure
tc.build
exec testBuild + "/alpha-check"
lastErr!
}The verification build has its own directory. This matters because LLAR can
run onTest against either a fresh build or an existing cached installation;
the scratch directory previously used by onBuild may not exist. onTest
does not install the package again or replace its metadata.
Most tests fail at the first unsuccessful operation. When several independent
checks are worth reporting together, their non-nil errors can instead be added
to ctx.Errs before the hook returns.
llar test exercises dependency discovery, matrix selection, installation,
metadata, and onTest together:
llar test -v ./example/libalpha@v1.2.0The fromVer boundary is the first useful test point. Other representative
versions in the same range show whether onRequire follows upstream dependency
changes without selecting a different Formula. Running the same version again
also checks that onTest works with a cached installation.
When explicit matrix values are involved, the test names every required environment dimension and package option:
llar test -v ./example/libalpha@v1.2.0 \
--os "$(go env GOOS)" --arch "$(go env GOARCH)" \
--option shared=ONThe following fictional Formula combines the stages above. The filenames, dependency file, CMake option, package-config name, and consumer target are all placeholders for facts that would come from the selected upstream source.
import (
"slices"
"strings"
)
id "example/libalpha"
fromVer "v1.2.0"
defaults {
"shared": "OFF",
}
filter => {
for _, value := range target.options["shared"] {
if value != "ON" && value != "OFF" {
return false
}
}
return true
}
onRequire (proj, deps) => {
data, err := proj.readFile("dependency-version.txt")
if err != nil {
return
}
version := strings.trimSpace(string(data))
if version != "" {
deps.require "example/libbeta", version
}
}
onBuild ctx => {
installDir := ctx.outputDir
buildDir := ctx.SourceDir + "/_build"
c := cmake.new(ctx.SourceDir, buildDir, installDir)
for _, dep := range ctx.Proj.Deps {
c.use ctx.outputDir(dep)
}
shared := slices.contains(target.options["shared"], "ON")
c.defineBool "LIBALPHA_SHARED", shared
c.configure
c.build
c.install
c.use installDir
capout => {
exec "pkg-config", "--cflags", "--libs", "libalpha"
}
lastErr!
ctx.setMetadata strings.trimSpace(output)
}
onTest ctx => {
installDir := ctx.outputDir
testSource := ctx.SourceDir + "/consumer"
testBuild := ctx.SourceDir + "/_consumer_build"
tc := cmake.new(testSource, testBuild, "")
tc.use installDir
for _, dep := range ctx.Proj.Deps {
tc.use ctx.outputDir(dep)
}
tc.configure
tc.build
exec testBuild + "/alpha-check"
lastErr!
}| Surface | Role |
|---|---|
id "owner/repo" |
module id served by the Formula |
fromVer "version" |
first upstream version in the Formula's range |
defaults {...} |
default values for package options |
filter => { ... } |
acceptance check for the selected matrix |
onRequire (proj, deps) => { ... } |
direct dependencies for the requested upstream version |
onBuild ctx => { ... } |
build and install into the assigned output directory |
onTest ctx => { ... } |
build and run installed-output verification |
The hook values provide these commonly used members:
| Name | Meaning |
|---|---|
proj.readFile(path) |
read a file from the requested upstream version during onRequire
|
deps.require(path, version) |
add one direct dependency |
target.require |
selected environment requirements as map[string][]string
|
target.options |
selected package options as map[string][]string
|
ctx.SourceDir |
temporary checkout of the requested upstream source |
ctx.Proj.Deps |
resolved dependencies available to the build or test |
ctx.outputDir |
current module's assigned install directory |
ctx.outputDir(dep) |
installed output directory of a dependency |
ctx.buildResult(dep) |
build result and availability of a dependency |
ctx.setMetadata(value) |
set the current module's consumer metadata |
ctx.Errs.add(err) |
accumulate a non-nil hook error |
Most modules need no comparator; LLAR's default comparison handles numeric
segments in ordinary version strings. A module whose real tags require another
ordering can add one _cmp.gox file beside versions.json:
compareVer (a, b) => {
return semver.Compare(a.Version, b.Version)
}Semantic-version comparison applies only when every relevant upstream tag and Formula boundary is valid semantic-version syntax.