diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6cf621..d79b938 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,3 +39,43 @@ jobs: run: ./gradlew build --no-daemon -PjavaToolchain=${{ matrix.java }} env: GRADLE_OPTS: -Dorg.gradle.java.installations.auto-download=true + + e2e: + name: Assemble and compile the e2e corpus + # macOS rather than Ubuntu for one reason: Homebrew is preinstalled and + # carries current NSIS versions, where Debian's packages lag. Bootstrapping + # Homebrew on Ubuntu would get the same makensis but spends a minute or two + # on setup first. + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + # makensis is the oracle the corpus asserts against; without it run.sh skips. + # Homebrew tracks the current release rather than pinning one, so record the + # version: a red run caused by an NSIS upgrade should be distinguishable + # from one caused by a change here. + - name: Install NSIS + run: | + brew install makensis + makensis -VERSION + + # One JDK only: the assembled .nsi does not vary by toolchain, so running + # the corpus across the matrix would just spend CI minutes on makensis. + # E2E_REQUIRE_MAKENSIS turns the skip path into a failure - in CI a missing + # compiler is a broken workflow, not a reason to pass. + - name: Run the e2e corpus + run: ./e2e/run.sh + env: + E2E_REQUIRE_MAKENSIS: 1 + GRADLE_OPTS: -Dorg.gradle.java.installations.auto-download=true diff --git a/docs/Reference.md b/docs/Reference.md index 3aba230..76bb5e1 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -6,7 +6,7 @@ nsL is a high-level language for [NSIS](http://nsis.sourceforge.net). The nsL as ## Source Files -Just like with NSIS, one writes their installation wizard code in a plain text file with a text editor such as Notepad. For nsL, the source code files must have an “nsl” file extension. Right clicking on an nsL source code file in Windows Explorer will show the “Compile nsL Script” option. This option will run the nsL Assembler on the chosen file, which assembles the corresponding NSIS (.nsi) script. The makensisw compiler executable is then automatically run on the assembled NSIS script to build the installation wizard executable. +Just like with NSIS, one writes their installation wizard code in a plain text file with a text editor such as Notepad. For nsL, the source code files must have an “nsl” file extension. Right clicking on an nsL source code file in Windows Explorer will show the “Compile nsL Script” option. This option will run the nsL Assembler on the chosen file, which assembles the corresponding NSIS (.nsi) script. The NSIS compiler is then automatically run on the assembled NSIS script to build the installation wizard executable — `makensisw.exe` on Windows, or `makensis` from the `PATH` on other platforms. ## Syntax diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..727800a --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +# Assembled and compiled output, when a script is run in place rather than in a +# scratch copy. +*.nsi +*.exe diff --git a/e2e/01-expressions.nsl b/e2e/01-expressions.nsl new file mode 100644 index 0000000..7b84249 --- /dev/null +++ b/e2e/01-expressions.nsl @@ -0,0 +1,67 @@ +/* + * Literals, types and the register pool. + * + * Everything here is about what an expression can *be* - the operators that + * combine them are 02. + */ + +#include "guard.nsl" + +Name("01 expressions"); +OutFile("01-expressions.exe"); + +section Test("expressions") +{ + // Every literal type. + $R0 = 42; + $R1 = "a string"; + $R2 = true; + $R3 = false; + $R4 = 0x1F; + $R5 = -7; + + // Registers and NSIS constants as values. + $R6 = $R0; + $R7 = $INSTDIR; + $R8 = $WINDIR; + $R9 = $PROGRAMFILES; + + // A named variable, which becomes a Var rather than one of the pool. + $namedVariable = "declared by first assignment"; + DetailPrint($namedVariable); + + // Assemble-time conversions. + $0 = toint("255"); + $1 = toint("0xFF"); + $2 = toint(true); + $3 = toint($R0); + $4 = length("twelve chars"); + $5 = type(42); + $6 = type("s"); + $7 = type(true); + $8 = type($R0); + $9 = type($WINDIR); + + DetailPrint($0." ".$1." ".$2." ".$3." ".$4); + DetailPrint($5." ".$6." ".$7." ".$8." ".$9); + + /* + * Register pool pressure: enough nested sub-expressions in one statement that + * the allocator has to hand out and release several temporaries. A leak shows + * up as "out of registers" at assemble time. + */ + $R0 = 1; + $R1 = 2; + $R2 = 3; + $R3 = (($R0 + $R1) * ($R1 + $R2)) - (($R0 * $R2) + ($R1 * $R1)); + DetailPrint("nested = ".$R3); + + // Deeply nested concatenation of mixed operand types. + DetailPrint("int ".42." bool ".true." reg ".$R0." const ".$WINDIR." end"); + + // A block introduces a new scope; $blockLocal does not escape it. + { + $blockLocal = "scoped"; + DetailPrint($blockLocal); + } +} diff --git a/e2e/02-operators.nsl b/e2e/02-operators.nsl new file mode 100644 index 0000000..6bf8d70 --- /dev/null +++ b/e2e/02-operators.nsl @@ -0,0 +1,133 @@ +/* + * Every operator nsL defines, in both the assemble-time-constant and the + * run-time-register form. + * + * The two forms matter: the assembler folds an expression whose operands are + * all literals, so "9 + 9" never reaches NSIS and proves nothing about the + * emitted IntOp. Each group below therefore does the same work twice. + */ + +#include "guard.nsl" + +Name("02 operators"); +OutFile("02-operators.exe"); + +section Test("operators") +{ + // --- Arithmetic, folded at assemble time --- + DetailPrint("fold + = ".(9 + 4)); + DetailPrint("fold - = ".(9 - 4)); + DetailPrint("fold * = ".(9 * 4)); + DetailPrint("fold / = ".(9 / 4)); + DetailPrint("fold % = ".(9 % 4)); + DetailPrint("fold | = ".(9 | 4)); + DetailPrint("fold & = ".(9 & 4)); + DetailPrint("fold ^ = ".(9 ^ 4)); + DetailPrint("fold ~ = ".(~9)); + + // --- Arithmetic, emitted as IntOp --- + $R0 = 9; + $R1 = 4; + $0 = $R0 + $R1; + $1 = $R0 - $R1; + $2 = $R0 * $R1; + $3 = $R0 / $R1; + $4 = $R0 % $R1; + $5 = $R0 | $R1; + $6 = $R0 & $R1; + $7 = $R0 ^ $R1; + $8 = ~$R0; + $9 = -$R0; + DetailPrint($0." ".$1." ".$2." ".$3." ".$4); + DetailPrint($5." ".$6." ".$7." ".$8." ".$9); + + // Shifts. + $0 = $R0 < 2; + $1 = $R0 > 2; + DetailPrint("shifts ".$0." ".$1); + + // Precedence and explicit grouping must not agree by accident. + DetailPrint("prec = ".(2 + 3 * 4 - 6 / 2)); + DetailPrint("group = ".((2 + 3) * (4 - 6) / 2)); + $R2 = 2; + $R3 = 3; + $0 = $R2 + $R3 * 4 - 6 / $R2; + $1 = ($R2 + $R3) * (4 - 6) / $R2; + DetailPrint("prec = ".$0); + DetailPrint("group = ".$1); + + // --- Comparison: signed, unsigned (u), case sensitive (S), insensitive (s) --- + $R0 = 5; + $R1 = 9; + $0 = $R0 == $R1; + $1 = $R0 != $R1; + $2 = $R0 >= $R1; + $3 = $R0 <= $R1; + $4 = $R1 >=u $R0; + $5 = $R1 <=u $R0; + DetailPrint($0." ".$1." ".$2." ".$3." ".$4." ".$5); + + $R2 = "Text"; + $0 = $R2 ==s "text"; + $1 = $R2 ==S "text"; + $2 = $R2 !=s "text"; + $3 = $R2 !=S "text"; + DetailPrint($0." ".$1." ".$2." ".$3); + + // StrCmp is documented as the same thing; assert both forms assemble. + $0 = StrCmp($R2, "text"); + $1 = StrCmpS($R2, "text"); + DetailPrint($0." ".$1); + + // --- Boolean --- + DetailPrint("fold && = ".(true && false)); + DetailPrint("fold || = ".(true || false)); + DetailPrint("fold ! = ".(!true)); + + $R0 = 1; + $R1 = 0; + $0 = $R0 > 0 && $R1 > 0; + $1 = $R0 > 0 || $R1 > 0; + $2 = !($R0 > 0); + $3 = ($R0 == 1 || $R1 > 0) && $R0 == 1 && !($R1 == 4 || $R0 == 2); + DetailPrint($0." ".$1." ".$2." ".$3); + + // --- Concatenation --- + $R0 = "one"; + $R1 = "two"; + DetailPrint($R0.$R1); + DetailPrint("a".$R0."b".$R1."c".1.2.3); + + // --- Ternary, both folded and emitted --- + $R0 = 7; + $0 = $R0 >= 0 ? $R0 : 0; + $1 = true ? "taken" : "not taken"; + $2 = $R0 > 0 ? ($R0 > 5 ? "big" : "small") : "negative"; + DetailPrint($0." ".$1." ".$2); + DetailPrint($R0 > 0 ? "ternary in an argument" : "no"); + + // --- Assignment, every compound form --- + $R0 = 64; + $R0 += 2; + $R0 -= 1; + $R0 *= 3; + $R0 /= 2; + $R0 %= 50; + $R0 |= 8; + $R0 &= 60; + $R0 ^= 5; + $R0 <<= 2; + $R0 >>= 1; + DetailPrint("compound = ".$R0); + + // Increment and decrement. + $R1 = 0; + $R1++; + $R1--; + DetailPrint("incdec = ".$R1); + + // Assignment inside an expression, evaluated left to right. + $R2 = 3; + $0 = ($R2 = 9) > 5 || $R2 == 3; + DetailPrint("inline assign = ".$0." ".$R2); +} diff --git a/e2e/03-strings.nsl b/e2e/03-strings.nsl new file mode 100644 index 0000000..e4598fe --- /dev/null +++ b/e2e/03-strings.nsl @@ -0,0 +1,67 @@ +/* + * String literals: the three quote characters, escape sequences, the verbatim + * "@" prefix, and format(). + * + * nsL diverges from NSIS here - "$\" is gone and the escapes are C-style - so + * this is one of the easier places to emit something NSIS reads differently + * from what the author wrote. + */ + +#include "guard.nsl" + +Name("03 strings"); +OutFile("03-strings.exe"); + +section Test("strings") +{ + // All three quote characters are interchangeable. + DetailPrint("double quoted"); + DetailPrint('single quoted'); + DetailPrint(`back quoted`); + + // Each quote character lets the other two through untouched. + DetailPrint('contains "double" quotes'); + DetailPrint("contains 'single' quotes"); + DetailPrint(`contains "both" of 'them'`); + + // Escape sequences. + DetailPrint("tab:\there"); + DetailPrint("newline:\r\nsecond line"); + DetailPrint("escaped quote: \" and backslash: \\"); + + // A verbatim string keeps its backslashes, which is what Windows paths want. + DetailPrint(@"C:\no\escapes\here"); + DetailPrint(@'verbatim \r\n stays literal'); + + // The same path written both ways must mean the same thing. + $R0 = @"\a\b\c"; + $R1 = "\\a\\b\\c"; + DetailPrint("paths equal: ".($R0 ==S $R1)); + + // Variables cannot appear inside a quoted string; concatenate instead. + $R2 = "world"; + DetailPrint("hello ".$R2); + + // ... or use format(), which is resolved at assemble time. + DetailPrint(format("hello {0}", $R2)); + DetailPrint(format("{0} then {1} then {0} again", "first", "second")); + // Doubling a brace escapes it, so the first {0} here is literal text. + DetailPrint(format("a literal brace: {{0} is not a placeholder, {0} is", "this")); + DetailPrint(format("{0}-{1}-{2}", 1, true, $WINDIR)); + // A result shorter than the format string, and two adjacent placeholders: + // both used to walk the assembler's index off the string. + DetailPrint(format("{0}", 1)); + DetailPrint(format("{0}{1}", "AAAAA", "BBBBB")); + + // Assemble-time length of a literal, versus the run-time instruction. + DetailPrint("assemble-time length = ".length("abcdef")); + // StrLen() rejects any argument the assembler considers literal, and a plain + // register counts as one, so its operand has to be a nested instruction call. + // See KNOWN-GAPS.md. + $R3 = StrLen(ReadEnvStr("PATH")); + DetailPrint("run-time length = ".$R3); + + // An empty string is a legal operand. + $R4 = ""; + DetailPrint("empty is empty: ".($R4 ==S "")); +} diff --git a/e2e/04-control-flow.nsl b/e2e/04-control-flow.nsl new file mode 100644 index 0000000..3e752bc --- /dev/null +++ b/e2e/04-control-flow.nsl @@ -0,0 +1,165 @@ +/* + * if / while / do / for, with break and continue. + * + * Each construct appears three ways: with a condition the assembler can fold, + * with one it cannot, and nested inside another construct. The folded forms + * matter because the assembler omits unreachable branches entirely, and an + * omission that takes a label with it produces NSIS that will not compile. + */ + +#include "guard.nsl" + +Name("04 control flow"); +OutFile("04-control-flow.exe"); + +section Test("control flow") +{ + $R0 = 5; + + // --- if --- + if ($R0 > 0) + DetailPrint("if: single statement"); + + if ($R0 > 0) + { + DetailPrint("if: block"); + } + + if ($R0 > 9) + DetailPrint("if: not this"); + else + DetailPrint("if: else"); + + if ($R0 == 0) + DetailPrint("if: no"); + else if ($R0 == 5) + DetailPrint("if: else if"); + else if ($R0 == 6) + DetailPrint("if: no"); + else + DetailPrint("if: no"); + + // Conditions the assembler resolves on its own: the dead arm is not emitted. + if (true) + DetailPrint("if: folded true"); + if (false) + DetailPrint("if: folded false, never emitted"); + else + DetailPrint("if: folded false, else emitted"); + + // Nested. + if ($R0 > 0) + { + if ($R0 > 4) + { + if ($R0 < 6) + DetailPrint("if: three deep"); + } + } + + // --- while --- + $R1 = 0; + while ($R1 < 3) + { + DetailPrint("while: ".$R1); + $R1++; + } + + // Never entered, but still emitted - the body may hold a continue that needs + // the loop's label. + while ($R1 < 3 && false) + { + DetailPrint("while: unreachable"); + $R1++; + } + + // --- do / while --- + $R1 = 0; + do + { + DetailPrint("do: ".$R1); + $R1++; + } + while ($R1 < 3); + + // Runs exactly once, and leaves a label with nothing jumping to it. The + // assembler emits a zero-jump StrCmp to keep makensis from warning. + do + { + DetailPrint("do: once"); + } + while (false); + + // --- for --- + for ($i = 0; $i < 3; $i++) + DetailPrint("for: ".$i); + + // Multiple initialisers and multiple iterators. + for ($i = 0, $j = 3; $i < 3 && $j > 0; $i++, $j--) + DetailPrint("for: ".$i." ".$j); + + // Every clause is optional. + $m = 3; + for (; $m > 0;) + { + DetailPrint("for: no init or iterator, ".$m); + $m--; + } + + $m = 3; + for (;;) + { + DetailPrint("for: bare, ".$m); + if ($m == 0) + break; + $m--; + } + + /* + * Never assembled. Reuses $i rather than introducing a name of its own: a + * variable that appears only in unassembled code is still declared, and + * makensis warns that it wastes memory. Examples/Loops.nsl notes the same. + */ + for ($i = 0; true && false; $i++) + DetailPrint("for: unreachable"); + + // --- break and continue --- + for ($i = 0; $i < 10; $i++) + { + if ($i == 2) + continue; + if ($i == 5) + break; + DetailPrint("break/continue: ".$i); + } + + // break and continue bind to the innermost loop. + for ($i = 0; $i < 3; $i++) + { + $j = 0; + while ($j < 3) + { + $j++; + if ($j == 2) + continue; + if ($j == 3) + break; + DetailPrint("nested: ".$i." ".$j); + } + } + + // A loop inside an if inside a loop. + for ($i = 0; $i < 2; $i++) + { + if ($i == 1) + { + $j = 0; + do + { + DetailPrint("mixed: ".$i." ".$j); + $j++; + } + while ($j < 2); + } + } +} diff --git a/e2e/05-switch.nsl b/e2e/05-switch.nsl new file mode 100644 index 0000000..1b85cde --- /dev/null +++ b/e2e/05-switch.nsl @@ -0,0 +1,127 @@ +/* + * switch, over each subject type it accepts. + * + * Fallthrough is the interesting case: a case without a break runs into the + * next one, which the assembler has to express in NSIS with labels rather than + * a jump table. + */ + +#include "guard.nsl" + +Name("05 switch"); +OutFile("05-switch.exe"); + +section Test("switch") +{ + // --- Integer subject, with fallthrough --- + $R0 = 1; + switch ($R0) + { + case 0: + DetailPrint("int: 0"); + case 1: + DetailPrint("int: 0 or 1 (fell through)"); + break; + case 2: + DetailPrint("int: 2"); + break; + default: + DetailPrint("int: something else"); + break; + } + + // No default arm. + switch ($R0) + { + case 1: + DetailPrint("int: no default arm"); + break; + } + + // default must come last; the assembler rejects it anywhere else. + switch ($R0) + { + case 1: + DetailPrint("int: case before default"); + break; + default: + DetailPrint("int: default last"); + break; + } + + // --- String subject --- + $R1 = "beta"; + switch ($R1) + { + case "alpha": + DetailPrint("str: alpha"); + break; + case "beta": + DetailPrint("str: beta"); + break; + default: + DetailPrint("str: default"); + break; + } + + // --- Boolean subject --- + switch (Silent()) + { + case true: + DetailPrint("bool: silent"); + break; + case false: + DetailPrint("bool: not silent"); + break; + } + + // --- Subject is an expression, evaluated once --- + switch ($R0 + 1) + { + case 2: + DetailPrint("expr: subject folded to 2"); + break; + default: + DetailPrint("expr: default"); + break; + } + + // --- A switch inside a loop, with break bound to the switch --- + for ($i = 0; $i < 3; $i++) + { + switch ($i) + { + case 0: + DetailPrint("loop switch: zero"); + break; + case 1: + DetailPrint("loop switch: one"); + break; + default: + DetailPrint("loop switch: default"); + break; + } + } + + /* + * Deliberately absent: any loop or nested switch inside a case. The first one + * that appears leaves every later break in the enclosing switch rejected with + * "The break statement cannot be used here", while the assembler separately + * insists the switch end with a break - so no arrangement of the two + * assembles. Put the inner construct in a function and call it instead. + * See KNOWN-GAPS.md. + */ + + // Several statements per case, and an empty case body. + $R2 = 3; + switch ($R2) + { + case 3: + $R3 = 1; + $R3 += 1; + DetailPrint("multi: ".$R3); + break; + case 4: + break; + } +} diff --git a/e2e/06-functions.nsl b/e2e/06-functions.nsl new file mode 100644 index 0000000..507fa95 --- /dev/null +++ b/e2e/06-functions.nsl @@ -0,0 +1,156 @@ +/* + * Functions: parameters, single and multiple return values, overloading, + * recursion, and the installer/uninstaller namespace split. + * + * The last of those is the reason this file exists in the shape it does. NSIS + * keeps uninstaller functions under a "un." prefix and rejects an unprefixed + * Call from uninstaller code, so a call written identically in the two contexts + * has to assemble to two different names. guard.nsl depends on that working. + */ + +#include "guard.nsl" + +Name("06 functions"); +OutFile("06-functions.exe"); + +// --- Installer functions --- + +function NoParamsNoReturn() +{ + DetailPrint("function: no params, no return"); +} + +function OneParam($param) +{ + DetailPrint("function: one param = ".$param); +} + +function TwoParams($a, $b) +{ + DetailPrint("function: two params = ".$a." ".$b); +} + +function ReturnsOne($n) +{ + return $n * 2; +} + +function ReturnsThree() +{ + return (1, 2, 3); +} + +// Overloaded on parameter count. +function Overloaded($a) +{ + return $a; +} + +function Overloaded($a, $b) +{ + return $a + $b; +} + +// Overloaded on return count: same name, same arity, two returns. +function Overloaded($a, $b, $c) +{ + return $a + $b + $c; +} + +function Overloaded($a, $b, $c, $d) +{ + return ($a + $b, $c + $d); +} + +// Recursion. Guarded by a depth parameter so it terminates. +function Countdown($n) +{ + if ($n <= 0) + return 0; + DetailPrint("countdown: ".$n); + return Countdown($n - 1); +} + +// An early return. "return" always needs a value - a bare one is a parse error +// even in a function that returns nothing. See KNOWN-GAPS.md. +function EarlyReturn($n) +{ + if ($n < 0) + return 0; + DetailPrint("early return: reached the end with ".$n); + return 1; +} + +// --- Uninstaller functions, same names, separate namespace --- + +uninstall function NoParamsNoReturn() +{ + DetailPrint("un.function: no params, no return"); +} + +uninstall function ReturnsOne($n) +{ + return $n * 3; +} + +section Test("functions") +{ + NoParamsNoReturn(); + OneParam("x"); + TwoParams("x", "y"); + + $R0 = ReturnsOne(21); + DetailPrint("ReturnsOne = ".$R0); + + ($R1, $R2, $R3) = ReturnsThree(); + DetailPrint("ReturnsThree = ".$R1." ".$R2." ".$R3); + + // Overload resolution by arity ... + $R0 = Overloaded(1); + $R1 = Overloaded(1, 2); + $R2 = Overloaded(1, 2, 3); + DetailPrint("overloads = ".$R0." ".$R1." ".$R2); + + // ... and by return count. + ($R0, $R1) = Overloaded(1, 2, 3, 4); + DetailPrint("overload by returns = ".$R0." ".$R1); + + /* + * The return value is taken even though nothing needs it: calling a function + * that returns a value as a bare statement throws a NullPointerException out + * of the assembler when the function also takes parameters. See KNOWN-GAPS.md. + */ + $R0 = Countdown(3); + $R0 = EarlyReturn(1); + $R0 = EarlyReturn(-1); + + // A call in expression position, which only single-return functions allow. + $R0 = ReturnsOne(2) + ReturnsOne(3); + DetailPrint("calls in an expression = ".$R0); + + // A call as an argument to an instruction. + DetailPrint("call as an argument = ".ReturnsOne(5)); + + // A call as a condition. + if (ReturnsOne(1) == 2) + DetailPrint("call as a condition"); + + /* + * Indirect calls are not covered: GetFunctionAddress and Call both have + * wrapper classes but neither is wired into Statement.matchInstruction(), so + * a script using them fails with "Function ... not found". See KNOWN-GAPS.md. + */ +} + +uninstall section Uninstall("functions") +{ + /* + * Written exactly as in the installer section above, and it has to assemble + * to "Call un.NoParamsNoReturn". If it ever emits the unprefixed name again, + * makensis rejects the script outright - which is the point. + */ + NoParamsNoReturn(); + + $R0 = ReturnsOne(21); + DetailPrint("un.ReturnsOne = ".$R0); +} diff --git a/e2e/07-sections.nsl b/e2e/07-sections.nsl new file mode 100644 index 0000000..066f117 --- /dev/null +++ b/e2e/07-sections.nsl @@ -0,0 +1,85 @@ +/* + * Sections and section groups, across every argument the section header takes. + * + * The arguments are positional and all optional, which is exactly the shape + * that hides an off-by-one: readOnly, optional and bold are booleans in that + * order, and any trailing integers are InstTypes. + */ + +#include "guard.nsl" + +Name("07 sections"); +OutFile("07-sections.exe"); + +InstType("Full"); +InstType("Minimal"); + +section Plain() +{ + DetailPrint("section: no arguments at all"); +} + +section Described("A described section") +{ + DetailPrint("section: description only"); +} + +section ReadOnly("Read only", true) +{ + DetailPrint("section: read only"); +} + +section Optional("Unchecked by default", false, true) +{ + DetailPrint("section: optional"); +} + +section Bold("Bold on the components page", false, false, true) +{ + DetailPrint("section: bold"); +} + +section InInstTypes("In both install types", false, false, false, 1, 2) +{ + DetailPrint("section: instTypes 1 and 2"); +} + +// The booleans can be skipped entirely before the InstType numbers. +section InstTypesOnly("InstTypes without the flags", 1) +{ + DetailPrint("section: instType 1"); +} + +// SectionIn does the same job from inside the body; true means read only. +section WithSectionIn("Uses SectionIn") +{ + SectionIn(1, 2); + DetailPrint("section: SectionIn"); +} + +// A hidden section: an empty description means NSIS does not list it. +section Hidden("") +{ + DetailPrint("section: hidden"); +} + +// AddSize adjusts the reported install size from inside a section. +section WithAddSize("Reports extra size") +{ + AddSize(100); + DetailPrint("section: AddSize"); +} + +/* + * Section groups are not covered. SectionGroupStatement parses its body with a + * BlockStatement, which refuses to run in global context - so every spelling of + * "sectiongroup" fails with "code block can only be used in a function or + * section context" and the keyword is unusable. See KNOWN-GAPS.md. + */ + +// --- Uninstaller side --- + +uninstall section Uninstall("Uninstall") +{ + DetailPrint("un.section: plain"); +} diff --git a/e2e/08-pages.nsl b/e2e/08-pages.nsl new file mode 100644 index 0000000..9b95b7e --- /dev/null +++ b/e2e/08-pages.nsl @@ -0,0 +1,87 @@ +/* + * Pages, in both forms: the declaration ending in a semicolon, and the PageEx + * form with a body. + * + * Page order is significant to NSIS and the callbacks named here have to exist, + * so this file is as much about the interaction between page declarations and + * function definitions as about the page syntax itself. + */ + +#include "guard.nsl" + +Name("08 pages"); +OutFile("08-pages.exe"); +LicenseData("fixtures/licence.txt"); + +// --- Callbacks referenced by the declarations below --- + +function LicensePre() +{ + DetailPrint("page: license pre"); +} + +function ComponentsShow() +{ + DetailPrint("page: components show"); +} + +function DirectoryLeave() +{ + DetailPrint("page: directory leave"); +} + +function CustomCreate() +{ + DetailPrint("page: custom create"); +} + +function CustomLeave() +{ + DetailPrint("page: custom leave"); +} + +uninstall function UninstConfirmPre() +{ + DetailPrint("un.page: confirm pre"); +} + +// --- Installer pages --- + +// A pre function. (The no-argument form is page InstFiles() below.) +page License("LicensePre"); + +// pre, show and leave functions, then the cancel flag. +page Components("", "ComponentsShow"); +page Directory("", "", "DirectoryLeave"); + +// A custom page: create function, leave function, caption. +page Custom("CustomCreate", "CustomLeave", "A custom page"); + +page InstFiles(); + +// The PageEx form: a body instead of a semicolon. +page Directory("") +{ + DirVar($INSTDIR); + DirText("PageEx form with a body"); +} + +// --- Uninstaller pages --- + +/* + * The callback name is passed through verbatim, so an uninstaller page has to + * name the emitted function - "un." prefix and all - rather than the name the + * uninstall function was declared with. See KNOWN-GAPS.md. + */ +uninstall page UninstConfirm("un.UninstConfirmPre"); +uninstall page InstFiles(); + +section Test("pages") +{ + DetailPrint("section body"); +} + +uninstall section Uninstall("Uninstall") +{ + DetailPrint("un.section body"); +} diff --git a/e2e/09-globals-and-scope.nsl b/e2e/09-globals-and-scope.nsl new file mode 100644 index 0000000..12bea64 --- /dev/null +++ b/e2e/09-globals-and-scope.nsl @@ -0,0 +1,90 @@ +/* + * Global variables and variable scope. + * + * An assignment made outside any function or section is a global initialiser. + * The assembler collects those and emits them at the top of .onInit - or of + * un.onInit for the uninstaller's own list - which is why this file is worth + * having alongside guard.nsl: the guard defines both callbacks, so the + * initialisers have to be threaded into a body that already exists rather than + * into a synthesised one. + */ + +#include "guard.nsl" + +Name("09 globals and scope"); +OutFile("09-globals-and-scope.exe"); + +// Installer globals. These land at the top of .onInit, ahead of the guard call. +$globalString = "set before anything runs"; +$globalInt = 40 + 2; +$globalFromConstant = $WINDIR; +$globalComputed = $globalInt * 2; + +// The uninstaller keeps its own list; these land in un.onInit. +uninstall +{ + $uninstallGlobal = "uninstaller side"; + $uninstallInt = 7; +} + +function ReadsAGlobal() +{ + // Globals are visible everywhere without being redeclared. + DetailPrint("function sees: ".$globalString); + return $globalInt; +} + +section Test("globals and scope") +{ + DetailPrint("global string = ".$globalString); + DetailPrint("global int = ".$globalInt); + DetailPrint("global constant = ".$globalFromConstant); + DetailPrint("global computed = ".$globalComputed); + + $R0 = ReadsAGlobal(); + DetailPrint("from a function = ".$R0); + + // A global can be reassigned at run time like any other variable. + $globalInt += 1; + DetailPrint("global int = ".$globalInt); + + /* + * Scope. A block introduces a new one, and a name first assigned inside a + * block does not survive it - using $inner after the closing brace is an + * assemble-time error, not a run-time surprise. + */ + { + $inner = "inner"; + DetailPrint("block: ".$inner); + + { + $deeper = "deeper"; + DetailPrint("block: ".$inner." then ".$deeper); + } + } + + // The pool registers behave the same way, so this is a fresh assignment. + { + $R1 = "block local pool register"; + DetailPrint($R1); + } + + // Loop and function bodies are scopes too. + for ($i = 0; $i < 2; $i++) + { + $loopLocal = "iteration ".$i; + DetailPrint($loopLocal); + } + + if (true) + { + $ifLocal = "inside an if"; + DetailPrint($ifLocal); + } +} + +uninstall section Uninstall("Uninstall") +{ + DetailPrint("un global string = ".$uninstallGlobal); + DetailPrint("un global int = ".$uninstallInt); +} diff --git a/e2e/10-defines.nsl b/e2e/10-defines.nsl new file mode 100644 index 0000000..8960f5c --- /dev/null +++ b/e2e/10-defines.nsl @@ -0,0 +1,81 @@ +/* + * #define, #redefine, #undef and defined(). + * + * The distinction that matters is when the value is evaluated. A plain #define + * evaluates at definition, so the constant holds a result. A backquoted one + * evaluates at substitution, so the constant holds a fragment of source that + * need not be a complete expression - and may refer to names that do not exist + * yet. + */ + +#include "guard.nsl" + +Name("10 defines"); +OutFile("10-defines.exe"); + +// --- Evaluated at definition --- + +#define AnInt 60 +#define AnExpression 9 * 5 + AnInt +#define AString "a string" +#define ABoolean true + +section Test("defines") +{ + DetailPrint("AnInt = ".AnInt); + DetailPrint("AnExpression = ".AnExpression); + DetailPrint("AString = ".AString); + DetailPrint("ABoolean = ".ABoolean); + + // --- Evaluated at substitution --- + + // A complete expression, deferred. + #define Deferred `AnInt + 5` + DetailPrint("Deferred = ".Deferred); + + // Fragments that are not expressions on their own, pasted together. + #define Head `99 +` + #define Middle `(55 -` + #define Tail `2) * 3` + DetailPrint("Fragments = ".(Head Middle Tail)); + + // An operator, a function name and its argument, each held in a constant. + #define Left 5 + #define Op `>` + #define Right 6 + #define Call `DetailPrint` + #define Arg "constants can hold anything" + if (Left Op Right) + DetailPrint("unreachable"); + else + Call(Arg); + + // Building a value by concatenation. + #define PartOne 'Hello, ' + #define PartTwo 'world' + #define Joined `"`.PartOne.PartTwo.`"` + DetailPrint(Joined); + + // A deferred value may name a constant defined only later. + #define UsesLater `LaterConstant + 1` + #define LaterConstant 10 + DetailPrint("UsesLater = ".UsesLater); + + // --- defined(), #redefine and #undef --- + + DetailPrint("defined(AnInt) = ".defined(AnInt)); + DetailPrint("defined(AnInt, AString) = ".defined(AnInt, AString)); + DetailPrint("defined(NeverDefined) = ".defined(NeverDefined)); + DetailPrint("defined(AnInt, NotDefined) = ".defined(AnInt, NotDefined)); + + // #define on an existing name is an error; #redefine is how you change one. + #redefine AnInt 61 + DetailPrint("redefined AnInt = ".AnInt); + + #undef AnInt + DetailPrint("after #undef = ".defined(AnInt)); + + // And it can be defined again once undefined. + #define AnInt 62 + DetailPrint("defined again = ".AnInt); +} diff --git a/e2e/11-conditionals.nsl b/e2e/11-conditionals.nsl new file mode 100644 index 0000000..727b2cf --- /dev/null +++ b/e2e/11-conditionals.nsl @@ -0,0 +1,107 @@ +/* + * #if / #elseif / #else / #endif. + * + * These decide what is assembled at all, so the check that matters is not only + * that the taken branch is right but that the untaken ones leave nothing + * behind - no stray label, no declared-but-unused variable. + */ + +#include "guard.nsl" + +Name("11 conditionals"); +OutFile("11-conditionals.exe"); + +#define Version 3 +#define Feature true + +// At global scope, choosing between whole declarations. +#if Version >= 3 + #define VersionName "three or later" +#else + #define VersionName "older" +#endif + +#if Feature +function FeatureFunction() +{ + DetailPrint("conditional: the function that got assembled"); +} +#else +function FeatureFunction() +{ + DetailPrint("conditional: the function that did not"); +} +#endif + +section Test("conditionals") +{ + DetailPrint("VersionName = ".VersionName); + FeatureFunction(); + + // Simple two-way. + #if Version == 3 + DetailPrint("if: version is 3"); + #else + DetailPrint("if: version is not 3"); + #endif + + // Full chain, with the match in the middle. + #if Version == 1 + DetailPrint("chain: one"); + #elseif Version == 2 + DetailPrint("chain: two"); + #elseif Version == 3 + DetailPrint("chain: three"); + #elseif Version == 4 + DetailPrint("chain: four"); + #else + DetailPrint("chain: none"); + #endif + + // No matching arm and no else: nothing is emitted at all. + #if Version == 99 + DetailPrint("never emitted"); + #endif + + // Conditions built from defined() and from boolean constants. + #if defined(Version) + DetailPrint("defined: Version is defined"); + #endif + + #if !defined(NeverDefined) + DetailPrint("defined: NeverDefined is not"); + #endif + + #if Feature && Version > 2 + DetailPrint("compound: both held"); + #endif + + // Nested. + #if Version >= 2 + #if Feature + #if Version < 10 + DetailPrint("nested: three deep"); + #else + DetailPrint("nested: not this"); + #endif + #endif + #endif + + /* + * #if wraps whole statements only - it is matched where a statement is + * expected, so it cannot appear part way through one. The ternary operator is + * the in-line equivalent, and folds the same way when its condition is known + * at assemble time. + */ + DetailPrint("partial: ".(Feature ? "feature on" : "feature off")); + + // Interaction with run-time control flow: the branch is chosen while + // assembling, and what is left is an ordinary if. + $R0 = 5; + if ($R0 > 0) + { + #if Version >= 3 + DetailPrint("mixed: run-time if, assemble-time body"); + #endif + } +} diff --git a/e2e/12-macros.nsl b/e2e/12-macros.nsl new file mode 100644 index 0000000..bda9684 --- /dev/null +++ b/e2e/12-macros.nsl @@ -0,0 +1,131 @@ +/* + * #macro: parameters, return values, overloading, recursion, and the Returns + * constant. + * + * Macros are inserted rather than called, so everything here happens while + * assembling. Recursion combined with #if is how nsL expresses an + * assemble-time loop, and eval() is how a macro builds nsL source to insert. + */ + +#include "guard.nsl" + +Name("12 macros"); +OutFile("12-macros.exe"); + +section Test("macros") +{ + // --- No parameters, no return value --- + #macro Simple() + DetailPrint("macro: simple"); + #macroend + + Simple(); + + // --- Parameters --- + #macro WithArgs(First, Second) + DetailPrint("macro: args ".First." and ".Second); + // The same thing through format(), which substitutes the macro arguments at + // assemble time just as concatenation does. + DetailPrint(format("macro: args {0} and {1}", First, Second)); + #macroend + + WithArgs("one", 2); + + // --- A single return value, usable in an expression --- + #macro ReturnsOne() + #return 41 + 1 + #macroend + + $R0 = ReturnsOne(); + DetailPrint("macro: returns one = ".$R0); + DetailPrint("macro: in an expression = ".(ReturnsOne() + 1)); + + // --- Several return values --- + #macro ReturnsThree() + #return (1, 2, 3) + #macroend + + ($R0, $R1, $R2) = ReturnsThree(); + DetailPrint("macro: returns three = ".$R0." ".$R1." ".$R2); + + // --- Overloaded on parameter count --- + #macro Overloaded(A) + #return A + #macroend + + #macro Overloaded(A, B) + #return A + B + #macroend + + DetailPrint("macro: overloads = ".Overloaded(1)." ".Overloaded(1, 2)); + + // --- A return value that is itself a jump, used as a condition --- + #macro IsSilent() + #return Silent() == true + #macroend + + if (IsSilent()) + DetailPrint("macro: silent"); + else + DetailPrint("macro: not silent"); + + // --- Recursion as an assemble-time loop --- + #macro Repeat(From, To, Insert, Arg) + #if (From <= To) + eval(Insert.'(From, Arg)'); + Repeat(From + 1, To, Insert, Arg); + #endif + #macroend + + #macro Body(Count, Arg) + DetailPrint('macro: iteration '.Count.' of '.Arg); + #macroend + + Repeat(1, 4, 'Body', 'four'); + + /* + * --- Returns --- + * + * Returns holds how many values the current insertion is being asked for, so + * one macro can serve every arity. ZeroAll builds a return list of the right + * length by recursing on it. + */ + #macro ZeroAll() + #return (eval(BuildZeroes(Returns))) + #macroend + + #macro BuildZeroes(Count) + #if (Count > 1) + #return '0, '.BuildZeroes(Count - 1) + #else + #return '0' + #endif + #macroend + + $R0 = ZeroAll(); + ($R1, $R2, $R3) = ZeroAll(); + DetailPrint("macro: zeroed = ".$R0." ".$R1." ".$R2." ".$R3); + + // --- returnvar() inside a macro --- + // returnvar(1) has to be the whole argument; concatenating it into a larger + // expression reports "no return registers are being used". + #macro EchoTarget() + DetailPrint(returnvar(1)); + #return returnvar(1) + #macroend + + $R4 = 9; + $R4 = EchoTarget(); + DetailPrint("macro: target unchanged = ".$R4); + + // --- #error, guarding an insertion that asks for the wrong arity --- + #macro MustReturnOne() + #if (Returns != 1) + #error "MustReturnOne returns exactly one value" + #endif + #return true + #macroend + + $R0 = MustReturnOne(); + DetailPrint("macro: arity guard = ".$R0); +} diff --git a/e2e/13-include.nsl b/e2e/13-include.nsl new file mode 100644 index 0000000..cf74023 --- /dev/null +++ b/e2e/13-include.nsl @@ -0,0 +1,35 @@ +/* + * #include, and the path rule that governs it. + * + * IncludeDirective opens the file with new FileReader(path), so every include + * path is relative to the assembler's working directory - not to the file doing + * the including. A nested include therefore has to be written from the point of + * view of the run, which is why fixtures/includes-another.nsl says + * "fixtures/included.nsl" rather than "included.nsl". + * + * guard.nsl is itself an include, so every other file in the corpus exercises + * the simple case; this one covers nesting and what crosses the boundary. + */ + +#include "guard.nsl" +#include "fixtures/includes-another.nsl" + +Name("13 include"); +OutFile("13-include.exe"); + +section Test("include") +{ + // A constant defined in the directly included file. + DetailPrint("nested constant = ".NestedConstant); + + // ... and one from the file that file included in turn. + DetailPrint("deeper constant = ".IncludedConstant); + + // A global initialiser from an included file, which lands in .onInit like any + // other. + DetailPrint("included global = ".$includedGlobal); + + // A function defined in an included file. + $R0 = IncludedFunction(41); + DetailPrint("included function = ".$R0); +} diff --git a/e2e/14-inline-nsis.nsl b/e2e/14-inline-nsis.nsl new file mode 100644 index 0000000..0fdb95d --- /dev/null +++ b/e2e/14-inline-nsis.nsl @@ -0,0 +1,77 @@ +/* + * #nsis / #nsisend - raw NSIS passed straight through. + * + * The block is written out verbatim, which means it bypasses everything the + * assembler otherwise guarantees: it does not go through RegisterList, so + * anything it clobbers is invisible to the register allocator, and it does not + * go through scope checking. That makes it the escape hatch for NSIS features + * nsL has no syntax for - !include, !insertmacro, ${...} macros. + * + * Inside a #macro the block gets the macro's parameters as ${Name} and its + * return registers as ${ReturnVarN}, which is how an instruction wrapper can be + * written in nsL itself. + */ + +#include "guard.nsl" + +Name("14 inline nsis"); +OutFile("14-inline-nsis.exe"); + +// At global scope: directives nsL has no equivalent for. +#nsis + !define InlineDefine "defined by raw NSIS" + !include "LogicLib.nsh" +#nsisend + +section Test("inline nsis") +{ + // A plain block in a section body, emitted exactly as written. + #nsis + DetailPrint "inline: a raw NSIS line" + DetailPrint "inline: ${InlineDefine}" + #nsisend + + /* + * Raw NSIS and nsL interleaved, sharing registers. $R1 has to be given a + * value in nsL before the block writes to it: scope checking cannot see + * inside a #nsis block, so a register only ever assigned in there is still + * "may not have been initialised" as far as the assembler is concerned. + */ + $R0 = "set by nsL"; + $R1 = ""; + #nsis + DetailPrint "inline: reads $R0 = $R0" + StrCpy $R1 "set by raw NSIS" + #nsisend + DetailPrint("nsL reads back: ".$R1); + + // Something with no nsL syntax at all: a LogicLib block. + #nsis + ${If} $R0 != "" + DetailPrint "inline: LogicLib says $R0 is set" + ${EndIf} + #nsisend + + /* + * Inside a macro, the parameters are available as NSIS defines and the return + * registers as ${ReturnVarN}. This is the documented way to wrap an + * instruction the assembler does not know - ReadINIStr here, which it does + * know, so the two can be compared. + */ + #macro RawReadINIStr(IniFile, SectionName, ValueName) + #if (Returns != 1) + #error "RawReadINIStr returns exactly one value" + #endif + #nsis + ReadINIStr ${ReturnVar1} "${IniFile}" "${SectionName}" "${ValueName}" + #nsisend + #return 1 + #macroend + + $R2 = RawReadINIStr("fixtures/sample.ini", "Section", "Value"); + DetailPrint("macro-wrapped ReadINIStr = ".$R2); + + // The built-in wrapper, for the same call. + $R3 = ReadINIStr("fixtures/sample.ini", "Section", "Value"); + DetailPrint("built-in ReadINIStr = ".$R3); +} diff --git a/e2e/15-assembler-functions.nsl b/e2e/15-assembler-functions.nsl new file mode 100644 index 0000000..6d7fd13 --- /dev/null +++ b/e2e/15-assembler-functions.nsl @@ -0,0 +1,101 @@ +/* + * The special assemble-time functions: returnvar, toint, eval, defined, type, + * format, length, nsisconst. + * + * None of them emit an instruction. Each one either folds into a literal or + * changes what gets parsed, so the check is what ends up in the .nsi rather + * than what happens at run time. + */ + +#include "guard.nsl" + +Name("15 assembler functions"); +OutFile("15-assembler-functions.exe"); + +section Test("assembler functions") +{ + /* + * --- toint(): every accepted argument shape --- + * + * The documented hexadecimal string form is absent, because it does not work + * in either spelling: toint("0xFF") reaches Integer.parseInt("0xFF", 16), + * which rejects the prefix, and toint("FF") is parsed as decimal. Both warn + * and return 0. See KNOWN-GAPS.md. + * + * The optional second argument is the value to fall back to when the + * conversion fails; it is undocumented but works. + */ + DetailPrint("toint decimal string = ".toint("255")); + DetailPrint("toint with a default = ".toint("not a number", -1)); + DetailPrint("toint true = ".toint(true)); + DetailPrint("toint false = ".toint(false)); + DetailPrint("toint integer = ".toint(42)); + DetailPrint("toint $INSTDIR = ".toint($INSTDIR)); + + // A register argument has to be in scope first, even though toint never + // reads it at run time - it converts the register's index, not its value. + $0 = 0; + $9 = 0; + $R0 = 0; + DetailPrint("toint $0 = ".toint($0)); + DetailPrint("toint $9 = ".toint($9)); + DetailPrint("toint $R0 = ".toint($R0)); + + // --- type(): the five categories --- + DetailPrint("type integer = ".type(42)); + DetailPrint("type string = ".type("s")); + DetailPrint("type boolean = ".type(true)); + DetailPrint("type register = ".type($R0)); + DetailPrint("type constant = ".type($WINDIR)); + + // --- length(): characters in a literal, without StrLen at run time --- + DetailPrint("length empty = ".length("")); + DetailPrint("length six = ".length("abcdef")); + // length() measures the string after escape translation into NSIS form, + // where a tab is the three characters "$\t" - so this is 5, not 3. + DetailPrint("length escape = ".length("a\tb")); + + // --- defined() --- + #define Present 1 + DetailPrint("defined one = ".defined(Present)); + DetailPrint("defined several = ".defined(Present, Present)); + DetailPrint("defined missing = ".defined(Absent)); + DetailPrint("defined one of two = ".defined(Present, Absent)); + + // --- format() --- + DetailPrint(format("format: x{0}x{1}x", "first", "second")); + // Substituted text is not itself rescanned, so a brace inside an argument + // stays literal rather than being read as another placeholder. + DetailPrint(format("format: {0} {1}", "{0}", "not substituted twice")); + + // --- nsisconst(): emit ${name} for something NSIS defines and nsL does not + #nsis + !define CorpusDefine "defined in raw NSIS" + #nsisend + DetailPrint("nsisconst = ".nsisconst(CorpusDefine)); + + // --- eval(): parse a string as nsL source --- + eval("DetailPrint('eval: a whole statement');"); + DetailPrint("eval: an expression = ".eval("1 + 1")); + + // Built up by concatenation, which is the point of it. + #define Fn "DetailPrint" + #define Msg "'eval: built by concatenation'" + eval(Fn."(".Msg.");"); + + // --- returnvar(): the nth register being assigned to --- + #macro FirstTarget() + // Assigning a register to itself emits nothing at all. + #return returnvar(1) + #macroend + + #macro BothTargets() + #return (returnvar(1), returnvar(2)) + #macroend + + $R0 = 1; + $R1 = 2; + $R0 = FirstTarget(); + ($R0, $R1) = BothTargets(); + DetailPrint("returnvar left them alone: ".$R0." ".$R1); +} diff --git a/e2e/16-attributes.nsl b/e2e/16-attributes.nsl new file mode 100644 index 0000000..8406d89 --- /dev/null +++ b/e2e/16-attributes.nsl @@ -0,0 +1,106 @@ +/* + * Installer attributes: the instructions that are only legal at global scope. + * + * These configure the installer rather than doing anything at run time, and + * most take a fixed set of literal keywords. Getting one of those keywords + * wrong produces a script that assembles and then fails to compile, which is + * exactly the gap between a textual diff and this suite. + */ + +#include "guard.nsl" + +// Unicode has to come before anything that touches the header or the compressed +// data, so it goes first. +Unicode(true); + +Name("16 attributes"); +Caption("16 attributes - caption"); +SubCaption(0, "custom page caption"); +BrandingText("corpus build"); +OutFile("16-attributes.exe"); + +// Install directory, and where to read a previous one back from. +InstallDir("$PROGRAMFILES\\nsL-corpus"); +InstallDirRegKey("HKLM", "Software\\nsL-corpus", "InstallDir"); + +// Compression. +SetCompressor("lzma", true); +SetCompressorDictSize(8); +SetCompress("auto"); +SetDatablockOptimize("on"); + +// Behaviour and appearance. +XPStyle("on"); +ShowInstDetails("show"); +ShowUninstDetails("hide"); +AutoCloseWindow(false); +CRCCheck("on"); +SetDateSave("on"); +SetOverwrite("on"); +AllowRootDirInstall(false); +AllowSkipFiles("on"); +InstProgressFlags("smooth"); +LicenseForceSelection("off"); +SpaceTexts("auto"); + +// Text on the standard pages. +ComponentText("Choose components", "Top text", "Sub text"); +InstallButtonText("Go"); +DetailsButtonText("Details"); +CompletedText("All done"); +MiscButtonText("Back", "Next", "Cancel", "Close"); +// UninstallIcon is not covered here: it needs a real .ico laid out as NSIS +// expects. See KNOWN-GAPS.md. +UninstallButtonText("Remove"); +UninstallCaption("16 attributes - uninstall"); +UninstallSubCaption(0, "custom uninstall page caption"); +UninstallText("This removes the corpus build."); +FileErrorText("Could not write $0"); + +// Colours and fonts. +InstallColors("000000", "FFFFFF"); +LicenseBkColor("FFFFFF"); +// Both forms of SetFont: the optional third argument is a language id, which +// NSIS wants as a leading /LANG= switch rather than a trailing operand. +SetFont("Tahoma", 8); +SetFont("Tahoma", 8, 1033); + +// Version information. VIProductVersion has to come first, and the four keys +// NSIS calls standard have to be present or it warns about each missing one. +VIProductVersion("1.0.0.0"); +VIAddVersionKey("ProductName", "nsL corpus"); +VIAddVersionKey("FileVersion", "1.0.0.0"); +VIAddVersionKey("FileDescription", "attributes corpus build"); +VIAddVersionKey("LegalCopyright", "none"); +VIAddVersionKey("Comments", "language-tagged key", 1033); + +// Install types, referenced by the sections below. +InstType("Full"); +InstType("Minimal"); + +// Language strings. +LangString("CorpusString", 1033, "a language string"); + +page Components(); +// A PageEx block, which is where the directory-page attributes belong: NSIS +// rejects DirVerify and DirText outside one even though nsL accepts them at +// global scope. See KNOWN-GAPS.md. +page Directory() +{ + DirVerify("auto"); + DirText("Choose a directory", "Sub text", "Browse", "No space text"); +} +page InstFiles(); +uninstall page UninstConfirm(); +uninstall page InstFiles(); + +section Test("attributes", false, false, false, 1, 2) +{ + AddSize(64); + DetailPrint("attributes: section body"); +} + +uninstall section Uninstall("Uninstall") +{ + DetailPrint("attributes: uninstall body"); +} diff --git a/e2e/17-inst-void.nsl b/e2e/17-inst-void.nsl new file mode 100644 index 0000000..2a309ec --- /dev/null +++ b/e2e/17-inst-void.nsl @@ -0,0 +1,79 @@ +/* + * Instructions that take arguments and return nothing. + * + * Grouped by shape rather than by subject, because the bugs these catch are + * about arity and operand order, and those do not respect subject boundaries. + * The registry, filesystem and UI families get their own files. + */ + +#include "guard.nsl" + +Name("17 inst void"); +OutFile("17-inst-void.exe"); + +section Test("void instructions") +{ + // Detail window. + DetailPrint("void: DetailPrint"); + SetDetailsPrint("both"); + SetDetailsView("show"); + SetAutoClose("false"); + + // The error flag. + ClearErrors(); + SetErrors(); + ClearErrors(); + + // Error level returned by the installer process. + SetErrorLevel(0); + + // Reboot flag. + SetRebootFlag(false); + + // Silent flag. + SetSilent("normal"); + + // Which of $SMPROGRAMS, $DESKTOP etc. resolve per-user or per-machine. + SetShellVarContext("current"); + SetShellVarContext("all"); + SetShellVarContext("current"); + + // Registry view on 64-bit Windows. + SetRegView("32"); + SetRegView("64"); + SetRegView("default"); + + // Overwrite policy for File(), scoped to the rest of the section. + SetOverwrite("try"); + SetOverwrite("on"); + + // Compression, which is legal inside a section as well as globally. + SetCompress("off"); + SetCompress("auto"); + + // The plugins directory, needed before any plugin call. + InitPluginsDir(); + + // The stack. + Push("pushed"); + $R0 = Pop(); + DetailPrint("void: popped ".$R0); + + // Install type selection. + SetCurInstType(0); + + /* + * Not covered: LogSet and LogText. Both need an NSIS built with + * NSIS_CONFIG_LOG, which the stock build is not, and makensis rejects the + * script outright rather than warning - so covering them would mean the whole + * corpus only runs against a special build. + */ + + /* + * Not covered: Sleep, Exch, ChangeUI, Call, GetCurrentAddress, + * GetFunctionAddress and GetLabelAddress. All seven have wrapper classes in + * nsl/instruction/ that are never referenced from + * Statement.matchInstruction(), so using any of them fails with + * "Function ... not found". See KNOWN-GAPS.md. + */ +} diff --git a/e2e/18-inst-returns.nsl b/e2e/18-inst-returns.nsl new file mode 100644 index 0000000..446e053 --- /dev/null +++ b/e2e/18-inst-returns.nsl @@ -0,0 +1,86 @@ +/* + * Instructions that produce a value, which nsL spells as an assignment rather + * than as an output-variable argument. + * + * Two shapes: one return value, usable anywhere an expression is, and several + * return values, which need the parenthesised assignment form and cannot appear + * inside a larger expression. + */ + +#include "guard.nsl" + +Name("18 inst returns"); +OutFile("18-inst-returns.exe"); + +section Test("returning instructions") +{ + // --- One return value --- + + // Environment and paths. + $R0 = ReadEnvStr("PATH"); + $R1 = ExpandEnvStrings("%PATH%"); + $R2 = GetTempFileName(); + $R3 = GetTempFileName($TEMP); + $R4 = SearchPath("notepad.exe"); + + // Registry. + $R5 = ReadRegStr("HKLM", "Software\\Microsoft\\Windows\\CurrentVersion", "ProgramFilesDir"); + $R6 = ReadRegDWORD("HKLM", "Software\\nsL-corpus", "ADword"); + $R7 = EnumRegKey("HKLM", "Software", 0); + $R8 = EnumRegValue("HKLM", "Software\\nsL-corpus", 0); + + // INI files. + $R9 = ReadINIStr("fixtures/sample.ini", "Section", "Value"); + + // Installer state. + $0 = GetErrorLevel(); + $1 = GetCurInstType(); + $2 = GetInstDirError(); + $3 = GetRegView(); + $4 = GetShellVarContext(); + $5 = GetWinVer("Major"); + $6 = InstTypeGetText(0); + + // Formatting and copying, which are expressions in nsL but instructions in + // NSIS. + $7 = IntFmt("%08X", 255); + $8 = StrCpy($R0, 5); + $9 = StrCpy($R0, 5, 2); + + DetailPrint("returns: ".$0." ".$1." ".$2." ".$3." ".$4); + DetailPrint("returns: ".$5." ".$6." ".$7." ".$8." ".$9); + + // Windows and controls. + $R0 = FindWindow("#32770", ""); + $R1 = GetDlgItem($HWNDPARENT, 1); + $R2 = CreateFont("Tahoma", 8); + + // A return value used directly in an expression, without a variable. + if (GetErrorLevel() == 0) + DetailPrint("returns: in a condition"); + DetailPrint("returns: in an argument ".GetErrorLevel()); + + // --- Several return values --- + + // Two: the high and low halves of a version number. The run-time form, not + // GetDLLVersionLocal - that one reads the file while compiling and needs a + // real DLL, which the corpus does not ship. + ($R3, $R4) = GetDLLVersion($SYSDIR."\\kernel32.dll"); + + // Two: a file time, split the same way. + ($R5, $R6) = GetFileTimeLocal("fixtures/sample.txt"); + + DetailPrint("multi: ".$R3." ".$R4." ".$R5." ".$R6); + + // Section state, read back from the section defined below. + $R7 = SectionGetText(0); + $R8 = SectionGetFlags(0); + $R9 = SectionGetSize(0); + $0 = SectionGetInstTypes(0); + DetailPrint("section state: ".$R7." ".$R8." ".$R9." ".$0); +} + +section Other("another section") +{ + DetailPrint("returns: other section"); +} diff --git a/e2e/19-inst-boolean.nsl b/e2e/19-inst-boolean.nsl new file mode 100644 index 0000000..7c5baf5 --- /dev/null +++ b/e2e/19-inst-boolean.nsl @@ -0,0 +1,110 @@ +/* + * Instructions whose value is a Boolean. + * + * In NSIS these are branch instructions - IfErrors, IfFileExists and friends, + * each taking a pair of labels. nsL turns them into expressions, so the same + * instruction has to assemble one way inside an if condition and another way + * when its result is stored in a variable. Both forms are covered here for each + * one, plus their use inside compound conditions where the assembler has to + * chain the jumps together. + */ + +#include "guard.nsl" + +Name("19 inst boolean"); +OutFile("19-inst-boolean.exe"); + +section Test("boolean instructions") +{ + // --- As a condition --- + + ClearErrors(); + if (Errors()) + DetailPrint("bool: Errors"); + else + DetailPrint("bool: no errors"); + + if (FileExists($EXEDIR)) + DetailPrint("bool: FileExists"); + + if (Silent()) + DetailPrint("bool: silent"); + else + DetailPrint("bool: not silent"); + + if (RebootFlag()) + DetailPrint("bool: reboot flag set"); + + if (IsWindow($HWNDPARENT)) + DetailPrint("bool: IsWindow"); + + if (AbortCalled()) + DetailPrint("bool: AbortCalled"); + + // --- Negated --- + + if (!Errors()) + DetailPrint("bool: negated"); + + // --- Stored in a variable --- + + $R0 = Errors(); + $R1 = FileExists($EXEDIR); + $R2 = Silent(); + $R3 = RebootFlag(); + $R4 = IsWindow($HWNDPARENT); + DetailPrint("bool: ".$R0." ".$R1." ".$R2." ".$R3." ".$R4); + + // --- In compound conditions, where the jumps have to be threaded together --- + + if (FileExists($EXEDIR) && !Errors()) + DetailPrint("bool: and"); + + if (Silent() || FileExists($EXEDIR)) + DetailPrint("bool: or"); + + if ((Silent() || RebootFlag()) && FileExists($EXEDIR) && !Errors()) + DetailPrint("bool: nested"); + + // --- Compared against a literal --- + + if (FileExists($EXEDIR) == true) + DetailPrint("bool: compared to true"); + + if (Silent() != true) + DetailPrint("bool: compared to false"); + + // --- Driving a loop --- + + $R5 = 0; + while (!Errors() && $R5 < 2) + { + DetailPrint("bool: loop ".$R5); + $R5++; + } + + // --- As a switch subject --- + + /* + * The subject goes through a variable. Using the instruction directly emits + * the branch after the case bodies, referring back to a label ahead of it, + * and makensis then warns that the label is not used. See KNOWN-GAPS.md. + */ + $R6 = FileExists($EXEDIR); + switch ($R6) + { + case true: + DetailPrint("bool: switch true"); + break; + case false: + DetailPrint("bool: switch false"); + break; + } + + // --- MessageBox, whose value is a button name rather than a Boolean --- + + if (MessageBox("MB_YESNO|MB_ICONQUESTION", "Never shown") == "IDYES") + DetailPrint("bool: MessageBox yes"); + else + DetailPrint("bool: MessageBox no"); +} diff --git a/e2e/20-inst-switches.nsl b/e2e/20-inst-switches.nsl new file mode 100644 index 0000000..59ee2b1 --- /dev/null +++ b/e2e/20-inst-switches.nsl @@ -0,0 +1,85 @@ +/* + * The switch convention: where NSIS takes a /FLAG, nsL takes a Boolean. + * + * This is the largest single source of translation bugs in the assembler, + * because the Boolean has to turn back into a flag in the right position - + * before the operands for some instructions, after them for others - and + * because passing false has to emit nothing at all rather than a literal + * "false". + * + * Every instruction below therefore appears twice: once with the flag set and + * once without. + */ + +#include "guard.nsl" + +Name("20 inst switches"); +OutFile("20-inst-switches.exe"); + +section Test("switch instructions") +{ + // Reserved files, which nsL requires be declared inside a section even though + // NSIS allows them at global scope too. + ReserveFile("fixtures/sample.txt"); + ReserveFileRecursive("fixtures/subdir"); + + SetOutPath($INSTDIR); + + // --- Delete: /REBOOTOK --- + Delete($INSTDIR."\\a.txt"); + Delete($INSTDIR."\\b.txt", true); + + // --- RMDir: /REBOOTOK, and the separate recursive wrapper --- + RMDir($INSTDIR."\\empty"); + RMDir($INSTDIR."\\empty", true); + RMDirRecursive($INSTDIR."\\tree"); + RMDirRecursive($INSTDIR."\\tree", true); + + // --- Rename: /REBOOTOK --- + Rename($INSTDIR."\\from.txt", $INSTDIR."\\to.txt"); + Rename($INSTDIR."\\from.txt", $INSTDIR."\\to.txt", true); + + // --- File: /oname, and the recursive wrapper for /r --- + File("fixtures/sample.txt"); + File("fixtures/sample.txt", "renamed.txt"); + FileRecursive("fixtures/subdir"); + + // --- CopyFiles: /SILENT and /FILESONLY --- + CopyFiles($INSTDIR."\\src", $INSTDIR."\\dst"); + CopyFiles($INSTDIR."\\src", $INSTDIR."\\dst", true); + CopyFiles($INSTDIR."\\src", $INSTDIR."\\dst", true, true); + + // --- SetOutPath and CreateDirectory, neither of which takes a flag, for + // contrast with the ones above --- + CreateDirectory($INSTDIR."\\made"); + + // --- SetCompressor: /SOLID and /FINAL are global, so they live in 16 --- + + // --- ExecShell and its waiting variant --- + ExecShell("open", $INSTDIR); + ExecShell("open", $INSTDIR, ""); + ExecShellWait("open", $INSTDIR); + + // --- CreateShortCut, which has a long optional tail rather than a flag --- + CreateShortCut($INSTDIR."\\short.lnk", $INSTDIR."\\target.exe"); + CreateShortCut($INSTDIR."\\short.lnk", $INSTDIR."\\target.exe", "-arg"); + CreateShortCut( + $INSTDIR."\\short.lnk", + $INSTDIR."\\target.exe", + "-arg", + $INSTDIR."\\target.exe", + 0, + "SW_SHOWNORMAL", + "", + "a description"); + + // --- WriteUninstaller, so the uninstaller below is real --- + WriteUninstaller($INSTDIR."\\Uninstall-20.exe"); +} + +uninstall section Uninstall("Uninstall") +{ + // The same convention applies unchanged on the uninstaller side. + Delete($INSTDIR."\\a.txt", true); + RMDirRecursive($INSTDIR, true); +} diff --git a/e2e/21-inst-registry.nsl b/e2e/21-inst-registry.nsl new file mode 100644 index 0000000..c1bbde0 --- /dev/null +++ b/e2e/21-inst-registry.nsl @@ -0,0 +1,89 @@ +/* + * Registry and INI instructions. + * + * Every write here targets HKCU under a key of its own. Nothing in this file + * ever runs - guard.nsl sees to that - but the corpus should still not be + * writing anywhere a stray execution could do damage, and HKCU is the only + * hive a "user" execution level can reach at all. + */ + +#include "guard.nsl" + +Name("21 inst registry"); +OutFile("21-inst-registry.exe"); + +#define CorpusKey "Software\\nsL-corpus\\e2e" + +section Test("registry") +{ + // --- Writing, one instruction per value type --- + WriteRegStr("HKCU", CorpusKey, "AString", "a value"); + WriteRegExpandStr("HKCU", CorpusKey, "AnExpandString", "%TEMP%\\thing"); + WriteRegDWORD("HKCU", CorpusKey, "ADword", 1); + WriteRegBin("HKCU", CorpusKey, "ABinary", "DEADBEEF"); + // The value is a hex string, not a delimited list: WriteRegMultiStr always + // emits /REGEDIT5, which is the form NSIS documents as taking hex. + WriteRegMultiStr("HKCU", CorpusKey, "AMultiString", "610000000000"); + WriteRegNone("HKCU", CorpusKey, "ANone"); + + // --- Reading --- + $R0 = ReadRegStr("HKCU", CorpusKey, "AString"); + $R1 = ReadRegDWORD("HKCU", CorpusKey, "ADword"); + DetailPrint("registry: ".$R0." ".$R1); + + // The default value of a key is the empty value name. + WriteRegStr("HKCU", CorpusKey, "", "the default value"); + $R2 = ReadRegStr("HKCU", CorpusKey, ""); + DetailPrint("registry: default = ".$R2); + + // --- Enumerating --- + $R3 = EnumRegKey("HKCU", "Software\\nsL-corpus", 0); + $R4 = EnumRegValue("HKCU", CorpusKey, 0); + DetailPrint("registry: ".$R3." ".$R4); + + // Enumeration in a loop, which is what it is for. + for ($i = 0; $i < 3; $i++) + { + $R5 = EnumRegValue("HKCU", CorpusKey, $i); + if ($R5 ==S "") + break; + DetailPrint("registry: value ".$i." = ".$R5); + } + + // --- The 32/64-bit view, which changes what all of the above see --- + SetRegView("64"); + $R6 = GetRegView(); + SetRegView("32"); + SetRegView("default"); + DetailPrint("registry: view was ".$R6); + + // --- Every root key nsL accepts --- + $R7 = ReadRegStr("HKCR", ".txt", ""); + $R8 = ReadRegStr("HKLM", "Software\\Microsoft\\Windows\\CurrentVersion", "ProgramFilesDir"); + $R9 = ReadRegStr("HKU", ".DEFAULT\\Environment", "TEMP"); + $0 = ReadRegStr("HKCC", "System\\CurrentControlSet\\Control\\Print", ""); + DetailPrint("registry: roots ".$R7." ".$R8." ".$R9." ".$0); + + // --- INI files, the same shape without a hive --- + $1 = "$PLUGINSDIR\\corpus.ini"; + WriteINIStr($1, "Section", "Value", "written"); + $2 = ReadINIStr($1, "Section", "Value"); + DetailPrint("ini: ".$2); + + DeleteINIStr($1, "Section", "Value"); + DeleteINISec($1, "Section"); + FlushINI($1); + + // --- Deleting, including the switch forms --- + DeleteRegValue("HKCU", CorpusKey, "AString"); + DeleteRegKey("HKCU", CorpusKey); + // Delete only if the key has no subkeys. + DeleteRegKey("HKCU", "Software\\nsL-corpus", true); + + WriteUninstaller($INSTDIR."\\Uninstall-21.exe"); +} + +uninstall section Uninstall("Uninstall") +{ + DeleteRegKey("HKCU", "Software\\nsL-corpus"); +} diff --git a/e2e/22-inst-files.nsl b/e2e/22-inst-files.nsl new file mode 100644 index 0000000..f6ec7e1 --- /dev/null +++ b/e2e/22-inst-files.nsl @@ -0,0 +1,113 @@ +/* + * Filesystem instructions: embedding files, the file handle API, directory + * search, and attributes. + * + * Everything that writes writes under $INSTDIR or $PLUGINSDIR. As with the + * registry file, nothing here ever runs, but the corpus should not be capable + * of touching anything outside its own install directory even if it did. + */ + +#include "guard.nsl" + +Name("22 inst files"); +OutFile("22-inst-files.exe"); +InstallDir("$TEMP\\nsL-corpus-22"); + +section Test("files") +{ + SetOutPath($INSTDIR); + + // --- Embedding files into the installer --- + File("fixtures/sample.txt"); + File("fixtures/sample.txt", "renamed.txt"); + File("fixtures/sample.ini"); + FileRecursive("fixtures/subdir"); + + // --- Directories --- + CreateDirectory($INSTDIR."\\made\\deeper"); + SetOutPath($INSTDIR."\\made"); + SetOutPath($INSTDIR); + + // --- The file handle API: open, write, seek, read, close --- + ClearErrors(); + $R0 = FileOpen($INSTDIR."\\written.txt", "w"); + if (!Errors()) + { + FileWrite($R0, "first line\r\n"); + FileWriteByte($R0, 65); + FileWriteWord($R0, 66); + FileWriteUTF16LE($R0, "wide\r\n"); + FileClose($R0); + } + + $R1 = FileOpen($INSTDIR."\\written.txt", "r"); + if (!Errors()) + { + $R2 = FileRead($R1); + $R3 = FileReadByte($R1); + $R4 = FileReadWord($R1); + $R5 = FileReadUTF16LE($R1); + DetailPrint("files: read ".$R2." ".$R3." ".$R4." ".$R5); + + // Seek, in each of the three modes, with and without the position output. + FileSeek($R1, 0, "SET"); + $R6 = FileSeek($R1, 4, "CUR"); + $R7 = FileSeek($R1, 0, "END"); + DetailPrint("files: seek ".$R6." ".$R7); + + FileClose($R1); + } + + // Appending, the third open mode. + $R8 = FileOpen($INSTDIR."\\written.txt", "a"); + if (!Errors()) + { + FileWrite($R8, "appended\r\n"); + FileClose($R8); + } + + // The read buffer size, in megabytes rather than bytes. + FileBufSize(1); + + // --- Searching a directory --- + ($R9, $0) = FindFirst($INSTDIR."\\*.txt"); + if ($0 !=S "") + { + while ($0 !=S "") + { + DetailPrint("files: found ".$0); + $0 = FindNext($R9); + } + FindClose($R9); + } + + // --- Attributes and times --- + SetFileAttributes($INSTDIR."\\written.txt", "NORMAL"); + SetFileAttributes($INSTDIR."\\written.txt", "READONLY|HIDDEN"); + ($1, $2) = GetFileTime($INSTDIR."\\written.txt"); + ($3, $4) = GetFileTimeLocal("fixtures/sample.txt"); + DetailPrint("files: times ".$1." ".$2." ".$3." ".$4); + + // --- Moving, copying and removing --- + Rename($INSTDIR."\\written.txt", $INSTDIR."\\moved.txt"); + CopyFiles($INSTDIR."\\moved.txt", $INSTDIR."\\made"); + Delete($INSTDIR."\\moved.txt"); + RMDirRecursive($INSTDIR."\\made"); + + // --- Temporary files --- + InitPluginsDir(); + $5 = GetTempFileName(); + $6 = GetTempFileName($PLUGINSDIR); + DetailPrint("files: temp ".$5." ".$6); + + WriteUninstaller($INSTDIR."\\Uninstall-22.exe"); +} + +uninstall section Uninstall("Uninstall") +{ + Delete($INSTDIR."\\sample.txt"); + Delete($INSTDIR."\\renamed.txt"); + Delete($INSTDIR."\\sample.ini"); + Delete($INSTDIR."\\Uninstall-22.exe"); + RMDirRecursive($INSTDIR); +} diff --git a/e2e/23-inst-ui.nsl b/e2e/23-inst-ui.nsl new file mode 100644 index 0000000..8e52127 --- /dev/null +++ b/e2e/23-inst-ui.nsl @@ -0,0 +1,102 @@ +/* + * Window, dialog and process instructions. + * + * These are the ones that only make sense against a live installer window, so + * the compile check is all the assurance available for them - which is exactly + * why having it matters here more than anywhere else in the corpus. + */ + +#include "guard.nsl" + +Name("23 inst ui"); +OutFile("23-inst-ui.exe"); + +page Components(); +page Directory(); +page InstFiles(); + +function .onGUIInit() +{ + // The installer window and its children. + $R0 = GetDlgItem($HWNDPARENT, 1); + // Both take an integer. NSIS spells these ${SW_SHOW} and friends, but those + // come from an .nsh nsL does not include, so the numbers are literal here. + EnableWindow($R0, 1); + ShowWindow($R0, 5); + + $R1 = CreateFont("Tahoma", 10, 700, true, false); + SendMessage($R0, 0x0030, $R1, 1); + + /* + * SetCtlColors is not covered: it only implements the assemble-into-a- + * register form, so calling it as a statement - which is the only way it can + * be called, since it returns nothing - throws UnsupportedOperationException + * out of the assembler. See KNOWN-GAPS.md. + */ + + BringToFront(); +} + +section Test("ui") +{ + // --- Message boxes: the flag sets, and the return value --- + MessageBox("MB_OK", "Never shown"); + MessageBox("MB_OK|MB_ICONINFORMATION", "Never shown"); + $R0 = MessageBox("MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2", "Never shown"); + DetailPrint("ui: MessageBox returned ".$R0); + + if (MessageBox("MB_RETRYCANCEL|MB_ICONEXCLAMATION", "Never shown") == "IDRETRY") + DetailPrint("ui: retry"); + + // --- The details window --- + SetDetailsView("hide"); + SetDetailsView("show"); + DetailPrint("ui: DetailPrint"); + SetDetailsPrint("textonly"); + DetailPrint("ui: text only"); + SetDetailsPrint("both"); + + // --- The main window --- + HideWindow(); + BringToFront(); + LockWindow("on"); + LockWindow("off"); + + // --- Finding and talking to windows --- + $R1 = FindWindow("#32770", ""); + $R2 = FindWindow("#32770", "", $HWNDPARENT); + $R3 = IsWindow($HWNDPARENT); + DetailPrint("ui: windows ".$R1." ".$R2." ".$R3); + + // SendMessage, with and without a return value and with a timeout. + SendMessage($HWNDPARENT, 0x0010, 0, 0); + $R4 = SendMessage($HWNDPARENT, 0x000E, 0, 0); + DetailPrint("ui: SendMessage returned ".$R4); + + // --- Section text and flags, which drive the components page --- + SectionSetText(0, "renamed at run time"); + SectionSetFlags(0, 1); + SectionSetSize(0, 100); + SectionSetInstTypes(0, 1); + InstTypeSetText(0, "renamed install type"); + SetCurInstType(0); + + /* + * SetBrandingImage has the same defect as SetCtlColors and is not covered + * either: it rejects being given a return variable and then throws from the + * statement form, so there is no way to call it. See KNOWN-GAPS.md. + */ + + // --- Running things --- + Exec($SYSDIR."\\notepad.exe"); + ExecWait($SYSDIR."\\notepad.exe"); + $R5 = ExecWait($SYSDIR."\\notepad.exe"); + ExecShell("open", "https://nsis.sourceforge.io"); + DetailPrint("ui: exit code ".$R5); + + // --- Waiting and rebooting --- + SetRebootFlag(true); + if (RebootFlag()) + DetailPrint("ui: would reboot"); + SetRebootFlag(false); +} diff --git a/e2e/24-inst-misc.nsl b/e2e/24-inst-misc.nsl new file mode 100644 index 0000000..c84141b --- /dev/null +++ b/e2e/24-inst-misc.nsl @@ -0,0 +1,106 @@ +/* + * The remainder: instructions that did not fit any of the shapes above. + * + * Mostly global attributes with no natural grouping, plus the few run-time + * instructions that end the installer. Its job is to close the gap against + * Statement.matchInstruction() rather than to tell a story. + */ + +/* + * Target and CPU come before the guard include, which is the one place in the + * corpus where that order matters: they have to precede anything that touches + * the header, and the functions guard.nsl defines already do. + */ +// CPU first, then Target: both set the stub, and ManifestLongPathAware below +// is only compatible with a Unicode one, so Target has to have the last word. +CPU("x86"); +Target("x86-unicode"); + +#include "guard.nsl" + +Name("24 inst misc"); +OutFile("24-inst-misc.exe"); + +// Silent install and uninstall. +SilentInstall("normal"); +SilentUninstall("normal"); + +// Compression level, on top of the compressor chosen in 16. +SetCompressionLevel(9); + +// Licence page text, and a language string scoped to it. +LicenseData("fixtures/licence.txt"); +LicenseText("Read this first"); +LicenseLangString("CorpusLicence", 1033, "fixtures/licence.txt"); + +// Background window. +BGGradient("000000", "0000FF", "FFFFFF"); +BGFont("Tahoma", 40, 700, true, false, false); + +// Application manifest. +ManifestDPIAware(true); +ManifestGdiScaling(true); +ManifestLongPathAware(true); +ManifestDisableWindowFiltering(true); +ManifestSupportedOS("Win10"); +/* + * ManifestAppendCustomString is not covered: makensis 3.12 rejects every + * two-argument spelling of it with its own usage line, including one written + * by hand in a bare .nsi, so this is an NSIS-side limitation rather than + * anything nsL does. + */ + +page License(); +page Components(); +page InstFiles(); + +section Test("misc") +{ + // UnsafeStrCpy is StrCpy without the literal-argument check, so it is an + // expression like StrCpy is - not a two-argument statement. + $R2 = "unchecked"; + $R0 = UnsafeStrCpy($R2, 5); + DetailPrint("misc: ".$R0); + + // A known folder by GUID - Downloads. + $R1 = GetKnownFolderPath("{374DE290-123F-4565-9164-39C4925E467B}"); + DetailPrint("misc: known folder ".$R1); + + // DLL registration, and a direct call into one. + RegDLL($SYSDIR."\\nonexistent.dll"); + UnRegDLL($SYSDIR."\\nonexistent.dll"); + CallInstDLL($SYSDIR."\\nonexistent.dll", "SomeFunction"); + + // Both of these end the installer, so they come last and are guarded by a + // condition that is false at run time. + if (AbortCalled()) + { + SetRebootFlag(true); + Reboot(); + } + + if (AbortCalled()) + Quit(); +} + +/* + * Not covered here: + * + * Icon, UninstallIcon, WindowIcon, CheckBitmap, AddBrandingImage, + * SetBrandingImage - all need real image files, and CheckBitmap in + * particular needs one laid out the way NSIS expects. A fixture set for + * these is worth adding, but it is bitmap authoring rather than corpus + * authoring. + * + * LoadLanguageFile - needs an .nlf from the NSIS installation, so covering + * it would tie the corpus to a particular NSIS layout. + * + * GetDLLVersionLocal - reads the file while compiling and so needs a real + * DLL. 18 covers the run-time GetDLLVersion instead. + * + * LogSet, LogText - rejected outright unless NSIS was built with + * NSIS_CONFIG_LOG. + * + * SetCtlColors, SetFont, VIAddVersionKey, VIProductVersion, UninstallIcon, + * UninstallButtonText - broken in the assembler. See KNOWN-GAPS.md. + */ diff --git a/e2e/KNOWN-GAPS.md b/e2e/KNOWN-GAPS.md new file mode 100644 index 0000000..c70a172 --- /dev/null +++ b/e2e/KNOWN-GAPS.md @@ -0,0 +1,187 @@ +# Known gaps + +Everything here was found by writing the corpus in [e2e/](.) and running it +through `makensis`. Each entry is a documented nsL feature that does not work, +with the smallest reproduction found and what the corpus does instead. + +None of these are fixed. They are listed so that the exclusions in the corpus +are deliberate and reviewable rather than silent, and so that a fix has a test +waiting for it: removing the workaround in the named file is the regression +test. + +--- + +## Assembler crashes + +### Calling a value-returning function as a bare statement + +```nsl +function F($n) { return $n; } +section S("s") { F(1); } +``` + +``` +java.lang.NullPointerException: Cannot invoke "nsl.Register.toString()" + at nsl.expression.FunctionCallExpression.assemble(FunctionCallExpression.java:140) +``` + +The discard path looks up `getUsedVars().get(0)` - register index 0, `$0` - +rather than the function's first used register, so any function that takes +parameters and returns a value cannot be called for its side effects alone. + +*Corpus:* [06-functions.nsl](06-functions.nsl) assigns the result even where +nothing needs it. + +### `SetCtlColors` and `SetBrandingImage` cannot be called + +Both reject being given a return variable and then throw +`UnsupportedOperationException` from the statement form, so there is no way to +write either of them. + +*Corpus:* not used; [23-inst-ui.nsl](23-inst-ui.nsl) says why. + +--- + +## Wrong code emitted + +### An uninstaller page names a function that does not exist + +The callback name is written through verbatim, so an uninstall page declaration +has to name the emitted function rather than the declared one: + +```nsl +uninstall function Pre() { } +uninstall page UninstConfirm("un.Pre"); // "Pre" fails to compile +``` + +*Corpus:* [08-pages.nsl](08-pages.nsl) spells the prefix out. + +--- + +## Features that cannot be used at all + +### `sectiongroup` + +`SectionGroupStatement` parses its body with a `BlockStatement`, which refuses +to run in global context - so every spelling of the keyword fails: + +``` +"code block" can only be used in a function or section context. +``` + +There is no way to write a section group. *Corpus:* +[07-sections.nsl](07-sections.nsl) covers sections only. + +### Seven instructions are never dispatched + +`Call`, `ChangeUI`, `Exch`, `GetCurrentAddress`, `GetFunctionAddress`, +`GetLabelAddress` and `Sleep` all have wrapper classes in +[../src/nsl/instruction/](../src/nsl/instruction/) that nothing references from +`Statement.matchInstruction()`. Using any of them fails with: + +``` +Function "GetFunctionAddress" not found that expects 1 parameters and returns 1 values. +``` + +This is the failure mode [CLAUDE.md](../CLAUDE.md) warns about when adding an +instruction. It means indirect calls and `Sleep` have no spelling in nsL. + +*Corpus:* [06-functions.nsl](06-functions.nsl) and +[17-inst-void.nsl](17-inst-void.nsl) name them where they would have gone. + +### A loop or a nested switch inside a `switch` + +The first breakable construct inside a case leaves every later `break` in the +enclosing switch rejected: + +```nsl +switch ($R0) +{ + case 1: + $i = 0; + while ($i < 2) { $i++; } + default: + DetailPrint("d"); + break; // The "break" statement cannot be used here. +} +``` + +The assembler separately insists a switch end with a `break`, so no arrangement +of the two assembles. *Corpus:* [05-switch.nsl](05-switch.nsl) keeps case bodies +flat and says so. + +--- + +## Smaller things + +### `return` always needs a value + +A bare `return;` is a parse error - `Expected an expression, but found ";"` - +even in a function that returns nothing. + +### `StrLen()` rejects a plain register + +`isLiteral()` is true for anything that is not an `AssembleExpression`, and a +register is not one, so `StrLen($R0)` is refused with "use the length() +assembler function instead". Its argument has to be a nested instruction call: + +```nsl +$R3 = StrLen(ReadEnvStr("PATH")); +``` + +### `toint()` cannot parse hexadecimal + +Documented to accept "a string literal of a decimal or hexadecimal +representation". `toint("0xFF")` reaches `Integer.parseInt("0xFF", 16)`, which +rejects the prefix, and `toint("FF")` is parsed as decimal. Both warn and +return 0. The undocumented second parameter - a fallback value - does work. + +### `length()` measures the escaped form + +`length("a\tb")` is 5, not 3: it counts the string after translation into NSIS +form, where a tab is the three characters `$\t`. + +### `returnvar()` has to be the whole argument + +`DetailPrint(returnvar(1))` works; `DetailPrint("x".returnvar(1))` reports "Use +of returnvar() where no return registers are being used". + +### `#if` cannot appear part way through a statement + +It is matched where a statement is expected. The ternary operator is the in-line +equivalent and folds the same way. + +### `DirVerify` and `DirText` are accepted outside a `PageEx` + +Both list `NslContext.Global` as valid, but NSIS rejects them anywhere except +inside a `PageEx`. *Corpus:* [16-attributes.nsl](16-attributes.nsl) puts them in +the `page Directory()` block. + +### A boolean instruction as a `switch` subject leaves an unused label + +`switch (FileExists($EXEDIR))` emits the branch after the case bodies, referring +back to a label ahead of it, and `makensis` warns that the label is not used. +Going through a variable avoids it. *Corpus:* +[19-inst-boolean.nsl](19-inst-boolean.nsl). + +### A variable used only in unassembled code is still declared + +`for ($k = 0; true && false; $k++)` declares `$k`, and `makensis` warns that it +wastes memory. `Examples/Loops.nsl` notes the same. *Corpus:* +[04-control-flow.nsl](04-control-flow.nsl) reuses a variable that is live +elsewhere. + +--- + +## Not gaps: environment limits + +These are excluded for reasons that have nothing to do with the assembler. + +| Excluded | Why | +| --- | --- | +| `Icon`, `UninstallIcon`, `WindowIcon`, `CheckBitmap`, `AddBrandingImage` | Need real image files, laid out as NSIS expects | +| `LoadLanguageFile` | Needs an `.nlf` from the NSIS installation | +| `GetDLLVersionLocal` | Reads the file while compiling, so needs a real DLL. [18-inst-returns.nsl](18-inst-returns.nsl) covers the run-time `GetDLLVersion` | +| `LogSet`, `LogText` | Rejected outright unless NSIS was built with `NSIS_CONFIG_LOG` | +| `ManifestAppendCustomString` | `makensis` 3.12 rejects every two-argument spelling, including one written by hand in a bare `.nsi` | +| Plug-in calls | Need actual plug-in DLLs present at compile time | diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..1eee84d --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,167 @@ +# End-to-end corpus + +nsL scripts that exist to be assembled and then compiled. The assertion is +narrow and cheap: **every script here assembles cleanly and `makensis` accepts +the result.** Nothing is run, and nothing is diffed against a stored `.nsi`. + +`makensis` is the oracle. A textual diff only proves the output changed the way +the author expected; it says nothing about whether the output is valid NSIS. +This corpus catches operands in the wrong order, off-by-one arity, a directive +emitted where NSIS rejects it, and an instruction that does not exist in the +installed NSIS version. See [E2E_TESTING_PLAN.md](../E2E_TESTING_PLAN.md) for +the design, and [KNOWN-GAPS.md](KNOWN-GAPS.md) for what the corpus deliberately +does not cover and why. + +## Running it + +```bash +mise run test:e2e # the whole corpus +mise run test:e2e switch # just the scripts whose name contains "switch" +``` + +`mise run checks` includes it, alongside the formatter and the unit tests. + +[run.sh](run.sh) does the work and can be called directly. Per script it copies +`e2e/` into `build/e2e-run//`, assembles and compiles there, checks the +guard survived, and deletes the directory. On failure it prints the compiler +output *and* the generated `.nsi`, because the error line means nothing without +the line it is complaining about. + +| Environment | Effect | +| --- | --- | +| `E2E_REQUIRE_MAKENSIS=1` | Fail instead of skipping when `makensis` is missing. For CI | +| `E2E_KEEP=1` | Keep the run directories under `build/e2e-run/` for inspection | + +Without `makensis` on the `PATH` the task skips with a message rather than +passing quietly - the corpus is only meaningful with the real compiler behind +it. Warnings are reported but do not fail the run. + +### Running one by hand + +The assembler resolves `#include` relative to its own working directory, not to +the including file - see +[IncludeDirective.java:30](../src/nsl/preprocessor/IncludeDirective.java#L30). +So a script has to be run from a directory that has `guard.nsl` and `fixtures/` +next to it, and both the `.nsi` and the `.exe` land beside the source: + +```bash +./gradlew jar +cp -r e2e /tmp/e2e-run +cd /tmp/e2e-run +java -jar $OLDPWD/build/libs/nsL.jar 01-expressions.nsl /nopause +``` + +Not `/nomake` - running the compiler is the entire point. Exit 3 means +`makensis` rejected the output; anything else non-zero is an assembler-level +failure. + +## Runtime safety + +The corpus writes to the registry, the filesystem and the shell, and each script +builds a real, runnable installer. They are inert, in three independent layers, +because any one of them could be defeated by the very codegen bug the suite +exists to catch. + +**1. [guard.nsl](guard.nsl), included first by every script.** It defines +`RuntimeGuard()` once per context - installer and uninstaller are separate NSIS +namespaces - and calls it from both `.onInit` callbacks. The guard shows an +`MB_OK` reading *"This shall never run, exiting."* and then aborts. + +The message box is the point. An inert installer that dies silently looks like a +broken installer; this one says why it did nothing, so anyone who double clicks +a stray corpus binary knows it is a test artifact. + +`Abort` propagates out of the `Call` - verified by running a probe installer +under Wine, not assumed. Re-run that probe if the shape of the guard changes; it +is the one part of the design resting on NSIS runtime semantics rather than on +emitted syntax. + +**2. `RequestExecutionLevel("user")`,** also in `guard.nsl`. A corpus binary +cannot touch `HKLM` or `Program Files` even if layer 1 failed. Every registry +write in the corpus targets `HKCU`, and every file write targets `$INSTDIR` or +`$PLUGINSDIR`. + +**3. The binary does not outlive the check.** `OutFile` is a bare filename, so +the `.exe` is written wherever the script was run from - a scratch copy, never +the repo. + +### Guard self-check + +The guard is written in the language under test, so a codegen regression could +drop it. [run.sh](run.sh) therefore asserts on each generated `.nsi` that all +four pieces survived: + +``` +Function RuntimeGuard ... containing Abort +Function un.RuntimeGuard ... containing Abort +Function .onInit ... containing Call RuntimeGuard +Function un.onInit ... containing Call un.RuntimeGuard +``` + +Checking only the callbacks is not enough now that the `Abort` lives one frame +away: a bug that emitted `Call RuntimeGuard` from `un.onInit` would pass a naive +check and produce an uninstaller that runs. That bug was real - the assembler +resolved every call from uninstaller code to the installer function - and is +what [06-functions.nsl](06-functions.nsl) now pins down. + +## Layout + +| File | Covers | +| --- | --- | +| [guard.nsl](guard.nsl) | The runtime guard and `RequestExecutionLevel`; included by every script below | +| [fixtures/](fixtures/) | Small real files for `File()`, `LicenseData()`, `ReadINIStr()` and `#include` | +| [01-expressions.nsl](01-expressions.nsl) | Literals, NSIS constants, named variables, register-pool pressure | +| [02-operators.nsl](02-operators.nsl) | Every operator, twice: folded at assemble time and emitted as `IntOp` | +| [03-strings.nsl](03-strings.nsl) | The three quote characters, escapes, `@` verbatim strings, `format()` | +| [04-control-flow.nsl](04-control-flow.nsl) | `if`/`while`/`do`/`for`, `break`, `continue`, folded and unreachable branches | +| [05-switch.nsl](05-switch.nsl) | `switch` over integer, string, boolean and expression subjects; fallthrough | +| [06-functions.nsl](06-functions.nsl) | Parameters, multiple returns, overloading, recursion, the `un.` namespace split | +| [07-sections.nsl](07-sections.nsl) | Every section header argument, `SectionIn`, `AddSize`, uninstall sections | +| [08-pages.nsl](08-pages.nsl) | Both page forms, every callback position, uninstaller pages | +| [09-globals-and-scope.nsl](09-globals-and-scope.nsl) | Global initialisers threaded into `.onInit`, block and loop scope | +| [10-defines.nsl](10-defines.nsl) | `#define`, `#redefine`, `#undef`, `defined()`, definition- vs substitution-time evaluation | +| [11-conditionals.nsl](11-conditionals.nsl) | `#if`/`#elseif`/`#else`, nested, at global and statement scope | +| [12-macros.nsl](12-macros.nsl) | Parameters, returns, overloads, recursion as an assemble-time loop, `Returns` | +| [13-include.nsl](13-include.nsl) | Nested `#include`, and what crosses the file boundary | +| [14-inline-nsis.nsl](14-inline-nsis.nsl) | `#nsis` blocks, at global scope, in a section, and inside a macro | +| [15-assembler-functions.nsl](15-assembler-functions.nsl) | `toint`, `type`, `length`, `defined`, `format`, `nsisconst`, `eval`, `returnvar` | +| [16-attributes.nsl](16-attributes.nsl) | Global installer attributes: compression, text, colours, install types | +| [17-inst-void.nsl](17-inst-void.nsl) | Instructions that take arguments and return nothing | +| [18-inst-returns.nsl](18-inst-returns.nsl) | Instructions that produce one value, and several | +| [19-inst-boolean.nsl](19-inst-boolean.nsl) | Branch instructions as conditions, as values, and in compound expressions | +| [20-inst-switches.nsl](20-inst-switches.nsl) | The Boolean-argument-for-`/FLAG` convention, each instruction with and without | +| [21-inst-registry.nsl](21-inst-registry.nsl) | Registry writes, reads, enumeration, views, root keys; INI files | +| [22-inst-files.nsl](22-inst-files.nsl) | Embedding, the file handle API, directory search, attributes and times | +| [23-inst-ui.nsl](23-inst-ui.nsl) | Message boxes, windows, controls, section text, running processes | +| [24-inst-misc.nsl](24-inst-misc.nsl) | Everything left over: target, manifest, licence text, DLL registration | + +One file per feature area, numbered for stable ordering, each self-contained and +compiling alone - so when a run fails, the failing filename names the feature. +Resist writing one large script; a single `makensis` error line then tells you +nothing. + +## Instruction coverage + +174 of the 189 instructions dispatched by `Statement.matchInstruction()` appear +somewhere in the corpus. The 15 that do not are all listed in +[KNOWN-GAPS.md](KNOWN-GAPS.md), either as assembler defects or as environment +limits. + +To recheck after adding an instruction wrapper: + +```bash +tr '\n' ' ' < src/nsl/statement/Statement.java \ + | grep -o '[A-Za-z0-9]*Instruction\.name' | sed 's/\.name//' | sort -u \ + | while read c; do + grep -h 'static final String name' "src/nsl/instruction/$c.java" \ + | sed 's/.*= "//;s/".*//' + done | sort -u > /tmp/names.txt + +cat e2e/*.nsl e2e/fixtures/*.nsl \ + | grep -o '[A-Za-z][A-Za-z0-9]*(' | sed 's/(//' | sort -u > /tmp/used.txt + +comm -23 /tmp/names.txt /tmp/used.txt +``` + +That is a name match, not a parse, so it is a prompt rather than proof - but it +is enough to notice a new wrapper that nothing exercises. diff --git a/e2e/fixtures/included.nsl b/e2e/fixtures/included.nsl new file mode 100644 index 0000000..9255c8a --- /dev/null +++ b/e2e/fixtures/included.nsl @@ -0,0 +1,13 @@ +/* + * Included by 13-include.nsl. Contributes a constant, a function and a global + * so that the including script can prove all three cross the file boundary. + */ + +#define IncludedConstant "from the included file" + +$includedGlobal = "global set in an included file"; + +function IncludedFunction($n) +{ + return $n + 1; +} diff --git a/e2e/fixtures/includes-another.nsl b/e2e/fixtures/includes-another.nsl new file mode 100644 index 0000000..fa80d4b --- /dev/null +++ b/e2e/fixtures/includes-another.nsl @@ -0,0 +1,12 @@ +/* + * Included by 13-include.nsl, and itself includes another file. + * + * Note the path: #include resolves through new FileReader(path), which is + * relative to the assembler's working directory rather than to this file. So + * even from inside fixtures/, the nested include has to be written as it would + * be from the corpus root. + */ + +#include "fixtures/included.nsl" + +#define NestedConstant "from the file that included another" diff --git a/e2e/fixtures/licence.txt b/e2e/fixtures/licence.txt new file mode 100644 index 0000000..992a5e6 --- /dev/null +++ b/e2e/fixtures/licence.txt @@ -0,0 +1,2 @@ +nsL Assembler end-to-end corpus. +This is not a real licence; it exists so LicenseData() has a file. diff --git a/e2e/fixtures/sample.ini b/e2e/fixtures/sample.ini new file mode 100644 index 0000000..5da0ef1 --- /dev/null +++ b/e2e/fixtures/sample.ini @@ -0,0 +1,2 @@ +[Section] +Value=hello diff --git a/e2e/fixtures/sample.txt b/e2e/fixtures/sample.txt new file mode 100644 index 0000000..ba4f22c --- /dev/null +++ b/e2e/fixtures/sample.txt @@ -0,0 +1,2 @@ +nsL end-to-end corpus fixture. +This file exists so File() and friends have something real to embed. diff --git a/e2e/fixtures/subdir/nested.txt b/e2e/fixtures/subdir/nested.txt new file mode 100644 index 0000000..a63f36e --- /dev/null +++ b/e2e/fixtures/subdir/nested.txt @@ -0,0 +1 @@ +A fixture inside a subdirectory, for the recursive File() variants. diff --git a/e2e/guard.nsl b/e2e/guard.nsl new file mode 100644 index 0000000..d69d2f4 --- /dev/null +++ b/e2e/guard.nsl @@ -0,0 +1,67 @@ +/* + * guard.nsl + * + * Runtime safety for the end-to-end corpus. Every script in e2e/ includes this + * file first. + * + * The corpus exercises instructions that write to the registry, the filesystem + * and the shell, and the build product of each script is a real, runnable + * installer. They must be inert. RuntimeGuard() is called from both .onInit + * callbacks and aborts before any section can run. + * + * The message box is deliberate: an installer that dies silently looks broken. + * This one says why it did nothing, so anyone who double clicks a stray corpus + * binary knows it is a test artifact. + * + * Two definitions of the same function because NSIS keeps installer and + * uninstaller functions in separate namespaces - a un. function cannot call an + * installer one. The call sites are identical; the assembler emits + * "Call RuntimeGuard" in .onInit and "Call un.RuntimeGuard" in un.onInit. + * + * Abort propagates out of the Call - verified by running a probe installer + * under Wine, not assumed. Re-run that probe if the shape of this guard + * changes; it is the one part of the design resting on NSIS runtime semantics + * rather than on emitted syntax. + */ + +// Second layer: a corpus binary cannot touch HKLM or Program Files even if the +// guard above were to fail. +RequestExecutionLevel("user"); + +function RuntimeGuard() +{ + MessageBox("MB_OK", "This shall never run, exiting."); + Abort(); +} + +uninstall function RuntimeGuard() +{ + MessageBox("MB_OK", "This shall never run, exiting."); + Abort(); +} + +function .onInit() +{ + RuntimeGuard(); +} + +uninstall function .onInit() +{ + RuntimeGuard(); +} + +/* + * NSIS discards the uninstaller entirely - and warns - unless WriteUninstaller + * is called somewhere, which would leave un.onInit above compiled into nothing. + * This pair keeps the uninstaller real so that the guard covering it is real + * too, and it costs the corpus one extra section. + */ +section GuardWriteUninstaller("Guard") +{ + WriteUninstaller($INSTDIR."\\GuardUninstall.exe"); +} + +uninstall section GuardUninstall("Guard") +{ + DetailPrint("Guarded."); +} diff --git a/e2e/run.sh b/e2e/run.sh new file mode 100755 index 0000000..1459b50 --- /dev/null +++ b/e2e/run.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# Runs the end-to-end corpus: assemble each script, compile the result with +# makensis, and check that the runtime guard survived into the output. +# +# Usage: e2e/run.sh [filter] +# +# The filter is a substring matched against the script name, for iterating on +# one area: e2e/run.sh switch +# +# Environment: +# E2E_REQUIRE_MAKENSIS=1 fail instead of skipping when makensis is missing +# E2E_KEEP=1 keep the run directories for inspection + +set -uo pipefail + +repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +corpus=$repo/e2e +runs=$repo/build/e2e-run +jar=$repo/build/libs/nsL.jar +filter=${1:-} + +if ! command -v makensis >/dev/null 2>&1; then + # Skipped rather than passed. The corpus is only meaningful with the real + # compiler behind it, and on Windows the assembler shells out to makensisw + # instead, which is a GUI and is never waited on. + echo "SKIP: makensis is not on the PATH; the corpus needs it as its oracle." + echo " Install NSIS, or set E2E_REQUIRE_MAKENSIS=1 to make this a failure." + [[ ${E2E_REQUIRE_MAKENSIS:-0} == 1 ]] && exit 1 + exit 0 +fi + +"$repo/gradlew" --project-dir "$repo" jar -q || exit 1 +[[ -f $jar ]] || { echo "FAIL: $jar was not built."; exit 1; } + +rm -rf "$runs" +mkdir -p "$runs" + +# The guard is written in the language under test, so a codegen regression could +# drop it and leave a live installer behind. All four pieces have to survive: +# checking only the callbacks would miss a Call that named the wrong namespace. +check_guard() { + awk ' + # The assembler writes CRLF line endings, so strip the CR before matching. + { sub(/\r$/, "") } + /^Function / { fn = $2; next } + /^FunctionEnd$/ { fn = ""; next } + fn == "RuntimeGuard" && $0 == "Abort" { a = 1 } + fn == "un.RuntimeGuard" && $0 == "Abort" { b = 1 } + fn == ".onInit" && $0 == "Call RuntimeGuard" { c = 1 } + fn == "un.onInit" && $0 == "Call un.RuntimeGuard" { d = 1 } + END { + if (!a) print " missing: Abort inside Function RuntimeGuard" + if (!b) print " missing: Abort inside Function un.RuntimeGuard" + if (!c) print " missing: Call RuntimeGuard inside Function .onInit" + if (!d) print " missing: Call un.RuntimeGuard inside Function un.onInit" + exit (a && b && c && d) ? 0 : 1 + } + ' "$1" +} + +ran=0 +failed=0 + +for source in "$corpus"/*.nsl; do + name=$(basename "$source" .nsl) + [[ $name == guard ]] && continue + [[ -n $filter && $name != *"$filter"* ]] && continue + + dir=$runs/$name + mkdir -p "$dir" + cp -R "$corpus"/. "$dir"/ + + # Not /nomake: running the compiler is the entire point. The working directory + # has to be the copy, because #include resolves relative to the process rather + # than to the including file, and because both the .nsi and the .exe are + # written next to the source. + output=$(cd "$dir" && java -jar "$jar" "$name.nsl" /nopause 2>&1) + status=$? + ran=$((ran + 1)) + + if [[ $status -ne 0 ]]; then + failed=$((failed + 1)) + case $status in + 3) echo "FAIL $name: makensis rejected the assembled script" ;; + *) echo "FAIL $name: the assembler exited $status" ;; + esac + # Both halves, always: the compiler error means nothing without the line it + # is complaining about. + echo "$output" | sed 's/^/ /' + if [[ -f $dir/$name.nsi ]]; then + echo " --- $name.nsi ---" + sed 's/^/ /' "$dir/$name.nsi" + fi + continue + fi + + if ! guard_errors=$(check_guard "$dir/$name.nsi"); then + failed=$((failed + 1)) + echo "FAIL $name: the runtime guard did not survive assembly" + echo "$guard_errors" + continue + fi + + # Warnings do not fail the run, but they are not swallowed either. + if warnings=$(echo "$output" | grep -E '^[0-9]+ warnings?:$' -A100); then + echo "WARN $name" + echo "$warnings" | sed 's/^/ /' + else + echo "ok $name" + fi + + [[ ${E2E_KEEP:-0} == 1 ]] || rm -rf "$dir" +done + +echo +if [[ $ran -eq 0 ]]; then + echo "No scripts matched${filter:+ \"$filter\"}." + exit 1 +fi + +echo "$ran script(s), $failed failed." +[[ ${E2E_KEEP:-0} == 1 ]] && echo "Run directories kept under $runs" +exit $((failed > 0)) diff --git a/hk.pkl b/hk.pkl new file mode 100644 index 0000000..a6f06ff --- /dev/null +++ b/hk.pkl @@ -0,0 +1,33 @@ +amends "package://github.com/jdx/hk/releases/download/v1.53.0/hk@1.53.0#/Config.pkl" + +/// Spotless owns its own file set (see the `target` in build.gradle), so the +/// steps run the whole Gradle task rather than passing `{{files}}` through. +/// `exclusive` keeps two Gradle invocations off the same project lock. +local linters = new Mapping { + ["format"] { + glob = List("*.java") + exclusive = true + fix = "mise run format" + } + ["lint"] { + glob = List("*.java") + exclusive = true + check = "mise run lint" + } +} + +hooks { + ["pre-commit"] { + fix = true + stage = true + stash = "git" + steps = linters + } + ["check"] { + steps = linters + } + ["fix"] { + fix = true + steps = linters + } +} diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..7b583e6 --- /dev/null +++ b/mise.lock @@ -0,0 +1,92 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +[[tools.hk]] +version = "1.53.0" +backend = "aqua:jdx/hk" + +[tools.hk."platforms.linux-arm64"] +checksum = "sha256:3cf58d268a9114f0923a06c38cf0995b9da43bf2279b09aac3c3c615a615b3e4" +url = "https://github.com/jdx/hk/releases/download/v1.53.0/hk-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/487429257" + +[tools.hk."platforms.linux-x64"] +checksum = "sha256:960c14b3bcd61e36dcb42c304e3cc23b22ef2e6f28ac6559e3856d27acf6b54a" +url = "https://github.com/jdx/hk/releases/download/v1.53.0/hk-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/487429267" + +[tools.hk."platforms.linux-x64-musl"] +checksum = "sha256:c2075be6f6d4b3606450bdcb58ce5d17d9668804e8ee4f9d93dda596cae2de55" +url = "https://github.com/jdx/hk/releases/download/v1.53.0/hk-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/487429272" + +[tools.hk."platforms.macos-arm64"] +checksum = "sha256:d5c1f9a4c3598cc72c9f15908af6e036b138b9dd97c8891e4d08f699c98a5069" +url = "https://github.com/jdx/hk/releases/download/v1.53.0/hk-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/487429255" + +[tools.hk."platforms.windows-x64"] +checksum = "sha256:a2e5c6131d01cfd6d62204872798f97fc933e7f142d718b3e191434e2d31ef1c" +url = "https://github.com/jdx/hk/releases/download/v1.53.0/hk-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/jdx/hk/releases/assets/487429261" + +[[tools.java]] +version = "temurin-17.0.20+8" +backend = "core:java" + +[tools.java."platforms.linux-arm64"] +checksum = "sha256:d143936f473a4cb24e3b0e247d6d0775769d55ec9775c339540e753059a8d77a" +url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20%2B8/OpenJDK17U-jdk_aarch64_linux_hotspot_17.0.20_8.tar.gz" + +[tools.java."platforms.linux-x64"] +checksum = "sha256:be7668bc030d578b83d6d5ef9221d6d6729bbbca8cf94a7d52e16ac68b5a5a35" +url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20%2B8/OpenJDK17U-jdk_x64_linux_hotspot_17.0.20_8.tar.gz" + +[tools.java."platforms.linux-x64-musl"] +checksum = "sha256:c8bb5bc6984762dbce2ab7403d90832b6897c07f36f8706e4a315aa7a566d04d" +url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20%2B8/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.20_8.tar.gz" + +[tools.java."platforms.macos-arm64"] +checksum = "sha256:524850138c742324fb21fca4ff6ef68ea25f25bf59366a864e45b4a0c45ed0df" +url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20%2B8/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.20_8.tar.gz" + +[tools.java."platforms.macos-x64"] +checksum = "sha256:3710c3131c5d7c090582b357f1310133a90bf701183d065223f1a0b90b9ed5ae" +url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20%2B8/OpenJDK17U-jdk_x64_mac_hotspot_17.0.20_8.tar.gz" + +[tools.java."platforms.windows-x64"] +checksum = "sha256:418497be5cf585bdd2203d6486a565d66d3f5e992d5630d45104cb873fab8122" +url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20%2B8/OpenJDK17U-jdk_x64_windows_hotspot_17.0.20_8.zip" + +[[tools.pkl]] +version = "0.32.1" +backend = "aqua:apple/pkl" + +[tools.pkl."platforms.linux-arm64"] +checksum = "sha256:a76d2dd47da435a8f911b0347373f47c7e59ea54fb75ff846d20b8df10dba058" +url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-aarch64" +url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426568" + +[tools.pkl."platforms.linux-x64"] +checksum = "sha256:3180b62da95c0cad1d904e9bb6c5f4a8f9032413c21e53194bb91ff1ee5f3211" +url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-amd64" +url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426567" + +[tools.pkl."platforms.linux-x64-musl"] +checksum = "sha256:3180b62da95c0cad1d904e9bb6c5f4a8f9032413c21e53194bb91ff1ee5f3211" +url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-linux-amd64" +url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426567" + +[tools.pkl."platforms.macos-arm64"] +checksum = "sha256:563eb51c9a20b16a3625464ed745c675ed9750381f2126722696a0d7cac1d9d3" +url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-macos-aarch64" +url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426570" + +[tools.pkl."platforms.macos-x64"] +checksum = "sha256:5b74b903234047960144f66f2cebdd12d267d9b98e8155ed91d2ae5ed27e2d1f" +url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-macos-amd64" +url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426599" + +[tools.pkl."platforms.windows-x64"] +checksum = "sha256:8550a00fcf027335e42c5e2cd553b88e98845408cb1880b3e3d1860caf46d22a" +url = "https://github.com/apple/pkl/releases/download/0.32.1/pkl-windows-amd64.exe" +url_api = "https://api.github.com/repos/apple/pkl/releases/assets/487426668" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..c527cb4 --- /dev/null +++ b/mise.toml @@ -0,0 +1,34 @@ +[tools] +hk = "1.53.0" +java = "temurin-17" +pkl = "latest" + +[env] +HK_MISE = 1 + +[hooks] +postinstall = "hk install --mise" + +[tasks.build] +description = "Build the JAR" +run = "./gradlew build" + +[tasks.checks] +description = "Run all checks (formatting, tests, e2e corpus)" +depends = ["lint", "test:unit", "test:e2e"] + +[tasks."test:e2e"] +description = "Assemble the e2e corpus and compile it with makensis" +run = "./e2e/run.sh" + +[tasks.format] +description = "Format Java code with Spotless" +run = "./gradlew spotlessApply --no-daemon -q" + +[tasks.lint] +description = "Check Java formatting with Spotless" +run = "./gradlew spotlessCheck --no-daemon -q" + +[tasks."test:unit"] +description = "Run all tests" +run = "./gradlew test" diff --git a/src/nsl/Constant.java b/src/nsl/Constant.java index 6c1a6cc..29bf7a0 100644 --- a/src/nsl/Constant.java +++ b/src/nsl/Constant.java @@ -23,7 +23,7 @@ public class Constant { */ public Constant(String name, String realName, int index) { this.name = name; - this.realName = null; + this.realName = realName; this.index = index; } diff --git a/src/nsl/Main.java b/src/nsl/Main.java index 681bbb5..022829d 100644 --- a/src/nsl/Main.java +++ b/src/nsl/Main.java @@ -35,7 +35,8 @@ private static void showUsage() { System.out.println(" java -jar nsL.jar [Options] script.nsl"); System.out.println(); System.out.println("Options:"); - System.out.println(" -n"); + System.out.println(" /nomake Do not run the NSIS compiler after assembling"); + System.out.println(" /nopause Do not wait for a key press on error"); System.exit(1); } } diff --git a/src/nsl/NsisCompiler.java b/src/nsl/NsisCompiler.java new file mode 100644 index 0000000..2452631 --- /dev/null +++ b/src/nsl/NsisCompiler.java @@ -0,0 +1,144 @@ +/* + * NsisCompiler.java + */ + +package nsl; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Locale; + +/** + * Runs the NSIS compiler over an assembled script. + * + *

