Skip to content

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.

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.

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 {} 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 = ""

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.lua
local VALID <const> = "D8EABD"
local settings = page.custom { … }
return { VALID = VALID, settings = settings }
-- install.lua
local 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)
end

A 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.

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)
end

A <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.

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)
Terminal window
$ 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 -D naming a parameter the program does not declare is an error. That is the whole point. NSIS’s version fails the other way — a misspelt -DVERSOIN defines a second thing nobody reads, the build succeeds, the default ships, and nothing says so.
  • The default is the type. param("PORT", 8080) makes PORT an integer, so -D PORT=abc is rejected instead of arriving at an IntOp as a string. param("SIGNED", false) takes true and false and nothing else.
  • The name and the binding are separate. The string is what -D sets; the local is 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.

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")
Terminal window
$ installua build install.lua
install.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.

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) }
end

The 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 licensePage
if 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.

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.