From 3c72dc7e9dd790e76c2e40715e83279c06213944 Mon Sep 17 00:00:00 2001 From: Elias Oelschner <62939318+levno-710@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:41:24 +0100 Subject: [PATCH] fix lua 5.1 edge cases and add regression tests --- src/prometheus/cff/expressions/number.lua | 9 ++- src/prometheus/cff/register.lua | 4 +- src/prometheus/cff/statements/assignment.lua | 18 ++++- .../statements/local_variable_declaration.lua | 15 +++- src/prometheus/tokenizer.lua | 18 ++++- src/prometheus/vm/compiler.lua | 5 ++ tests/cff-assignment-store-order.lua | 44 ++++++++++++ tests/cff-discarded-expressions.lua | 49 +++++++++++++ tests/cff-terminal-position-environment.lua | 49 +++++++++++++ tests/harness/long-string-newlines.lua | 29 ++++++++ tests/harness/vm-signed-zero.lua | 69 +++++++++++++++++++ tests/long-string-newlines.lua | 5 ++ tests/signed-zero.lua | 17 +++++ 13 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 tests/cff-assignment-store-order.lua create mode 100644 tests/cff-discarded-expressions.lua create mode 100644 tests/cff-terminal-position-environment.lua create mode 100644 tests/harness/long-string-newlines.lua create mode 100644 tests/harness/vm-signed-zero.lua create mode 100644 tests/long-string-newlines.lua create mode 100644 tests/signed-zero.lua diff --git a/src/prometheus/cff/expressions/number.lua b/src/prometheus/cff/expressions/number.lua index 0b09044a..4fe5e3b9 100644 --- a/src/prometheus/cff/expressions/number.lua +++ b/src/prometheus/cff/expressions/number.lua @@ -12,7 +12,14 @@ return function(self, expression, funcDepth, numReturns) for i = 1, numReturns do regs[i] = self:allocRegister(); if i == 1 then - self:addStatement(self:setRegister(scope, regs[i], Ast.NumberExpression(expression.value)), {regs[i]}, {}, false); + if type(expression.value) == "number" and expression.value == 0 and 1 / expression.value < 0 then + -- Keep negative zero out of native Lua 5.1 numeric constants. + -- The register read makes this runtime negation distinct from a -0 literal. + self:addStatement(self:setRegister(scope, regs[i], Ast.NumberExpression(0)), {regs[i]}, {}, false); + self:addStatement(self:setRegister(scope, regs[i], Ast.NegateExpression(self:register(scope, regs[i]))), {regs[i]}, {regs[i]}, false); + else + self:addStatement(self:setRegister(scope, regs[i], Ast.NumberExpression(expression.value)), {regs[i]}, {}, false); + end else self:addStatement(self:setRegister(scope, regs[i], Ast.NilExpression()), {regs[i]}, {}, false); end diff --git a/src/prometheus/cff/register.lua b/src/prometheus/cff/register.lua index 6baaa301..f7fba7dc 100644 --- a/src/prometheus/cff/register.lua +++ b/src/prometheus/cff/register.lua @@ -6,7 +6,6 @@ local Ast = require("prometheus.ast"); local constants = require("prometheus.cff.constants"); -local randomStrings = require("prometheus.randomStrings"); local MAX_REGS = constants.MAX_REGS; @@ -226,9 +225,8 @@ return function(Compiler) function Compiler:setPos(scope, val) if not val then - local v = Ast.IndexExpression(self:env(scope), randomStrings.randomStringNode(math.random(12, 14))); scope:addReferenceToHigherScope(self.containerFuncScope, self.posVar); - return Ast.AssignmentStatement({Ast.AssignmentVariable(self.containerFuncScope, self.posVar)}, {v}); + return Ast.AssignmentStatement({Ast.AssignmentVariable(self.containerFuncScope, self.posVar)}, {Ast.NilExpression()}); end scope:addReferenceToHigherScope(self.containerFuncScope, self.posVar); return Ast.AssignmentStatement({Ast.AssignmentVariable(self.containerFuncScope, self.posVar)}, {Ast.NumberExpression(val) or Ast.NilExpression()}); diff --git a/src/prometheus/cff/statements/assignment.lua b/src/prometheus/cff/statements/assignment.lua index 71162fb4..fba6b458 100644 --- a/src/prometheus/cff/statements/assignment.lua +++ b/src/prometheus/cff/statements/assignment.lua @@ -7,6 +7,12 @@ local Ast = require("prometheus.ast"); local AstKind = Ast.AstKind; +local function isSideEffectFreeLiteral(expression) + local kind = expression.kind; + return kind == AstKind.NilExpression or kind == AstKind.BooleanExpression or + kind == AstKind.NumberExpression or kind == AstKind.StringExpression; +end + return function(self, statement, funcDepth) local scope = self.activeBlock.scope; local exprregs = {}; @@ -44,7 +50,7 @@ return function(self, statement, funcDepth) table.insert(exprregs, reg); end else - if statement.lhs[i] or expr.kind == AstKind.FunctionCallExpression or expr.kind == AstKind.PassSelfFunctionCallExpression then + if statement.lhs[i] then local reg = self:compileExpression(expr, funcDepth, 1)[1]; if(self:isVarRegister(reg)) then local ro = reg; @@ -52,11 +58,19 @@ return function(self, statement, funcDepth) self:addStatement(self:copyRegisters(scope, {reg}, {ro}), {reg}, {ro}, false); end table.insert(exprregs, reg); + else + -- Lua evaluates every RHS expression, including values that + -- have no corresponding assignment target. + if not isSideEffectFreeLiteral(expr) then + local reg = self:compileExpression(expr, funcDepth, 1)[1]; + self:freeRegister(reg, false); + end end end end - for i, primaryExpr in ipairs(statement.lhs) do + for i = #statement.lhs, 1, -1 do + local primaryExpr = statement.lhs[i]; if primaryExpr.kind == AstKind.AssignmentVariable then if primaryExpr.scope.isGlobal then local tmpReg = self:allocRegister(false); diff --git a/src/prometheus/cff/statements/local_variable_declaration.lua b/src/prometheus/cff/statements/local_variable_declaration.lua index 8294ad2b..56ece645 100644 --- a/src/prometheus/cff/statements/local_variable_declaration.lua +++ b/src/prometheus/cff/statements/local_variable_declaration.lua @@ -7,6 +7,12 @@ local Ast = require("prometheus.ast"); local AstKind = Ast.AstKind; +local function isSideEffectFreeLiteral(expression) + local kind = expression.kind; + return kind == AstKind.NilExpression or kind == AstKind.BooleanExpression or + kind == AstKind.NumberExpression or kind == AstKind.StringExpression; +end + return function(self, statement, funcDepth) local scope = self.activeBlock.scope; local exprregs = {}; @@ -17,9 +23,16 @@ return function(self, statement, funcDepth) table.insert(exprregs, reg); end else - if statement.ids[i] or expr.kind == AstKind.FunctionCallExpression or expr.kind == AstKind.PassSelfFunctionCallExpression then + if statement.ids[i] then local reg = self:compileExpression(expr, funcDepth, 1)[1]; table.insert(exprregs, reg); + else + -- Initializers past the declared names are discarded, but + -- Lua still evaluates them for their effects and errors. + if not isSideEffectFreeLiteral(expr) then + local reg = self:compileExpression(expr, funcDepth, 1)[1]; + self:freeRegister(reg, false); + end end end end diff --git a/src/prometheus/tokenizer.lua b/src/prometheus/tokenizer.lua index d0ef1f35..f80c8543 100644 --- a/src/prometheus/tokenizer.lua +++ b/src/prometheus/tokenizer.lua @@ -445,15 +445,27 @@ function Tokenizer:multiLineString() -- Multiline String -- Parse String to Closing bracket but also consider that the count of equal signs must be the same - -- Skip Leading newline if existing + -- Lua normalizes CR, LF, CRLF, and LFCR line breaks to LF in long strings. + local function normalizedNewline(char) + local nextChar = peek(self) + if (char == "\r" and nextChar == "\n") or (char == "\n" and nextChar == "\r") then + self.index = self.index + 1 + end + return "\n" + end + + -- Skip one leading newline sequence, if present. self.index = self.index + 1; - if(is(self, "\n")) then - self.index = self.index + 1; + if(is(self, "\n") or is(self, "\r")) then + normalizedNewline(get(self)) end local value = ""; while true do local char = get(self); + if char == "\n" or char == "\r" then + char = normalizedNewline(char) + end if(char == ']') then local eqCount2 = 0; while(is(self, "=")) do diff --git a/src/prometheus/vm/compiler.lua b/src/prometheus/vm/compiler.lua index e0249387..ec85b9fc 100644 --- a/src/prometheus/vm/compiler.lua +++ b/src/prometheus/vm/compiler.lua @@ -93,6 +93,11 @@ end function Compiler:load(value) local register = self:alloc() if value == nil then self:emit('NIL', register) + elseif type(value) == 'number' and value == 0 and 1 / value < 0 then + -- Lua 5.1 merges signed-zero literals in one native function's constants. + -- Materialize the sign with a VM operation so it cannot be lost in emitted data. + self:emit('LOAD', register, self:constant(0)) + self:emit('NEG', register, register) else self:emit('LOAD', register, self:constant(value)) end return register end diff --git a/tests/cff-assignment-store-order.lua b/tests/cff-assignment-store-order.lua new file mode 100644 index 00000000..fe081308 --- /dev/null +++ b/tests/cff-assignment-store-order.lua @@ -0,0 +1,44 @@ +-- @runtime lua51 +-- Parallel assignments evaluate targets and values first, then store right to left. +local value +value, value = 10, 20 +assert(value == 10) + +local writes = {} +local proxy = setmetatable({}, { + __newindex = function(_, key, assigned) + writes[#writes + 1] = key .. "=" .. assigned + end, +}) +proxy.a, proxy.b, proxy.c = 1, 2, 3 +assert(table.concat(writes, ",") == "c=3,b=2,a=1") + +local repeated = {} +repeated[1], repeated[1], repeated[1] = 10, 20, 30 +assert(repeated[1] == 10) + +-- Indexed targets retain their table/key before later target and RHS writes. +local tableValue = {old = 0} +local saved = tableValue +local key = "old" +tableValue, tableValue[key] = {}, 5 +assert(tableValue.old == nil and saved.old == 5) + +local order = {} +local target = {} +local counter = 0 +local function targetKey() + order[#order + 1] = "lhs" + counter = counter + 1 + return counter +end +local function rightValue() + order[#order + 1] = "rhs" + counter = counter + 1 + return counter +end +target[targetKey()], target[targetKey()] = rightValue(), rightValue() +assert(table.concat(order, ",") == "lhs,lhs,rhs,rhs") +assert(target[1] == 3 and target[2] == 4) + +print("OK") diff --git a/tests/cff-discarded-expressions.lua b/tests/cff-discarded-expressions.lua new file mode 100644 index 00000000..c5664521 --- /dev/null +++ b/tests/cff-discarded-expressions.lua @@ -0,0 +1,49 @@ +-- @runtime lua51 +-- Discarded expressions in assignment and local initializer lists still run. +local calls = 0 +local object = setmetatable({}, { + __index = function() + calls = calls + 1 + return 17 + end, + __add = function() + calls = calls + 10 + return 23 + end, +}) + +local first +first = 1, object.missing +assert(calls == 1) + +first = 1, object + object +assert(calls == 11) + +local ok = pcall(function() + first = 1, nil + {} +end) +assert(not ok, "discarded operator error must propagate") + +local localValue = 1, object.missing +assert(localValue == 1 and calls == 12) + +-- A discarded global read must still consult the active function environment. +local originalSetfenv = setfenv +local reads = 0 +local env = setmetatable({}, { + __index = function(_, key) + if key == "unusedGlobal" then + reads = reads + 1 + return true + end + error("unexpected global lookup: " .. tostring(key), 0) + end, +}) +local function readDiscardedGlobal() + local value = 1, unusedGlobal + return value +end +originalSetfenv(readDiscardedGlobal, env) +assert(readDiscardedGlobal() == 1 and reads == 1) + +print("OK") diff --git a/tests/cff-terminal-position-environment.lua b/tests/cff-terminal-position-environment.lua new file mode 100644 index 00000000..4e224c63 --- /dev/null +++ b/tests/cff-terminal-position-environment.lua @@ -0,0 +1,49 @@ +-- @runtime lua51 +-- Ending Cff dispatch must not perform a random global/environment lookup. +local originalSetfenv = setfenv +local originalAssert = assert +local function strictEnvironment() + return setmetatable({}, { + __index = function(_, key) + error("unexpected environment lookup: " .. tostring(key), 0) + end, + }) +end + +local function explicitReturn() + return 42 +end +originalSetfenv(explicitReturn, strictEnvironment()) +originalAssert(explicitReturn() == 42) + +local function fallthrough() + local value = 1 +end +originalSetfenv(fallthrough, strictEnvironment()) +originalAssert(fallthrough() == nil) + +local function outer() + local function nested() + return 7 + end + return nested +end +originalSetfenv(outer, strictEnvironment()) +local nested = outer() +originalAssert(nested() == 7) + +-- Also verify the no-error case does not make an extra lookup. +local lookups = {} +local loggingEnv = setmetatable({}, { + __index = function(_, key) + lookups[#lookups + 1] = key + return nil + end, +}) +local function loggedReturn() + return 9 +end +originalSetfenv(loggedReturn, loggingEnv) +originalAssert(loggedReturn() == 9 and #lookups == 0) + +print("OK") diff --git a/tests/harness/long-string-newlines.lua b/tests/harness/long-string-newlines.lua new file mode 100644 index 00000000..abe260ae --- /dev/null +++ b/tests/harness/long-string-newlines.lua @@ -0,0 +1,29 @@ +require("src.prometheus") -- Sets the repository module path used by tokenizer dependencies. +local Tokenizer = require("prometheus.tokenizer") + +local function check(body, expected) + local source = "[=[" .. body .. "]=]" + local tokenizer = Tokenizer:new({LuaVersion = "Lua51"}) + tokenizer:append(source) + local token = tokenizer:next() + assert(token.kind == Tokenizer.TokenKind.String) + assert(token.value == expected, string.format("expected %q, got %q", expected, token.value)) + assert(token.startPos == 0 and token.endPos == #source, "token positions must use raw source bytes") + assert(token.source == source, "token source must retain original newline bytes") +end + +-- Lua drops one initial newline sequence, for every accepted line ending. +for _, newline in ipairs({"\n", "\r", "\r\n", "\n\r"}) do + check(newline .. "body", "body") +end + +-- Internal newline sequences normalize to LF; equal adjacent bytes stay distinct. +check("a\nb\rc\r\nd\n\re", "a\nb\nc\nd\ne") +check("a\r\rb\n\nc", "a\n\nb\n\nc") + +-- A newline immediately before the closing delimiter is still part of the value. +for _, newline in ipairs({"\n", "\r", "\r\n", "\n\r"}) do + check("body" .. newline, "body\n") +end + +print("Long string newline normalization checks passed") diff --git a/tests/harness/vm-signed-zero.lua b/tests/harness/vm-signed-zero.lua new file mode 100644 index 00000000..83a5c39b --- /dev/null +++ b/tests/harness/vm-signed-zero.lua @@ -0,0 +1,69 @@ +local Prometheus = require('src.prometheus') +Prometheus.Logger.logLevel = Prometheus.Logger.LogLevel.Error +local Parser = require('prometheus.parser') +local Compiler = require('prometheus.vm.compiler') +local Emit = require('prometheus.vm.emit') +local opcodes = require('prometheus.vm.opcodes') + +local source = [[ + local function negativeIntegerZero() return -0 end + local function negativeDecimalZero() return -0.0 end + local function negativeProductZero() return 0 * -1 end + local function positiveZero() return 0 end + local function nestedNegativeZero() + local function inner() return -0 end + return inner() + end + assert(1 / negativeIntegerZero() == -math.huge) + assert(1 / negativeDecimalZero() == -math.huge) + assert(1 / negativeProductZero() == -math.huge) + assert(1 / nestedNegativeZero() == -math.huge) + assert(1 / positiveZero() == math.huge) +]] + +local proto = Compiler:compile(Parser:new({LuaVersion = 'Lua51'}):parse(source)) +local negativeZeroLoads = 0 +local function checkPrototype(value) + for _, constant in ipairs(value.constants) do + assert(not (type(constant) == 'number' and constant == 0 and 1 / constant < 0), + 'VM prototype must not contain a negative-zero literal') + end + for _, instruction in ipairs(value.code) do + if instruction[1] == opcodes.NEG then negativeZeroLoads = negativeZeroLoads + 1 end + end + for _, child in ipairs(value.children) do checkPrototype(child) end +end +checkPrototype(proto) +assert(negativeZeroLoads >= 4, 'negative-zero literals must lower to VM NEG in their prototypes') + +assert(loadstring(Emit.source(proto)))() +print('VM signed-zero checks passed') + +for _, preset in ipairs({'Cff', 'Weak'}) do + for _, seed in ipairs({1, 2, 17}) do + local config = require('src.presets')[preset] + config.Seed = seed + local generated = Prometheus.Pipeline:fromConfig(config):apply(source) + assert(loadstring(generated))() + end +end + +-- Keep more than MAX_REGS locals live so the negative-zero register spills. +local locals, checks = {}, {} +for i = 1, 130 do + locals[i] = 'local value' .. i .. '=' .. i + checks[i] = 'assert(value' .. i .. '==' .. i .. ')' +end +local spillSource = table.concat(locals, '\n') .. [[ + local negative = -0 + assert(1 / negative == -math.huge) +]] .. table.concat(checks, '\n') +for _, preset in ipairs({'Cff', 'Weak'}) do + for _, seed in ipairs({1, 2, 17}) do + local config = require('src.presets')[preset] + config.Seed = seed + local generated = Prometheus.Pipeline:fromConfig(config):apply(spillSource) + assert(loadstring(generated))() + end +end +print('Cff signed-zero and spill checks passed') diff --git a/tests/long-string-newlines.lua b/tests/long-string-newlines.lua new file mode 100644 index 00000000..40a94b96 --- /dev/null +++ b/tests/long-string-newlines.lua @@ -0,0 +1,5 @@ +-- @runtime lua51 +-- Raw CR bytes below are intentional: Lua omits the initial CR and normalizes internal CR to LF. +local value = [=[ hello world]=] +assert(value == "hello\nworld") +print("long string newline OK") diff --git a/tests/signed-zero.lua b/tests/signed-zero.lua new file mode 100644 index 00000000..6d4de4b9 --- /dev/null +++ b/tests/signed-zero.lua @@ -0,0 +1,17 @@ +-- @runtime lua51 +-- Negative zero literals and folded expressions keep their sign through Vmify. +local function negativeIntegerZero() return -0 end +local function negativeDecimalZero() return -0.0 end +local function negativeProductZero() return 0 * -1 end +local function positiveZero() return 0 end +local function nestedNegativeZero() + local function inner() return -0 end + return inner() +end + +assert(1 / negativeIntegerZero() == -math.huge) +assert(1 / negativeDecimalZero() == -math.huge) +assert(1 / negativeProductZero() == -math.huge) +assert(1 / nestedNegativeZero() == -math.huge) +assert(1 / positiveZero() == math.huge) +print('signed zero OK')