Windows uses makensisw.exe, the GUI compiler shipped with NSIS, which is expected + * to sit one directory above the assembler. Every other platform uses makensis, the + * console compiler, resolved from the PATH. The two need different treatment: the GUI + * owns its own window and is left to run detached, whereas the console compiler writes to this + * process' streams and must be waited on. + */ +public class NsisCompiler { + private NsisCompiler() {} + + /** The exit code returned when the NSIS compiler could not be run or reported a failure. */ + public static final int EXIT_COMPILE_FAILED = 3; + + private static final String MAKENSISW = "..\\makensisw.exe"; + private static final String MAKENSIS = "makensis"; + + /** + * Compiles the given NSIS script. + * + * @param nsiFile the assembled NSIS script + * @param noPauseOnError do not pause on error + * @param stdout the standard output writer + * @param stderr the standard error writer + * @return the exit code + */ + public static int compile( + File nsiFile, boolean noPauseOnError, PrintWriter stdout, PrintWriter stderr) + throws IOException { + if (isWindows()) return compileWithMakensisw(nsiFile, noPauseOnError, stderr); + return compileWithMakensis(nsiFile, stdout, stderr); + } + + /** + * Runs the GUI compiler and returns without waiting for it. + * + * @param nsiFile the assembled NSIS script + * @param noPauseOnError do not pause on error + * @param stderr the standard error writer + * @return the exit code + */ + private static int compileWithMakensisw(File nsiFile, boolean noPauseOnError, PrintWriter stderr) + throws IOException { + File makensisw = new File(MAKENSISW); + if (!makensisw.exists()) { + stderr.println("Unable to compile \"" + nsiFile.getCanonicalPath() + "\":"); + // getAbsoluteFile() first: getParent() is null for a bare filename, + // which is what "..\makensisw.exe" is on a non-Windows filesystem. + stderr.println( + " \"makensisw.exe\" not found in \"" + + makensisw.getAbsoluteFile().getParentFile().getCanonicalPath() + + "\"."); + if (!noPauseOnError) System.in.read(); + // Deliberately still 0: makensisw is never waited on, so this branch has + // no compiler status to report and changing it would alter long-standing + // behaviour on the only platform that reaches it. + return 0; + } + + // Pass the arguments individually; Runtime.exec(String) splits the + // command on whitespace and does not honour embedded quotes, so any + // path containing a space would arrive as several arguments. + new ProcessBuilder(makensisw.getAbsolutePath(), nsiFile.getCanonicalPath()).start(); + return 0; + } + + /** + * Runs the console compiler, waits for it and reports its status. + * + * @param nsiFile the assembled NSIS script + * @param stdout the standard output writer + * @param stderr the standard error writer + * @return the exit code + */ + private static int compileWithMakensis(File nsiFile, PrintWriter stdout, PrintWriter stderr) + throws IOException { + ProcessBuilder builder = new ProcessBuilder(MAKENSIS, nsiFile.getCanonicalPath()); + + // inheritIO() rather than pipes: makensis prints its progress to stdout and + // its errors to stderr, and nothing here reads them, so a pipe would fill up + // and block the compiler forever. Inheriting also hands the bytes straight to + // the console without this process decoding them in the default charset. + builder.inheritIO(); + + // The compiler writes to the same streams, so flush ours before it starts or + // the "Assembled successfully." lines appear after its output. + stdout.flush(); + stderr.flush(); + + Process process; + try { + process = builder.start(); + } catch (IOException ex) { + // ProcessBuilder gives a plain IOException whatever went wrong, so this + // cannot distinguish a missing compiler from a failed exec; in practice it + // is all but always the former. + stderr.println("Unable to compile \"" + nsiFile.getCanonicalPath() + "\":"); + stderr.println(" \"" + MAKENSIS + "\" not found on the PATH."); + // No System.in.read() here, unlike the makensisw branch. That pause exists + // so a console window opened by a double click does not close before the + // message can be read; here the terminal outlives the process anyway, and + // reading from it would suspend a backgrounded build with SIGTTIN. + return EXIT_COMPILE_FAILED; + } + + int compilerExitCode; + try { + compilerExitCode = process.waitFor(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + process.destroy(); + stderr.println("Interrupted while compiling \"" + nsiFile.getCanonicalPath() + "\"."); + return EXIT_COMPILE_FAILED; + } + + // makensis has already reported the reason on the inherited stderr; adding + // another message here would only repeat it. + if (compilerExitCode != 0) return EXIT_COMPILE_FAILED; + return 0; + } + + /** + * Determines if the assembler is running on Windows. + * + * @return true if running on Windows + */ + private static boolean isWindows() { + // Locale.ENGLISH, not the default: under a Turkish locale toLowerCase maps + // "I" to a dotless "i" and the comparison below never matches. + return System.getProperty("os.name", "").toLowerCase(Locale.ENGLISH).startsWith("windows"); + } +} diff --git a/src/nsl/ScriptParser.java b/src/nsl/ScriptParser.java index ccf7d5f..17bf29e 100644 --- a/src/nsl/ScriptParser.java +++ b/src/nsl/ScriptParser.java @@ -32,7 +32,7 @@ private ScriptParser() {} * * @param path the script file path * @param noPauseOnError do not pause on error - * @param noMakeNSIS do not run makensisw.exe + * @param noMakeNSIS do not run the NSIS compiler * @return the exit code */ public static int parse(String path, boolean noPauseOnError, boolean noMakeNSIS) @@ -49,12 +49,14 @@ public static int parse(String path, boolean noPauseOnError, boolean noMakeNSIS) try { statement = StatementList.match(); } catch (NslException ex) { + exitCode = 1; if (ex.getInner() != null) stderr.println(ex.getInner().toString()); else stderr.println(ex.getMessage()); if (!noPauseOnError) System.in.read(); } tokenizer.getReader().close(); } catch (IOException ex) { + exitCode = 1; stderr.println(ex); if (!noPauseOnError) System.in.read(); } @@ -97,8 +99,11 @@ public static int parse(String path, boolean noPauseOnError, boolean noMakeNSIS) if (writer != null) { try { writer.close(); - } finally { + } catch (IOException closeEx) { + // Ignored: a failure is already being reported below and the + // partial output file is deleted regardless. } + writer = null; outputFile.delete(); } @@ -119,23 +124,7 @@ public static int parse(String path, boolean noPauseOnError, boolean noMakeNSIS) // Build the NSIS script. if (!noMakeNSIS) { - File makensisw = new File("..\\makensisw.exe"); - if (makensisw.exists()) { - Runtime.getRuntime() - .exec( - "\"" - + makensisw.getAbsolutePath() - + "\" \"" - + outputFile.getCanonicalPath() - + "\""); - } else { - stderr.println("Unable to compile \"" + outputFile.getCanonicalPath() + "\":"); - stderr.println( - " \"makensisw.exe\" not found in \"" - + (new File(makensisw.getParent())).getCanonicalPath() - + "\"."); - if (!noPauseOnError) System.in.read(); - } + exitCode = NsisCompiler.compile(outputFile, noPauseOnError, stdout, stderr); } } } diff --git a/src/nsl/expression/Expression.java b/src/nsl/expression/Expression.java index 9c736a8..20039e6 100644 --- a/src/nsl/expression/Expression.java +++ b/src/nsl/expression/Expression.java @@ -1116,35 +1116,48 @@ else if (value.type.equals(ExpressionType.Register) throw new NslArgumentException("format", 1, ExpressionType.String); String formatString = value.toString(true); - int formatStringLength = formatString.length(); - for (int i = 0; i < formatStringLength; i++) { - // Two { characters escapes. - if (formatString.charAt(i) == '{' && formatString.charAt(++i) != '{') { - int paramNumberAt = i - 1; - String paramNumberString = ""; - - for (; i < formatStringLength; i++) { - char c = formatString.charAt(i); - if (c == '}') break; - if (c < '0' || c > '9') - throw new NslException( - "Bad parameter number for \"format\" (contains non numeric characters)", true); - paramNumberString += c; - } + // The string is rebuilt on every substitution, so its length is read afresh + // each time round rather than held in a local. + for (int i = 0; i < formatString.length(); i++) { + if (formatString.charAt(i) != '{') continue; + + // Two { characters escapes: drop the first and leave the second as text. + if (i + 1 < formatString.length() && formatString.charAt(i + 1) == '{') { + formatString = formatString.substring(0, i) + formatString.substring(i + 1); + continue; + } - int paramNumber = Integer.parseInt(paramNumberString); - if (paramNumber < 0 || paramNumber >= paramsCount) + int paramNumberAt = i; + String paramNumberString = ""; + int end = i + 1; + + for (; end < formatString.length(); end++) { + char c = formatString.charAt(end); + if (c == '}') break; + if (c < '0' || c > '9') throw new NslException( - "Parameter number for \"format\" is out of range of given parameters", true); + "Bad parameter number for \"format\" (contains non numeric characters)", true); + paramNumberString += c; + } - // Insert the parameter. - String paramValue = paramsList.get(paramNumber + 1).toString(true); - formatString = - formatString.substring(0, paramNumberAt) + paramValue + formatString.substring(i + 1); + if (end == formatString.length()) + throw new NslException("Missing \"}\" for a \"format\" parameter placeholder", true); + if (paramNumberString.isEmpty()) + throw new NslException("Missing parameter number for \"format\"", true); - // Move the new position to after the inserted parameter. - i = paramNumberAt + paramValue.length(); - } + int paramNumber = Integer.parseInt(paramNumberString); + if (paramNumber < 0 || paramNumber >= paramsCount) + throw new NslException( + "Parameter number for \"format\" is out of range of given parameters", true); + + // Insert the parameter. + String paramValue = paramsList.get(paramNumber + 1).toString(true); + formatString = + formatString.substring(0, paramNumberAt) + paramValue + formatString.substring(end + 1); + + // Carry on at the character after the inserted parameter, so that the + // inserted text is not itself rescanned for placeholders. + i = paramNumberAt + paramValue.length() - 1; } return Expression.fromString(formatString); diff --git a/src/nsl/expression/FunctionCallExpression.java b/src/nsl/expression/FunctionCallExpression.java index 10f36ab..d8b7f65 100644 --- a/src/nsl/expression/FunctionCallExpression.java +++ b/src/nsl/expression/FunctionCallExpression.java @@ -16,6 +16,7 @@ public class FunctionCallExpression extends MultipleReturnValueAssembleExpression { private final ArrayList params; private final int lineNo; + private final boolean inUninstaller; /** * Class constructor. @@ -26,6 +27,9 @@ public FunctionCallExpression(String name) { this.stringValue = name; this.params = Expression.matchList(); this.lineNo = ScriptParser.tokenizer.lineno(); + // Recorded here rather than read in assemble(): the flag tracks where the + // parser is, and by the time anything is written it has long been reset. + this.inUninstaller = Scope.inUninstaller(); } /** @@ -38,6 +42,7 @@ public FunctionCallExpression(String name, ArrayList params) { this.stringValue = name; this.params = params; this.lineNo = ScriptParser.tokenizer.lineno(); + this.inUninstaller = Scope.inUninstaller(); } /** @@ -71,8 +76,16 @@ public void assemble(Register var) throws IOException { * @param vars the variables to assign the values to */ public void assemble(ArrayList vars) throws IOException { - FunctionInfo functionInfo = - FunctionInfo.find(this.stringValue, this.params.size(), vars.size()); + // NSIS keeps installer and uninstaller functions in separate namespaces and + // rejects an unprefixed Call from uninstaller code, so a call made there has + // to resolve against the "un." prefixed name that FunctionStatement stored. + // Falling back to the plain name leaves the diagnostic below to report a + // function that genuinely does not exist. + FunctionInfo functionInfo = null; + if (this.inUninstaller) + functionInfo = FunctionInfo.find("un." + this.stringValue, this.params.size(), vars.size()); + if (functionInfo == null) + functionInfo = FunctionInfo.find(this.stringValue, this.params.size(), vars.size()); if (functionInfo == null) throw new NslException( "Function \"" diff --git a/src/nsl/instruction/InstTypeGetTextInstruction.java b/src/nsl/instruction/InstTypeGetTextInstruction.java index 1e99425..03d6bfd 100644 --- a/src/nsl/instruction/InstTypeGetTextInstruction.java +++ b/src/nsl/instruction/InstTypeGetTextInstruction.java @@ -52,7 +52,7 @@ public void assemble() throws IOException { @Override public void assemble(Register var) throws IOException { Expression varOrInstType = AssembleExpression.getRegisterOrExpression(this.instType); - ScriptParser.writeLine(name + " " + var + " " + varOrInstType); + ScriptParser.writeLine(name + " " + varOrInstType + " " + var); varOrInstType.setInUse(false); } } diff --git a/src/nsl/instruction/SectionGetFlagsInstruction.java b/src/nsl/instruction/SectionGetFlagsInstruction.java index 262c751..8bc556b 100644 --- a/src/nsl/instruction/SectionGetFlagsInstruction.java +++ b/src/nsl/instruction/SectionGetFlagsInstruction.java @@ -52,7 +52,7 @@ public void assemble() throws IOException { @Override public void assemble(Register var) throws IOException { Expression varOrIndex = AssembleExpression.getRegisterOrExpression(this.index); - ScriptParser.writeLine(name + " " + var + " " + varOrIndex); + ScriptParser.writeLine(name + " " + varOrIndex + " " + var); varOrIndex.setInUse(false); } } diff --git a/src/nsl/instruction/SectionGetInstTypesInstruction.java b/src/nsl/instruction/SectionGetInstTypesInstruction.java index 41310b1..fdd0354 100644 --- a/src/nsl/instruction/SectionGetInstTypesInstruction.java +++ b/src/nsl/instruction/SectionGetInstTypesInstruction.java @@ -52,7 +52,7 @@ public void assemble() throws IOException { @Override public void assemble(Register var) throws IOException { Expression varOrIndex = AssembleExpression.getRegisterOrExpression(this.index); - ScriptParser.writeLine(name + " " + var + " " + varOrIndex); + ScriptParser.writeLine(name + " " + varOrIndex + " " + var); varOrIndex.setInUse(false); } } diff --git a/src/nsl/instruction/SectionGetSizeInstruction.java b/src/nsl/instruction/SectionGetSizeInstruction.java index 313a92f..c585e0e 100644 --- a/src/nsl/instruction/SectionGetSizeInstruction.java +++ b/src/nsl/instruction/SectionGetSizeInstruction.java @@ -51,7 +51,7 @@ public void assemble() throws IOException { @Override public void assemble(Register var) throws IOException { Expression varOrIndex = AssembleExpression.getRegisterOrExpression(this.index); - ScriptParser.writeLine(name + " " + var + " " + varOrIndex); + ScriptParser.writeLine(name + " " + varOrIndex + " " + var); varOrIndex.setInUse(false); } } diff --git a/src/nsl/instruction/SectionGetTextInstruction.java b/src/nsl/instruction/SectionGetTextInstruction.java index de683ee..f994ab5 100644 --- a/src/nsl/instruction/SectionGetTextInstruction.java +++ b/src/nsl/instruction/SectionGetTextInstruction.java @@ -51,7 +51,7 @@ public void assemble() throws IOException { @Override public void assemble(Register var) throws IOException { Expression varOrIndex = AssembleExpression.getRegisterOrExpression(this.index); - ScriptParser.writeLine(name + " " + var + " " + varOrIndex); + ScriptParser.writeLine(name + " " + varOrIndex + " " + var); varOrIndex.setInUse(false); } } diff --git a/src/nsl/instruction/SectionSetInstTypesInstruction.java b/src/nsl/instruction/SectionSetInstTypesInstruction.java index ff739bd..619fbe8 100644 --- a/src/nsl/instruction/SectionSetInstTypesInstruction.java +++ b/src/nsl/instruction/SectionSetInstTypesInstruction.java @@ -1,5 +1,5 @@ /* - * SectionSetFlagsInstruction.java + * SectionSetInstTypesInstruction.java */ package nsl.instruction; @@ -14,7 +14,7 @@ * @author Stuart */ public class SectionSetInstTypesInstruction extends AssembleExpression { - public static final String name = "SectionSetFlags"; + public static final String name = "SectionSetInstTypes"; private final Expression index; private final Expression instTypes; diff --git a/src/nsl/instruction/SetFileAttributesInstruction.java b/src/nsl/instruction/SetFileAttributesInstruction.java index ab905de..4f16386 100644 --- a/src/nsl/instruction/SetFileAttributesInstruction.java +++ b/src/nsl/instruction/SetFileAttributesInstruction.java @@ -41,7 +41,10 @@ public SetFileAttributesInstruction(int returns) { /** Assembles the source code. */ @Override public void assemble() throws IOException { - throw new UnsupportedOperationException("Not supported."); + Expression varOrFile = AssembleExpression.getRegisterOrExpression(this.file); + AssembleExpression.assembleIfRequired(this.attributes); + ScriptParser.writeLine(name + " " + varOrFile + " " + this.attributes); + varOrFile.setInUse(false); } /** @@ -51,9 +54,6 @@ public void assemble() throws IOException { */ @Override public void assemble(Register var) throws IOException { - Expression varOrFile = AssembleExpression.getRegisterOrExpression(this.file); - AssembleExpression.assembleIfRequired(this.attributes); - ScriptParser.writeLine(name + " " + var + " " + varOrFile + " " + this.attributes); - varOrFile.setInUse(false); + throw new UnsupportedOperationException("Not supported."); } } diff --git a/src/nsl/instruction/SetFontInstruction.java b/src/nsl/instruction/SetFontInstruction.java index bd24a84..292238c 100644 --- a/src/nsl/instruction/SetFontInstruction.java +++ b/src/nsl/instruction/SetFontInstruction.java @@ -41,7 +41,7 @@ public SetFontInstruction(int returns) { if (!ExpressionType.isInteger(this.fontSize)) throw new NslArgumentException(name, 2, ExpressionType.Integer); - if (paramsCount > 1) { + if (paramsCount > 2) { this.langId = paramsList.get(2); if (!ExpressionType.isInteger(this.langId)) throw new NslArgumentException(name, 3, ExpressionType.Integer); @@ -53,15 +53,17 @@ public SetFontInstruction(int returns) { /** Assembles the source code. */ @Override public void assemble() throws IOException { - AssembleExpression.assembleIfRequired(this.fontFace); - AssembleExpression.assembleIfRequired(this.fontSize); - String write = name + " " + this.fontFace + " " + this.fontSize; + String write = name; if (this.langId != null) { AssembleExpression.assembleIfRequired(this.langId); - write += " " + this.langId; + write += " /LANG=" + this.langId; } + AssembleExpression.assembleIfRequired(this.fontFace); + AssembleExpression.assembleIfRequired(this.fontSize); + write += " " + this.fontFace + " " + this.fontSize; + ScriptParser.writeLine(write); } diff --git a/src/nsl/instruction/VIAddVersionKeyInstruction.java b/src/nsl/instruction/VIAddVersionKeyInstruction.java index 53fda06..baf989c 100644 --- a/src/nsl/instruction/VIAddVersionKeyInstruction.java +++ b/src/nsl/instruction/VIAddVersionKeyInstruction.java @@ -41,7 +41,7 @@ public VIAddVersionKeyInstruction(int returns) { if (!ExpressionType.isString(this.value)) throw new NslArgumentException(name, 2, ExpressionType.String); - if (paramsCount > 1) { + if (paramsCount > 2) { this.langId = paramsList.get(2); if (!ExpressionType.isInteger(this.langId)) throw new NslArgumentException(name, 3, ExpressionType.Integer); @@ -53,15 +53,17 @@ public VIAddVersionKeyInstruction(int returns) { /** Assembles the source code. */ @Override public void assemble() throws IOException { - AssembleExpression.assembleIfRequired(this.keyName); - AssembleExpression.assembleIfRequired(this.value); - String write = name + " " + this.keyName + " " + this.value; + String write = name; if (this.langId != null) { AssembleExpression.assembleIfRequired(this.langId); - write += " " + this.langId; + write += " /LANG=" + this.langId; } + AssembleExpression.assembleIfRequired(this.keyName); + AssembleExpression.assembleIfRequired(this.value); + write += " " + this.keyName + " " + this.value; + ScriptParser.writeLine(write); } diff --git a/src/nsl/preprocessor/DefineList.java b/src/nsl/preprocessor/DefineList.java index 0e51c1b..af66c8b 100644 --- a/src/nsl/preprocessor/DefineList.java +++ b/src/nsl/preprocessor/DefineList.java @@ -4,7 +4,7 @@ package nsl.preprocessor; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Set; import nsl.expression.Expression; @@ -14,7 +14,9 @@ * @author Stuart */ public class DefineList { - private final HashMap constants; + // LinkedHashMap, not HashMap: getNames() feeds the !define/!undef emission + // order in NSISDirective, which must not depend on the JDK's hash ordering. + private final LinkedHashMap constants; private int count; private static DefineList current = new DefineList(); @@ -30,7 +32,7 @@ public static DefineList getCurrent() { /** Class constructor. */ public DefineList() { - this.constants = new HashMap(); + this.constants = new LinkedHashMap(); this.count = 0; } diff --git a/src/nsl/statement/Statement.java b/src/nsl/statement/Statement.java index 9cde305..a50c632 100644 --- a/src/nsl/statement/Statement.java +++ b/src/nsl/statement/Statement.java @@ -382,11 +382,11 @@ public static AssembleExpression matchInstruction(int returns) { if (ScriptParser.tokenizer.match(UnicodeInstruction.name)) return new UnicodeInstruction(returns); if (ScriptParser.tokenizer.match(UninstallButtonTextInstruction.name)) - return new UninstallIconInstruction(returns); + return new UninstallButtonTextInstruction(returns); if (ScriptParser.tokenizer.match(UninstallCaptionInstruction.name)) return new UninstallCaptionInstruction(returns); if (ScriptParser.tokenizer.match(UninstallIconInstruction.name)) - return new UninstallButtonTextInstruction(returns); + return new UninstallIconInstruction(returns); if (ScriptParser.tokenizer.match(UninstallSubCaptionInstruction.name)) return new UninstallSubCaptionInstruction(returns); if (ScriptParser.tokenizer.match(UninstallTextInstruction.name)) diff --git a/test/nsl/expression/ExpressionTest.java b/test/nsl/expression/ExpressionTest.java index c4ec513..02e3c52 100644 --- a/test/nsl/expression/ExpressionTest.java +++ b/test/nsl/expression/ExpressionTest.java @@ -8,6 +8,7 @@ import java.io.OutputStreamWriter; import java.io.StringReader; +import nsl.NslException; import nsl.ScriptParser; import nsl.Tokenizer; import org.junit.After; @@ -147,4 +148,72 @@ public void testMatchComplex() { assertEquals(true == false || false != true || true == false && false != true, booleanValue); ScriptParser.tokenizer.matchEolOrDie(); } + + /** + * Assembles a single expression and returns its value as a string. Every call leaves the + * tokenizer stack as it found it. + */ + private static String evaluate(String expression) { + ScriptParser.pushTokenizer(new Tokenizer(new StringReader(expression), "ExpressionTest")); + try { + return Expression.matchComplex().toString(); + } finally { + ScriptParser.popTokenizer(); + } + } + + /** Test of the format() assemble time function, of class Expression. */ + @Test + public void testFormat() { + System.out.println("format"); + + // A substitution that leaves the string shorter than it was. + assertEquals("\"1\"", evaluate("format('{0}', 1)")); + + // One that leaves it longer, with and without literal text around it. + assertEquals("\"a-LONGVALUE-b\"", evaluate("format('a-{0}-b', 'LONGVALUE')")); + assertEquals( + "\"AAAAAAAAAA-and-BBBBBBBBBB-end\"", + evaluate("format('{0}-and-{1}-end', 'AAAAAAAAAA', 'BBBBBBBBBB')")); + + // Adjacent placeholders: nothing between them to resynchronise on. + assertEquals("\"AAAAABBBBB\"", evaluate("format('{0}{1}', 'AAAAA', 'BBBBB')")); + + // An argument may be used more than once, and in any order. + assertEquals("\"b a b\"", evaluate("format('{1} {0} {1}', 'a', 'b')")); + + // Inserted text is not rescanned, so a substituted brace stays literal. + assertEquals("\"{0} x\"", evaluate("format('{0} {1}', '{0}', 'x')")); + + // {{ escapes a brace. + assertEquals("\"{0}\"", evaluate("format('{{0}', 1)")); + assertEquals("\"{x}\"", evaluate("format('{{{0}}', 'x')")); + + // Nothing to do. + assertEquals("\"no placeholders\"", evaluate("format('no placeholders', 1)")); + } + + /** Test of the errors reported by the format() assemble time function. */ + @Test + public void testFormatErrors() { + System.out.println("format errors"); + + // An unterminated placeholder, with and without a parameter number, used to + // run off the end of the string instead of being reported. + assertFormatError("format('a{', 1)"); + assertFormatError("format('a{0', 1)"); + assertFormatError("format('{}', 1)"); + assertFormatError("format('{a}', 1)"); + assertFormatError("format('{5}', 1)"); + } + + /** Asserts that the given expression is rejected by the assembler. */ + private static void assertFormatError(String expression) { + try { + String result = evaluate(expression); + fail(expression + " was accepted and gave " + result); + } catch (NslException e) { + System.out.println(" " + expression + " -> " + e.getMessage()); + } + } }