Use the source, Luke!
Luke is a lightweight, self-bootstrapping build utility for LuaJIT
and Lua 5.1 through 5.5. It builds Lua and C modules, scripts and
programs, probing the host system for the headers, functions and
libraries they need, from a lukefile written in the same style as a
LuaRocks rockspec:
Luke grew out of replacing Autotools in lyaml and luaposix, without needing Perl and M4 on the developer’s machine, or carrying a 500k shell script in the distribution.
Copy build-aux/luke from the Luke repository into your project.
It is a single, minified Lua script with everything it needs, so there
is nothing else to install, and nothing for your users to install
either.
The luke rock installs the same file as the luke command, so that
you can run it, or copy it into your project, without a clone of the
repository:
luarocks install luke
cp "$(luarocks show --rock-dir luke)/bin/luke" build-aux/luke
Luke builds that file from its own sources, with its own lukefile.
If you change those sources, run ./luke in the Luke source tree to
regenerate build-aux/luke.
build-aux/luke [OPTION]... [VARIABLE=VALUE]... [TARGET]...Luke reads lukefile from the current directory, and builds every
module, script, program and generated file it describes. Name one or
more targets to build only those:
build-aux/luke posix.time posix.unistdA target names every module, script, program and generated file with
that name. Three targets are special: all builds everything, and is
the default; install copies everything already built to its
installation directory, except generated files; and release cuts a
release. With other targets, install installs only
those:
build-aux/luke
build-aux/luke PREFIX=/opt/lua install
build-aux/luke PREFIX=/opt/lua posix.time install
build-aux/luke release version=1.2Each VARIABLE=VALUE argument sets a variable,
overriding the same variable in lukefile, the environment, or Luke’s
defaults.
The options are:
--help print help, then exit
--version print version number, then exit
--dry-run print the commands, without running them
--file=FILE use FILE instead of lukefile
--value=NAME print the value of variable NAME, then exit
--quiet without any output
--verbose provide more progress output, such as probe results
Even with --quiet, Luke shows any command that fails, along with its
error output.
With --dry-run, Luke still runs its probes, so that it prints the
commands that it would run for any target, but it runs none of them,
and fills no templates.
A lukefile is a Lua file that sets a few global variables, much like a
rockspec. A minimal one for a pure Lua project is:
luke_format = 0
package = 'stack'
version = '$USER/$gitref'
modules = {
{['stack'] = 'lib/stack/init.lua'},
{['stack.utils'] = 'lib/stack/utils.lua'},
}package and version name the project, and are available to
templates as @package@ and @version@ (see
Templates). luke_format, package, version and a
modules table are required.
luke_format, like a rockspec’s rockspec_format, names the syntax
the lukefile is written in. It is a whole number, which goes up by
one with each incompatible change to the syntax, so each Luke reads
only its own format, which is 0 for now. For any other format, Luke
stops with a diagnostic before reading the rest of the file, saying
whether it needs a newer or an older Luke, rather than diagnosing
syntax it doesn’t know. A lukefile without one is for Luke 0.4 or
earlier, whose syntax format 0 replaces.
Like the rockspec builtin backend, modules maps each module name to
its source, but as a list of pair tables that map each name to its
source. Luke builds and installs modules in the order written.
Luke diagnoses a module name used as a top-level key of the modules
table, or a module name written twice, as a syntax error.
A Lua module is installed beneath $INST_LUADIR, and a C module is
compiled to $objdir/<name>.$LIB_EXTENSION and then installed
beneath $INST_LIBDIR.
A module made from several C sources, or that needs extra compiler or linker settings, takes a full table in place of a single source string:
modules = {
{['posix.sys.socket'] = {
defines = {{HAVE_NET_IF_H = {checkheader='net/if.h', include='sys/socket.h'}}},
incdirs = {'ext/include'},
libdirs = {'/opt/local/lib'},
libraries = {{checksymbol='socket', library='socket'}},
sources = {'ext/posix/sys/socket.c'},
}},
}Like the rockspec defines array, defines is a list of macro
definitions, which Luke passes to the compiler in the order written.
Each is a rockspec style string, 'HAVE_FOO' or 'HAVE_FOO=value', or
a table that pairs one macro name with its value, {HAVE_FOO = value}.
A value of 1 adds -DHAVE_FOO, and any other value adds
-DHAVE_FOO=<value>, quoted for the shell. The value in a table can
also be a build-time probe, as can an entry in
libraries. Luke diagnoses a macro defined more than once in the same
list.
Like the rockspec build.install.bin table, an install.bin table maps
the name of each script to install to its source, but as a list, like
modules, of tables that each pair one name with its source:
install = {
bin = {
{specl = 'bin/specl'},
},
}Luke fills the template of a source whose name ends in
.in, makes every listed script executable, and luke install copies
each to its name beneath $INST_BINDIR, all in the order written. A
source on its own is named for itself, without its directory or any
.in suffix.
A top-level bin table works the same way, for scripts that are only
for use in the source tree, and are never installed, such as a wrapper
that runs a program with the uninstalled modules:
bin = {
'specl.in',
}An entry in install.bin or bin that pairs a name with a table is a
rule for building a program from several sources, with the same
defines, incdirs, libdirs, libraries and probes as a module.
Without a command, Luke compiles and links its C sources:
install = {
bin = {
{mytool = {
sources = {'src/main.c', 'src/util.c'},
libraries = {'m'},
}},
},
}Otherwise, Luke runs command after filling the template of any .in
source, with $sources set to the filled source paths, $target to the
program to write, and each env variable exported. This is how Luke
builds its own build-aux/luke:
bin = {
{['build-aux/luke'] = {
command = '$LUA build-aux/smush $sources > $target',
env = {LUA_PATH = '$abs_top_srcdir/lib/?.lua;;'},
sources = {'lib/luke/init.lua', 'lib/luke/version.lua.in'},
}},
}An installable program is built as $objdir/<name>, and an in-tree
program at the path given as its name. Either way, Luke makes it
executable, and luke <name> rebuilds it. A table with sources is
the rules for a program without a name, so write a script or program
named sources as {['./sources'] = ...}.
A top-level files list works like bin, for files that are made
during the build, but are neither executable nor installed, such as a
man page made from a script’s --help output. Each entry pairs the
path to build with a template, or with a rule with a command and its
sources. A template on its own is built at its own path, without the
.in suffix. Luke builds every script in bin and install.bin
first, so that a rule can run them, and then each file in the order
written:
variables = {
HELP2MAN = {checkprog='HELP2MAN', progs={'help2man', 'true'}},
}
files = {
'doc/index.md.in',
{['doc/specl.1'] = {
command = '$HELP2MAN --output=$target --no-info ./specl',
sources = 'specl.in',
}},
}luke release commits every generated file to the release branch.
Luke runs every rule when it builds, so to build a release without the
tools that made them, find each tool with a checkprog
probe that falls back to true, as above: a
build without help2man then leaves the committed man page alone.
A top-level defines list adds its definitions to every C compilation,
before those of each module, which can redefine them, and top-level
incdirs, libdirs and libraries arrays add their directories and
libraries, in addition to the settings of each module:
defines = {
{PACKAGE = '"$package"'},
{VERSION = '"$version"'},
'NDEBUG',
}
incdirs = {
'ext/include',
'$LUA_INCDIR',
}An external_dependencies list names libraries that every module
needs, like modules, as tables that each pair a name with its
settings. For each one, in the order written, Luke adds
$<NAME>_INCDIR and $<NAME>_LIBDIR to the compiler and linker search
paths, defaulting to the include and lib directories beneath
$<NAME>_DIR (/usr unless you set it), and links with the library it
finds:
external_dependencies = {
{YAML = {
library = {checksymbol='yaml_document_initialize', library='yaml'},
}},
} build-aux/luke YAML_DIR=/opt/homebrewAn ldocs entry names an LDoc configuration file, which may be a
template, and Luke runs it after building every module:
ldocs = 'build-aux/config.ld.in' $LDOC -c build-aux/config.ld .If LDOC is not set, Luke looks for ldoc on your PATH, and quietly
skips the documentation if it is not installed.
Wherever a string in lukefile contains $NAME, Luke replaces it with
the value of variable NAME, looking first on the command line, then
in the lukefile variables table, then in Luke’s defaults, and finally
in the environment:
variables = {
INST_LUADIR = '$PREFIX/share/lua',
}A variable can also be a build-time probe,
such as a checkprog that finds a program, which commands then see
as the probe’s result.
Use --value to see what Luke makes of one:
$ build-aux/luke --value=INST_LUADIR
INST_LUADIR='/usr/local/share/lua/5.4'The defaults, most of which LuaRocks passes on the command line when it runs Luke (see Rockspecs), are:
| Variable | Default |
|---|---|
PREFIX |
/usr/local |
INST_BINDIR |
$PREFIX/bin |
INST_LIBDIR |
$PREFIX/lib/lua/$LUAVERSION |
INST_LUADIR |
$PREFIX/share/lua/$LUAVERSION |
LUA |
lua |
LUAVERSION |
the version of the Lua running Luke |
LUA_DIR |
/usr |
LUA_BINDIR |
$LUA_DIR/bin |
LUA_INCDIR |
$LUA_DIR/include/lua$LUAVERSION |
LUA_LIBDIR |
$LUA_DIR/lib |
CC |
the first of cc, gcc or clang found |
CFLAGS |
-O2 |
LIBFLAG |
-shared -fPIC, or a bundle on macOS |
LIB_EXTENSION |
so |
OBJ_EXTENSION |
o |
INSTALL |
cp |
MAKEDIRS |
mkdir -p |
CHMOD |
chmod |
objdir |
the platform name, such as linux |
The compile command also uses CPPFLAGS, LDFLAGS, LIBS and
PKGFLAGS, if you set them.
Luke also sets:
package and version, from lukefile;gitref, the abbreviated hash of the commit checked out in the
current directory, or the empty string outside a git working tree;abs_top_srcdir, the absolute path of the current directory.Setting version = '$USER/$gitref' makes every unreleased build
identify who built it, and from which commit, while a release can still
pass version=X.Y on the command line.
Wherever lukefile names a source file ending in .in, Luke copies it
to the same path without the .in, replacing each @NAME@ with the
value of NAME from the command line, the lukefile substitute table,
package, version and the built-in values above, or the environment.
This keeps a version string in sync, for example:
-- lib/stack/version.lua.in
return 'stack @version@' modules = {
{['stack.version'] = 'lib/stack/version.lua.in'},
}Any table in lukefile can contain a platforms table with settings
for particular platforms. Luke uses the settings for the most specific
platform it is running on, or else the remaining settings in the
platforms table as defaults:
defines = {
'NDEBUG',
platforms = {
linux = {'_DEFAULT_SOURCE'},
macosx = {'_DARWIN_C_SOURCE'},
'_POSIX_C_SOURCE=200809L',
},
}The list entries of each platform table that applies follow those of
the list itself, least specific first, so on macOS the list above is
NDEBUG, then _DARWIN_C_SOURCE. A setting for a more specific
platform replaces the same setting for a less specific one, or for no
platform, and a macro that a more specific platform defines again is
redefined where it was first defined. Luke diagnoses errors in the
defines table for all platforms, not just the one being built.
Luke recognizes these platforms, from the output of uname -s, most
specific first:
uname -s |
Platforms |
|---|---|
AIX |
aix, unix |
Darwin |
macosx, bsd, unix |
FreeBSD |
freebsd, bsd, unix |
NetBSD |
netbsd, bsd, unix |
OpenBSD |
openbsd, bsd, unix |
Linux |
linux, unix |
SunOS |
solaris, unix |
Haiku |
haiku, unix |
CYGWIN* |
cygwin, unix |
MSYS* |
msys, cygwin, unix |
MINGW* |
mingw32, win32, windows |
Windows* |
win32, windows |
QNX, procnto* |
qnx |
Anything else is treated as unix.
Wherever a value in defines, libraries or variables can go, you
can put a probe, a table that Luke runs to compute the value on the build
machine. Whatever order you write them in, Luke always runs the kinds
of probe in this order, and probes of the same kind in the order they
are written:
checkprog: {checkprog='SH', progs={'dash', 'ash', 'sh'}}
finds the first of progs on your PATH, and returns its path.
checkheader: {checkheader='net/if.h', include='sys/socket.h'}
returns 1 if a short C program that includes the header compiles,
otherwise 0. An optional include names another header that must
be included first, or includes an array of them.
checkdecl: {checkdecl='fdatasync', include='unistd.h'} returns
1 if the headers named by include (or includes) declare the
function, or define a macro of that name, otherwise 0.
checksymbol: {checksymbol='crypt', library='crypt'} returns
the library if linking with it is necessary to resolve the symbol,
or nothing if the symbol resolves without it. A libraries array
tries several libraries in turn. An optional include (or
includes) names headers to include, and an ifdef key marks the
symbol as unsupported when the named macro is not defined.
checkfunc: {checkfunc='crypt'} returns 1 if the function is
available, otherwise 0.
checkmember: {checkmember='struct tm.tm_gmtoff', include='time.h'}
returns 1 if the structure has the member, otherwise 0. An
optional include (or includes) names the headers that define the
structure.
Because of that ordering, this module:
{['crypt'] = {
defines = {
{HAVE_CRYPT = {checkfunc='crypt'}},
{HAVE_CRYPT_H = {checkheader='crypt.h'}},
},
libraries = {
{checksymbol='crypt', library='crypt'},
},
sources = 'crypt.c',
}},…might build like this with --verbose:
checking for C compiler... cc
checking whether cc works... yes
checking for crypt.h... no
checking for library containing crypt... none required
checking for crypt... yes
cc -O2 -shared -fPIC -DHAVE_CRYPT_H=0 -DHAVE_CRYPT crypt.c -o linux/crypt.so
…given crypt.c was written with:
#if HAVE_CRYPT_H
# include <crypt.h>
#endif
#if defined HAVE_CRYPT
static int Pcrypt(lua_State *L)
{
...
return pushresult(crypt(str, salt));
}
#endifTo build with Luke from a rockspec, use the command backend, and pass
along the settings LuaRocks has chosen. luaposix does it like this:
build = {
type = 'command',
build_command = '$(LUA) build-aux/luke'
.. ' package="' .. package .. '"'
.. ' version="' .. _MODREV .. '"'
.. ' PREFIX="$(PREFIX)"'
.. ' LUA="$(LUA)"'
.. ' LUA_INCDIR="$(LUA_INCDIR)"'
.. ' CC="$(CC)"'
.. ' LD="$(LD)"'
.. ' CFLAGS="$(CFLAGS)"'
.. ' LIBFLAG="$(LIBFLAG)"'
.. ' LIB_EXTENSION="$(LIB_EXTENSION)"'
.. ' OBJ_EXTENSION="$(OBJ_EXTENSION)"'
.. ' INST_LIBDIR="$(LIBDIR)"'
.. ' INST_LUADIR="$(LUADIR)"'
,
install_command = '$(LUA) build-aux/luke install --quiet'
.. ' INST_LIBDIR="$(LIBDIR)"'
.. ' INST_LUADIR="$(LUADIR)"'
,
}A release commits its LDocs (see Releases), so its
rockspec need not build them again, which would fail with an ldoc
that doesn’t work with the Lua it is installed for. Setting LDOC to
true on the command line skips them, and build_dependencies, from
rockspec format 3.0, gives development builds an ldoc to run:
if _MODREV == 'dev' then
build_dependencies = {'ldoc'}
else
build.build_command = build.build_command .. ' LDOC=true'
endFor now, that repeats some of the lukefile in the rockspec. One day,
Luke may read the extra syntax it needs directly from your rockspec,
so that a separate lukefile is no longer necessary.
luke release version=X.Y cuts release X.Y (or X.Y.Z) in your git
working tree, as set out in the lukefile release table. Luke’s own
is:
release = {
news = 'NEWS.md',
rockspec = 'luke-dev-1.rockspec',
replacements = {
{['README.md'] = {
{['?branch=master'] = '?branch=release-v@version@'},
}},
},
['gh-pages'] = {
{['index.md'] = 'doc/luke.md'},
'_config.yml.in',
},
}Every key is optional, and Luke skips the steps for any that are
missing. release cannot be combined with other targets, and Luke
pushes nothing: it prints the commands to publish the release when it
is done. If a step fails, Luke prints the commands that undo what the
release has done so far instead, so that its checks pass again. With
--dry-run, Luke still makes its checks, but then only prints the
commands it would run and the files it would write.
Luke checks, before changing anything, that:
master, main or a maintenance branch, and is up to date with
origin, which Luke fetches;release-vX.Y branch or vX.Y tag yet;## header in news is a placeholder for the next
release, with ?.? in place of its version, and has news under it;news has no release X.Y yet;rockspec is a development rockspec, named like luke-dev-1.rockspec,
whose first line sets _MODREV: local _MODREV, _SPECREV = 'dev', '-1'replacements exists, and has each text it replaces,
after the replacements written before it (see
Replacements);gh-pages, is
up to date with origin, and is not checked out in another worktree,
as Luke updates it in a worktree of its own; and each template
copied there from the same path is in the release or on that branch
(see Publishing to Branches).--verbose shows each check as Luke makes it.
Then Luke:
adds a header for X.Y, with today’s date, between the placeholder
in news and the news under it, and commits that to the branch
checked out:
## Noteworthy changes in release ?.? (????-??-??) [?]
## Noteworthy changes in release 1.2 (2026-10-13) [stable]
release-vX.Y branch, removes the placeholder
from news there, and makes the replacements;version=X.Y;luke-X.Y-1.rockspec, with
_MODREV set to X.Y, in place of rockspec;add list, every module filled from a
template, every generated
file and the release rockspec, even if git
ignores them, so that the release can be used, or its files copied
by hand, without building anything; removes each line of a
.gitignore that ignores them, so that the release branch ignores
nothing it tracks; and tags the commit vX.Y, with an announcement
of the release as the tag message: the description.summary and
description.homepage from rockspec, the release’s news from
news, and how to install it with LuaRocks, as far as the release
table has each of them;gh-pages, and commits them;checks out that branch again, and prints the commands to publish the
release, which for master are:
git push origin master release-v1.2 v1.2 gh-pages
gh release create v1.2 --verify-tag --latest --title '[ANN] luke v1.2 released' --notes-from-tag
git checkout release-v1.2
luarocks upload luke-1.2-1.rockspec
git checkout master
The gh command, which makes the tag the latest GitHub release and
its message the release notes, only appears when Luke finds gh on
the PATH, or GH names it; GH=true leaves it out.
Any other key in the release table names a branch to copy release
files to, such as gh-pages for GitHub Pages. Its table is a list,
like modules, which Luke copies in the order written. Each entry is
a pair mapping a path on that branch to the file or directory in the
release that replaces it. A path on its own stands for the same path
in both, and is copied only if the release has it.
For example, Specl publishes its manual and API documentation like
this:
['gh-pages'] = {
{api = 'doc/api/'},
{['index.md'] = 'doc/specl.md'},
'_config.yml.in',
}A path ending in .in is a template, which Luke fills
into the same path without the .in, with @version@ set to X.Y. A
template copied from the same path is filled from the copy on the
branch when the release has none, so a template such as
_config.yml.in can live on gh-pages alone:
release-no: @version@Each copy first removes what the branch has at that path, apart from
its templates, so that a file the release no longer has goes too, and
then copies the file, or the contents of the directory, into place.
The root of the branch is ., so LYAML publishes its LDoc output
there, keeping only the templates already on gh-pages:
['gh-pages'] = {
{['.'] = 'doc/'},
}A replacements list edits files on the release branch alone, such as
README badges that show the status of master, which should show the
release branch instead. Like modules, it is a list of tables that
each pair a file with its own list of {text = replacement} pairs:
replacements = {
{['README.md'] = {
{['?branch=master'] = '?branch=release-v@version@'},
}},
}Luke replaces every occurrence of each text, as written rather than as
a Lua pattern, in the order written, so that a later text can match
what an earlier replacement wrote. A replacement is a
template, with @version@ set to X.Y. The release
commits the edited files with everything else, and the branch checked
out keeps them as they were.
Every key that Luke understands, with the value it takes, where a list
can also be a single string, and where it is explained. Any of them,
and any table they contain, can have a platforms
table. Luke ignores other keys, so a lukefile can hold settings for
other tools.
Luke normalizes every path it takes, dropping . and empty components
and any trailing /, so ./doc/api/ and doc/api are the same path,
and ./ is ., the directory itself. It leaves .. alone.
| Key | Value | Explained in |
|---|---|---|
luke_format |
the lukefile format, a whole number | 3 |
package |
the project name | 3 |
version |
the project version | 3, 4.2 |
modules |
{name = source} entries, where a source can also be a list of sources or a table of rules |
3.1 |
install |
a table with bin |
3.2 |
install.bin |
{name = source or rules} entries, or sources on their own |
3.2, 3.3 |
bin |
like install.bin, but never installed |
3.2, 3.3 |
files |
{path = source or rules} entries, or templates on their own |
3.4 |
defines |
'NAME[=value]' or {NAME = value} entries, in order |
3.5, 6 |
incdirs |
a list of header directories | 3.5 |
libdirs |
a list of library directories | 3.5 |
libraries |
a list of libraries or probes | 3.5, 6 |
external_dependencies |
{NAME = {library = ...}} entries, in order |
3.5 |
ldocs |
an LDoc configuration file | 3.6 |
variables |
variable names, each mapped to a value | 4.1 |
substitute |
template names, each mapped to a value | 4.3 |
release |
a table with news, rockspec, add, replacements and branches |
8 |
release.news |
the NEWS file | 8.1, 8.2 |
release.rockspec |
the development rockspec | 8.1, 8.2 |
release.replacements |
{path = {{text = replacement}, ...}} entries, in order |
8.4 |
release.add |
a list of paths to commit to the release | 8.2 |
release[branch] |
{path = source} entries, or paths on their own, in order |
8.3 |
Please report bugs and make suggestions as GitHub issues, and check that they are not already fixed on master first. Pull requests are especially welcome.