Program structure
A file is a Lua program, read top to bottom, with no preprocessor. A func is
visible everywhere: every one is resolved before any body is compiled, so a
func may call one declared below it. A local is Lua’s, in scope from its
declaration down — see what hoists.
The order of the fields inside attributes {} does not matter either, and for
a different reason: NSIS has a handful of commands that refuse to run — or, in
one case, crash — once something ahead of them has changed the header, so the
compiler emits the block in an order it chose rather than the one you typed.
Which fields those are was measured against makensis rather than reasoned
about; the list is ORDERED in src/lower/mod.rs.
Function / FunctionEnd
Section titled “Function / FunctionEnd”Declares a function. Parameters and returns are ordinary Lua; the compiler writes the stack traffic NSIS needs.
Usage func(name, body) → nothing
func("majorOf", function(version) local dot = string.find(version, ".") return string.sub(version, 1, dot - 1)end)A func nothing calls is left out of the script and not checked, so a library
of them can be included by a program that uses only some. A call from a raw
block counts: any word of its text that names a func keeps it.
.onInit / un.onInit
Section titled “.onInit / un.onInit”The callback NSIS runs before anything is shown. Written inside the block it
belongs to, so the same name works in both the installer and the uninstaller, and the un. prefix is the
compiler’s business.
Usage onInit(body), listed in installer {} or uninstaller {}
The other callbacks are written the same way, each at most once per block, and
each takes a body with no arguments. The names are NSIS’s without the .:
| NSIS | Installua | Runs |
|---|---|---|
.onInstSuccess / un.onUninstSuccess |
onInstSuccess / onUninstSuccess |
after the last section, when nothing failed |
.onInstFailed / un.onUninstFailed |
onInstFailed / onUninstFailed |
after the last section, when something failed |
.onVerifyInstDir / un.onVerifyInstDir |
onVerifyInstDir |
each time the directory changes; abort() rejects it |
.onSelChange / un.onSelChange |
onSelChange |
each time a component is ticked or unticked |
.onGUIEnd / un.onGUIEnd |
onGUIEnd |
after the window closes |
.onRebootFailed / un.onRebootFailed |
onRebootFailed |
when reboot() could not restart |
The uninstaller’s outcomes say Uninst, as NSIS does, and the installer’s
name in uninstaller {} is an error. .onGUIInit, .onUserAbort and
.onMouseOverSection are MUI2’s; their entries are on
Pages and MUI.
installer { page.instFiles {},
onInit(function() if fileExists(INSTDIR .. "/app.exe") then detailPrint("upgrading") end end),}Installer and uninstaller blocks
Section titled “Installer and uninstaller blocks”installer {} holds the pages, sections and callbacks of the installer;
uninstaller {} the same for the uninstaller, and writing one is what makes
writeUninstaller legal. Both also carry the MUI settings that apply to the
whole installer or uninstaller — see Pages and MUI.
Usage installer { … }, uninstaller { … }
installer { installDir = PROGRAMFILES64 .. "/Example",
page.directory {}, page.instFiles {},
section("Core", function() setOutPath(INSTDIR) file("assets/Example.exe") writeUninstaller(INSTDIR .. "/uninstall.exe") end),}
uninstaller { page.confirm {}, page.instFiles {},
section("Remove", function() rmDir(INSTDIR, { recursive = true }) end),}Globals are declared by assigning to one at the top level. A local
inside a body is a register the compiler allocates and reuses.
Usage assignment at the top level
gitDescribe = ""!include
Section titled “!include”Source layout: the named file’s declarations are merged into this one and nothing is emitted. A path is relative to the file that names it.
A func and a global are program-wide, so an included file’s are usable
anywhere. A top-level local is not: as in Lua, it belongs to the file that
declares it. A file shares one by returning it, and the including file binds
the file to a name and reads through it:
Usage include(path) → nothing, or local m = include(path) → the file’s
returned names, read as m.name
-- lib/colors.lualocal VALID <const> = "D8EABD"local settings = page.custom { … }return { VALID = VALID, settings = settings }
-- install.lualocal colors = include "lib/colors.lua"installer { colors.settings }The returned table exists only at build time, and holds only names the file
declares at its top level. Two files may each declare a top-level local of
the same name, and each reads its own. The files are merged into one script,
though, so every file but the first spells its copy with a suffix in the
.nsi (!define COLOR_1, SEC_core_2). A raw string in that file that
spells the unsuffixed NSIS name reads the other file’s.
Walks the build machine at compile time and unrolls the result, so the file
list is fixed before anything ships. This is what replaces
FindFirst/FindNext/FindClose, which walk the target’s disk instead.
Usage for path in glob(pattern) do … end
for path in glob("assets/docs/*.pdf") do file(path)endA pattern is folders separated by /. In any of them, * matches any run of
characters and ? matches one. ** stands for any number of folders, none
included, so assets/**/*.txt finds every .txt below assets and
assets/** every file. A pattern ending in / yields folders instead of
files: glob("presets/*/") gives presets/classic, presets/modern and so
on. The paths come back sorted, relative to the source file, as you would have
written them.
Case follows the build machine. On Windows, *.bmp also finds LOGO.BMP, as
File *.bmp would. On macOS and Linux it doesn’t: write *.BMP as a second
glob if a folder holds both.
ipairs
Section titled “ipairs”Walks a <const> table at compile time and unrolls it, the way glob does: the
body is written out once per element, and there is no loop in the .nsi. i
counts from 1; leave it out as _, or write for i in ipairs(list) for the
position alone.
Usage for i, x in ipairs(list) do … end
local FONTS <const> = { { file = "Roboto.ttf", name = "Roboto" }, { file = "Inter.ttf", name = "Inter" },}
for _, font in ipairs(FONTS) do file("fonts/" .. font.file) detailPrint("installing " .. font.name)endA <const> table is a list, { "a", "b" }, or a record, { name = "a" },
not both, and either may hold the other. Everything in it has to be known at
build time. It is not a value you can pass or print: you read a field,
font.name, or walk a list with ipairs, and each field folds where it is
read. So the table itself gets no !define. There is no pairs, because Lua
does not promise an order for it.
!echo / !system / !getdllversion
Section titled “!echo / !system / !getdllversion”Side effects on the build machine, written where the call stands and run by
makensis when it reaches that line. The MAKENSIS. in front is how you tell
them apart from code that runs on the user’s machine.
| NSIS | Installua |
|---|---|
!echo |
MAKENSIS.echo(message) |
!system |
MAKENSIS.system(command) → int (the exit code) |
!getdllversion |
MAKENSIS.getDllVersion(path) → int, int, int, int |
MAKENSIS.echo("building " .. VERSION)local status = MAKENSIS.system("git diff --quiet")local major, minor = MAKENSIS.getDllVersion("bin/app.dll")detailPrint("app " .. major .. "." .. minor)The argument has to be known while building: a literal, a <const> or
NSISDIR. It is a ! line’s text, so a $ in a literal reaches the command
as written: MAKENSIS.system("echo $HOME") gets the shell’s $HOME.
An answer is an ordinary value at install time, carrying a fact from the build
machine. It is never a <const>, so a build-time if cannot branch on it.
Where that is what you need, run the command before the build and pass the
result in as a param.
MAKENSIS.system returns the exit code on every platform. Off Windows,
makensis returns the wait status (exit 3 reads 768), and the compiler shifts
it back. MAKENSIS.getDllVersion returns major, minor, release and build. A
file without a version resource reads as four zeros, because plugins often ship
without one. A missing file stops the build.
A build-time constant the invocation may set. This is what replaces
!ifndef VERSION / !define VERSION "1.4.2" / !endif, and -D is the same
flag makensis uses.
Usage local NAME <const> = param(name, default) → the value, or
param(name) for one the build cannot do without
local VERSION <const> = param("VERSION", "1.4.2")local PORT <const> = param("PORT", 8080)local SIGNED <const> = param("SIGNED", false)$ installua build install.lua -D VERSION=2.0.0 -D SIGNED=true$ installua build install.lua --param VERSION=2.0.0 --param SIGNED=true-D is the flag makensis users already know; --param is the same flag written
out. It is not --define or --declare because the source is what declares a
parameter and the invocation is what sets one — a flag named for declaring
would read as doing the thing the unknown-param error says it does not.
Three things follow from the declaration being written in the source rather than passed only on the command line:
- A
-Dnaming a parameter the program does not declare is an error. That is the whole point. NSIS’s version fails the other way — a misspelt-DVERSOINdefines a second thing nobody reads, the build succeeds, the default ships, and nothing says so. - The default is the type.
param("PORT", 8080)makesPORTan integer, so-D PORT=abcis rejected instead of arriving at anIntOpas a string.param("SIGNED", false)takestrueandfalseand nothing else. - The name and the binding are separate. The string is what
-Dsets; thelocalis what the program reads. They are usually spelled the same and do not have to be.
A relative path in a value is relative to the program, not to the folder the
command was typed in: -D PRESETS=fixtures/avs given to
packages/pack/install.lua means packages/pack/fixtures/avs, as every other
path in the program does. A parameter’s type does not say it is a path, so the
compiler cannot tell which values to rebase.
A parameter with no default is required
Section titled “A parameter with no default is required”Leave the default out and the invocation has to supply the value. A build
without the flag stops with missing-param, at the declaration — the line that
says the value is needed.
local SIGNING_CERT <const> = param("SIGNING_CERT")$ installua build install.luainstall.lua:1:30: error[missing-param]: `SIGNING_CERT` has no default, so the build needs `-D SIGNING_CERT=…`This is the NSIS !ifndef SIGNING_CERT / !error "..." / !endif guard,
written as the declaration instead of as a line beside it. The guard is
something a script has to remember to include and can be pasted in the wrong
place; the declaration is checked for every build because it is the parameter,
and it sits where a reader already looks for what the build takes.
A required parameter is a string. The default is what declares a type, so
with no default there is nothing to read the flag’s text as but text — -D PORT=abc is accepted by param("PORT") and rejected by param("PORT", 8080).
If a required value has to be an integer or a boolean, check it yourself, or
give it a default and let the type do the work.
param(…) is a declaration, so it stands alone as the whole initialiser of
a top-level local … <const> — not inside a body, and not composed into a
larger expression. Compose one line further down instead:
local VERSION <const> = param("VERSION", "1.4.2")local FULL <const> = VERSION .. "-win64" -- an ordinary <const>installua check takes the same -Ds, because a program whose parameters are
overridden is a different program to check.
Build-time if
Section titled “Build-time if”An if at the top level is decided by the compiler, not by the installer. This
is what replaces !if / !ifdef / !ifndef / !else / !endif.
Usage if <build-time condition> then … end, at the top level
local ARCH <const> = param("ARCH", "x86")
if ARCH == "x64" then attributes { name = "Example (64-bit)", outFile = "example-x64.exe" } installer { section("Core", function() file("bin/x64/app.exe") end) }else attributes { name = "Example", outFile = "example.exe" } installer { section("Core", function() file("bin/x86/app.exe") end) }endThe condition has to fold — a literal, a <const>, a param, or an
expression over them. A condition read at install time is an error, because out
here there is nothing to read it from: no register has been written, and the
branch would have to be taken by makensis rather than by the installer. That
if belongs inside a section or a func, where it is an ordinary runtime one.
It also has to be a bool, for the same reason a runtime condition does.
Whatever the branch holds is an ordinary top-level declaration. Sections,
funcs, attributes, <const>s, params, globals, raw.head / raw.tail —
there is no second set of rules, because the selected statements are the top
level once the branch is taken: a section declared inside a branch can be listed
by an installer {} written after it.
That includes a local, which stays visible after the end as Lua’s would not.
lua-language-server follows Lua and reports each later use as undefined-global,
so to pick a value write local X <const> = c and a or b instead, which folds the
same way — see truthiness.
The one thing to know is what “not taken” means: a declaration in the branch that
was not taken is not part of the program at all. A param declared only there is
not declared, so a -D for it is the same unknown-param error a misspelling
gets — which is the correct answer, since in that configuration the program really
does not have it.
A block’s list cannot hold an if, so to leave out a page or a section, declare
the name above the if, give it its value in the branch, and list it as usual.
When the branch is not taken, the entry is dropped rather than reported, and the
same goes for a group’s sections and a page’s controls. A name declared in no
branch at all is still an error, so a misspelling is caught either way:
local LICENSE <const> = param("LICENSE", "")
local licensePageif LICENSE ~= "" then licensePage = page.license { file = LICENSE }end
installer { licensePage, page.instFiles {}, section("Core", function() end) }Install-time code may still address a section or control the build left out.
Writing one of its fields does nothing, and reading one gives the field’s empty
value: false for selected or checked, "" for text or value, 0 for
size. So fonts.selected = false in onInit needs no branch of its own.
local licensePage = page.license { … } inside the branch builds the same way,
but lua-language-server scopes that local to the branch and reports every
use after the end.
Nothing of the conditional reaches the output. It is not a directive the
compiler emits and then orders against everything else — it is a branch the
compiler takes, before a single line is bucketed. The emitted script is
byte-for-byte the script you would get by writing only the branch that won.
include is the one thing that cannot go in a branch: files are merged before
any of this runs.
Inside a section or a func
Section titled “Inside a section or a func”The same if works in a body, with no separate syntax. When its condition
folds, the compiler takes the branch there too: the losing side is dropped and
no StrCmp or jump is emitted, which is what !if around a few instructions
does in NSIS.
local ARCH <const> = param("ARCH", "x86")
installer { section("Core", function() if ARCH == "x64" then file("bin/x64/app.exe") else file("bin/x86/app.exe") end end),}With -D ARCH=x64 the section is a single File "bin\x64\app.exe".
The difference from the top level is what happens when the condition does not
fold. At the top level that is an error; in a body it is an ordinary runtime
if, decided by the installer. You write both the same way, and whether a
condition folds is the only thing that decides which one you get.