NSIS-shaped, not NSIS
For someone who has written .nsi files. You know what an installer needs to do; this is
where Installua puts each of those things, and where it deliberately does not let you do
what you are used to.
The other table is Lua-shaped, not Lua, for people arriving from the other side.
The reversal that will bite hardest
Section titled “The reversal that will bite hardest”== on strings is case-sensitive.
local channel = readEnvStr("CHANNEL")if channel == "beta" then end --> StrCmpS $0 "beta" …if string.lower(channel) == "beta" then end --> StrCmp $0 "beta" …StrCmp is the one your fingers know, and it is not what == gives you. Installua’s ==
is Lua’s ==, which is case-sensitive, so it compiles to StrCmpS. The case-insensitive
form is string.lower(a) == string.lower(b) and it costs nothing — the compiler turns
it into a bare StrCmp with no ${StrCase} and no temporary.
Getting this backwards is the one mistake in this document that produces a working installer with wrong behaviour.
Where things you know live now
Section titled “Where things you know live now”| Your habit | Installua |
|---|---|
Name, OutFile, SetCompressor, … at top level |
fields of attributes {} — a set, reordered into a canonical order on output |
InstallDir, Caption, Icon |
fields of installer {}; the uninstaller’s twins are the same field names in uninstaller {} |
UninstallCaption, UninstallIcon |
uninstallCaption in attributes {}; icon in uninstaller {} — the Uninstall prefix is emitted, never written |
UninstallText |
page.confirm { topText = … } — it is that page’s text, not a block field |
ManifestDPIAwareness, ManifestSupportedOS, … |
manifest = { dpiAwareness = …, supportedOS = … } |
VIProductVersion, VIAddVersionKey |
versionInfo = { product = …, keys = { … } } |
Section / SectionEnd |
section("Name", function() … end) |
SetCompressor /SOLID lzma |
compressor = { "lzma", solid = true } — an attribute’s flags go in a table with it, the way a call’s do |
Section /o |
section { "Name", optional = true, body = function() … end } — options move the whole call into one table |
SectionGroup |
group("Name", { … }) — a list of sections and groups, not a body |
Function / FunctionEnd |
func("name", function() … end) |
Function .onInit, Function .onSelChange, … |
onInit(function() … end) — the leading . is emitted, never written |
Section un.Main, Function un.Foo |
declare them inside uninstaller {}; you never write un. |
LangString |
the languages {} block, keyed locale-first; un. is applied for you where it helps |
!include "WinVer.nsh" |
nothing — getWinVer("MAJOR") is a real instruction since NSIS 3, and returns a number |
!include "FileFunc.nsh" |
local fileFunc = import "FileFunc", then fileFunc.driveSpace("C:/", "/D=F /S=M") |
!define X 5 |
local X <const> = 5, used as X, emitted as ${X} |
!insertmacro MUI_PAGE_DIRECTORY |
page.directory {}, listed inline among installer {}’s entries — there is no pages field |
!define MUI_PAGE_HEADER_TEXT before a page macro |
a field of that page’s page.<name> { … } table |
nsExec::ExecToStack |
local nsExec = plugin "nsExec", then local rc, out = nsExec.execToStack(cmd) |
${If} / ${While} (LogicLib) |
ordinary if and while; LogicLib is reachable through raw if you insist |
Push / Pop / Exch |
nothing — the compiler owns the stack. A plugin’s output count comes from its declaration |
$0–$R9 |
nothing — the compiler owns the registers, and they are not nameable |
Goto, labels |
nothing — if, while, break, continue() |
$ does not exist inside a string
Section titled “$ does not exist inside a string”This is the biggest single change and it is not negotiable, because the failure mode it
prevents is silent. An unknown ${NOPE} or $x in real NSIS is warning 6000 and the
literal text ships into the installer.
So a literal is data. Every $ in one is escaped to $$ on the way out, and NSIS’s
four sigil forms become ordinary Lua names joined with ..:
| NSIS | Installua | Emitted |
|---|---|---|
$INSTDIR, $DESKTOP, $PROGRAMFILES64 |
INSTDIR, DESKTOP, PROGRAMFILES64 |
$INSTDIR |
${MYDEF} |
MYDEF, from local MYDEF <const> = … |
${MYDEF} |
$(MyString) |
lang.MyString |
$(MyString) |
$0–$R9 |
— | compiler-owned |
$$ |
a plain $ in any literal |
$$ |
detailPrint("Installing to " .. INSTDIR .. "/bin")--> DetailPrint "Installing to $INSTDIR\bin"It looks more verbose and costs nothing: .. compiles to a string template, so that is one
line and no registers. What you get back is that INSTDR is an error in your editor,
where $INSTDR was a warning in a build log — if you read build logs.
If you type $INSTDIR inside a literal out of habit, the compiler says so by name and
suggests .. INSTDIR.
Paths, and the backslash
Section titled “Paths, and the backslash”\ is Lua’s escape character now. So:
| Write | Get |
|---|---|
"assets/icon.ico" |
"assets\icon.ico" — / is normalised in path positions |
[[C:\Tools]] |
"C:\Tools" — long strings process no escapes |
"C:\\Tools" |
"C:\Tools" |
"C:\Tools" |
error — \T is not an escape |
A backslash is the recommended form, written [[bin\tools]] or "bin\\tools": it is
correct wherever the string goes. A forward slash is a convenience, and is normalised in
exactly two places:
- A path position. The overlay marks which parameters are paths — filesystem paths
and registry subkeys — so
file("assets/icon.ico")is fine anddetailPrint("a/b")is left alone. - Text joined straight after a built-in directory, in any position.
p = INSTDIR .. "/bin/x.exe"stores$INSTDIR\bin\x.exe. The rewrite stops at the first/or", soINSTDIR .. "/app.exe /S"keeps its switch.
Anything else keeps its /, and that includes most things you store and join later.
A fragment in a variable (sub = "bin/tools", later INSTDIR .. "/" .. sub), two
literals joined without a directory, and a user global holding a directory
(appDir .. "/x.exe") all reach the installer as written. Those are where a
half-converted C:\App\bin/tools comes from, and a command line, a plugin or the
GetParent family may not accept one. Write \ in them.
$\n, $\r, $\t and $\" are written \n, \r, \t and \".
Charset, always written, always first
Section titled “Charset, always written, always first”Unicode true is emitted unconditionally, and there is no setting to change it. NSIS’s
behaviour without the line depends on how your makensis was built, which means the same
script means two things on two machines — the thing this language exists to stop.
There is no ANSI installer either. Every plugin NSIS 3 ships has a Unicode build, and a
charset that could change would be one more thing every !addplugindir and every window
message named by charset had to be kept in step with. A third-party plugin that only ever
shipped an ANSI DLL is the cost.
Target has no Installua equivalent, because Target and Unicode are last-one-wins with
each other and silently so. If you need it, raw gives it to you, and putting Unicode
first is what lets your raw line override it rather than the other way round.
The same rule covers pointer size and maximum string length: a property of how makensis
was built may verify an assumption your source states, and may never decide what
your source means.
Pages are MUI2, and only MUI2
Section titled “Pages are MUI2, and only MUI2”PageEx, Page and UninstPage have no Installua equivalent. MUI2 is what gets generated.
The reason page settings group into a page.<name> { … } table rather than sitting loose is
correctness, not tidiness: MUI2’s settings are !defines that apply to the next
!insertmacro MUI_PAGE_* and are then undefined. Hand-written MUI2 can attach a header
text to the wrong page and nothing will tell you. A page.<name> { … } table cannot express the
mistake.
page.directory { headerText = "Choose a location", pre = function() detailPrint("choosing a location") end,}Custom pages are page.custom, and the controls on one are declarations listed in
its controls. nsDialogs is compiled, not !included: the compiler writes the
style constants nsDialogs.nsh’s ${NSD_Create*} macros would have written, so a
program with a dialog has the same include list as one without. See
tests/golden/dialog.lua.
Things NSIS lets you do that Installua will not
Section titled “Things NSIS lets you do that Installua will not”| NSIS | Why not | Instead |
|---|---|---|
relative jumps, Goto +2 |
correct until a later pass inserts an instruction, then silently wrong | never emitted, never writable |
IntOp with !, &&, || |
they materialise a boolean; conditions fuse into jumps instead | ordinary and/or/not |
Int64Op |
it does not exist in NSIS. Int64Cmp and Int64Fmt do |
arithmetic on an int64 is a hard error naming the reason, never a truncation |
StrCmp, IntCmp, IfErrors, IfFileExists as commands |
they take labels; the language owns labels | ==, <, errors(), fileExists(p) — all ordinary expressions |
MessageBox with a jump table |
it is a statement, a flag set and a branch at once | local answer = messageBox { … }; the jump table comes back for free when you compare the answer |
a config file for Name, OutFile, … |
anything NSIS expresses as a script attribute belongs in attributes {} |
installua.toml is not a settings file: it lists the projects and bounds the declaration search, and nothing else |
What is still yours
Section titled “What is still yours”raw [[ … ]] emits verbatim at the position it appears. It is deliberately conspicuous,
and it costs exactly what you would expect:
- nothing is hoisted — its position matters;
- no local survives it — raw NSIS can write any register (
System::Call '…i.r0'does it from inside a string literal) and nothing in the AST records which, so values cross arawblock through a global; - labels you declare in it join the enclosing body’s namespace;
- a long string is required, not merely preferred: a
"…"literal would be lexed as Lua and mangle your\and$\n.
raw has one other position, where it costs less: an argument of a declared plugin
method, plugin.method(raw "/index 0 /index 1", "$Doc"). There the text is spliced into
that call’s line and everything else stays declared — one argument however many words it
holds, and the outputs still bind to locals.
Third-party headers and plugins are declared in .installua/declarations/*.toml, which the
compiler, the editor stubs and the linter all read — five lines per method, and the call
is then as ordinary as detailPrint. Declaring one is not ceremony:
${StrCase} $0 "text" "L" puts its destination first and ${GetSize} "$dir" "" $0 $1 $2
puts it last, so there is no convention to infer, and guessing emits NSIS that looks
right and is not. The format is in
the reference; what stays
raw’s work is !addplugindir and anything you have not declared yet.