From 516d3fa6fa41c2bfb3dce10883b39b0972a11357 Mon Sep 17 00:00:00 2001 From: fl Date: Fri, 26 Jun 2026 02:15:26 +0800 Subject: [PATCH 1/6] feat(M8b): $formatInteger/$parseInteger decimal-digit-pattern + analyse New functions/formatinteger.lua: analyse_integer_picture + decimal-digit-pattern format/parse (Unicode digit families, regular+irregular grouping, ordinal suffix with the teen exception, D3131 mixed-group). Registers $formatInteger / $parseInteger ; roman/letters/words branches follow. Hoists codepoint/from_codepoint into H (shared with formatnumber, and M8c next). Co-Authored-By: Claude Opus 4.8 --- spec/formatinteger_spec.lua | 61 +++++ src/jsonata/errors.lua | 2 + src/jsonata/functions/formatinteger.lua | 327 ++++++++++++++++++++++++ src/jsonata/functions/formatnumber.lua | 36 +-- src/jsonata/functions/helpers.lua | 30 +++ src/jsonata/functions/init.lua | 1 + 6 files changed, 424 insertions(+), 33 deletions(-) create mode 100644 spec/formatinteger_spec.lua create mode 100644 src/jsonata/functions/formatinteger.lua diff --git a/spec/formatinteger_spec.lua b/spec/formatinteger_spec.lua new file mode 100644 index 0000000..a7e2200 --- /dev/null +++ b/spec/formatinteger_spec.lua @@ -0,0 +1,61 @@ +local jsonata = require("jsonata") +local function run(src, input) + return jsonata.compile(src):evaluate(input) +end + +describe("M8b: $formatInteger decimal-digit-pattern", function() + it("padding + mandatory/optional digits", function() + assert.are.equal("123", run("$formatInteger(123, '000')")) + assert.are.equal("0123", run("$formatInteger(123, '0000')")) + assert.are.equal("-0003", run("$formatInteger(-3, '0000')")) + assert.are.equal("1234", run("$formatInteger(1234, '###0')")) + assert.are.equal("12", run("$formatInteger(12, '###0')")) + assert.are.equal("12", run("$formatInteger(12.6, '###0')")) + end) + it("grouping regular + irregular", function() + assert.are.equal("12", run("$formatInteger(12, '#,##0')")) + assert.are.equal("1,200", run("$formatInteger(1200, '#,##0')")) + assert.are.equal("12,345,678", run("$formatInteger(12345678, '#,##0')")) + assert.are.equal("1234:567,890", run("$formatInteger(1234567890, '#:###,##0')")) + assert.are.equal("12345,67,890", run("$formatInteger(1234567890, '##,##,##0')")) + end) + it("ordinal suffix + teen exception", function() + assert.are.equal("1st", run("$formatInteger(1, '0;o')")) + assert.are.equal("123rd", run("$formatInteger(123, '000;o')")) + assert.are.equal("28th", run("$formatInteger(28, '#0;o')")) + assert.are.equal("12th", run("$formatInteger(12, '###0;o')")) + end) + it("unicode digit families", function() + assert.are.equal("١٢٣٤٠", run("$formatInteger(12340, '###١')")) + assert.are.equal("12340", run("$formatInteger(12340, '###0')")) + end) + it("D3131 mixed digit groups", function() + local ok, err = pcall(run, "$formatInteger(12340, '##00')") + assert.is_false(ok) + assert.are.equal("D3131", err.code) + end) + it("undefined -> undefined", function() + assert.is_nil(run("$formatInteger(blah, '0')", {})) + end) +end) + +describe("M8b: $parseInteger decimal-digit-pattern", function() + it("digits + padding + grouping", function() + assert.are.equal(123, run("$parseInteger('123', '000')")) + assert.are.equal(123, run("$parseInteger('0123', '0000')")) + assert.are.equal(1234, run("$parseInteger('1234', '###0')")) + assert.are.equal(1200, run("$parseInteger('1,200', '#,##0')")) + assert.are.equal(12345678, run("$parseInteger('12,345,678', '#,##0')")) + assert.are.equal(1234567890, run("$parseInteger('1234:567,890', '#:###,##0')")) + assert.are.equal(1234567890, run("$parseInteger('12345,67,890', '##,##,##0')")) + end) + it("ordinal + families", function() + assert.are.equal(123, run("$parseInteger('123rd', '000;o')")) + assert.are.equal(28, run("$parseInteger('28th', '#0;o')")) + assert.are.equal(12340, run("$parseInteger('١٢٣٤٠', '###١')")) + assert.are.equal(12340, run("$parseInteger('12340', '###0')")) + end) + it("undefined -> undefined", function() + assert.is_nil(run("$parseInteger(blah, '0')", {})) + end) +end) diff --git a/src/jsonata/errors.lua b/src/jsonata/errors.lua index ad03d5d..7e79fae 100644 --- a/src/jsonata/errors.lua +++ b/src/jsonata/errors.lua @@ -62,6 +62,8 @@ local MESSAGES = { D3091 = "The fractional part of the sub-picture must not contain an instance of the 'optional digit character' that is followed by a member of the 'decimal digit family'", D3092 = "A sub-picture that contains a 'percent' or 'per-mille' character must not contain a character treated as an 'exponent-separator'", D3093 = "The exponent part of the sub-picture must comprise only of one or more characters that are members of the 'decimal digit family'", + D3130 = "Formatting or parsing an integer as a sequence starting with {{value}} is not supported by this implementation", + D3131 = "In a decimal digit pattern, all digits must be from the same decimal group", D3137 = "$error() function evaluated", D3138 = "The single() function expected exactly 1 matching result. Instead it matched more.", D3139 = "The single() function expected exactly 1 matching result. Instead it matched 0.", diff --git a/src/jsonata/functions/formatinteger.lua b/src/jsonata/functions/formatinteger.lua new file mode 100644 index 0000000..179ef62 --- /dev/null +++ b/src/jsonata/functions/formatinteger.lua @@ -0,0 +1,327 @@ +local V = require("jsonata.value") +local H = require("jsonata.functions.helpers") + +-- =========================================================================== +-- Faithful port of jsonata-js v2.2.1 fn:format-integer machinery +-- (jsonata.js: analyseIntegerPicture 327-477, _formatInteger 247-317, +-- generateRegex integer branch 1045-1108). Internals exported under +-- `R._internal` for M8c ($formatDateTime/$parseDateTime) reuse. +-- =========================================================================== + +-- the 37 Unicode decimal-zero codepoints (jsonata.js:320) +local DECIMAL_GROUPS = { + 0x30, + 0x0660, + 0x06F0, + 0x07C0, + 0x0966, + 0x09E6, + 0x0A66, + 0x0AE6, + 0x0B66, + 0x0BE6, + 0x0C66, + 0x0CE6, + 0x0D66, + 0x0DE6, + 0x0E50, + 0x0ED0, + 0x0F20, + 0x1040, + 0x1090, + 0x17E0, + 0x1810, + 0x1946, + 0x19D0, + 0x1A80, + 0x1A90, + 0x1B50, + 0x1BB0, + 0x1C40, + 0x1C50, + 0xA620, + 0xA8D0, + 0xA900, + 0xA9D0, + 0xA9F0, + 0xAA50, + 0xABF0, + 0xFF10, +} + +-- ---- analyse_integer_picture (jsonata analyseIntegerPicture) -------------- +local function analyse_integer_picture(picture) + local format = { type = "integer", primary = "decimal", case = "lower", ordinal = false } + + local chars = H.utf8_chars(picture) + -- lastIndexOf(';') + local semicolon = -1 + for i = #chars, 1, -1 do + if chars[i] == ";" then + semicolon = i + break + end + end + local primaryFormat + if semicolon == -1 then + primaryFormat = picture + else + primaryFormat = table.concat(chars, "", 1, semicolon - 1) + local modifier = table.concat(chars, "", semicolon + 1) + if modifier:sub(1, 1) == "o" then + format.ordinal = true + end + end + + if primaryFormat == "A" then + format.case = "upper" + format.primary = "letters" + elseif primaryFormat == "a" then + format.primary = "letters" + elseif primaryFormat == "I" then + format.case = "upper" + format.primary = "roman" + elseif primaryFormat == "i" then + format.primary = "roman" + elseif primaryFormat == "W" then + format.case = "upper" + format.primary = "words" + elseif primaryFormat == "Ww" then + format.case = "title" + format.primary = "words" + elseif primaryFormat == "w" then + format.primary = "words" + else + -- decimal-digit-pattern: reverse the codepoints so separator positions count from the right + local pchars = H.utf8_chars(primaryFormat) + local codepoints = {} + for i = #pchars, 1, -1 do + codepoints[#codepoints + 1] = H.codepoint(pchars[i]) + end + local zeroCode, mandatoryDigits, optionalDigits, separatorPosition = nil, 0, 0, 0 + local groupingSeparators = {} + for _, cp in ipairs(codepoints) do + local digit = false + for _, group in ipairs(DECIMAL_GROUPS) do + if cp >= group and cp <= group + 9 then + digit = true + mandatoryDigits = mandatoryDigits + 1 + separatorPosition = separatorPosition + 1 + if zeroCode == nil then + zeroCode = group + elseif group ~= zeroCode then + H.err("D3131", {}) + end + break + end + end + if not digit then + if cp == 0x23 then -- '#' + separatorPosition = separatorPosition + 1 + optionalDigits = optionalDigits + 1 + else + groupingSeparators[#groupingSeparators + 1] = { position = separatorPosition, character = H.from_codepoint(cp) } + end + end + end + if mandatoryDigits > 0 then + format.primary = "decimal" + format.zeroCode = zeroCode + format.mandatoryDigits = mandatoryDigits + format.optionalDigits = optionalDigits + -- regular grouping? all same char + GCD-of-positions divides every position + local regular = 0 + if #groupingSeparators > 0 then + local sepChar = groupingSeparators[1].character + local same = true + for i = 2, #groupingSeparators do + if groupingSeparators[i].character ~= sepChar then + same = false + break + end + end + if same then + local function gcd(a, b) + if b == 0 then + return a + end + return gcd(b, a % b) + end + local factor = groupingSeparators[1].position + for i = 2, #groupingSeparators do + factor = gcd(factor, groupingSeparators[i].position) + end + local ok = true + for index = 1, #groupingSeparators do + local target = index * factor + local found = false + for _, s in ipairs(groupingSeparators) do + if s.position == target then + found = true + break + end + end + if not found then + ok = false + break + end + end + if ok then + regular = factor + end + end + end + if regular > 0 then + format.regular = true + format.groupingSeparators = { position = regular, character = groupingSeparators[1].character } + else + format.regular = false + format.groupingSeparators = groupingSeparators + end + else + format.primary = "sequence" + format.token = primaryFormat + end + end + return format +end + +-- ---- decimal format (jsonata _formatInteger DECIMAL branch) --------------- +local function format_decimal(value, format) + -- value is a non-negative integer here (sign handled by caller) + local digits = H.utf8_chars(tostring(value)) -- ASCII digits + -- left-pad with '0' to mandatoryDigits + local padLength = format.mandatoryDigits - #digits + if padLength > 0 then + local pad = {} + for _ = 1, padLength do + pad[#pad + 1] = "0" + end + for _, d in ipairs(digits) do + pad[#pad + 1] = d + end + digits = pad + end + -- map ASCII digits into the configured family + if format.zeroCode ~= 0x30 then + for i, ch in ipairs(digits) do + digits[i] = H.from_codepoint(ch:byte(1) + format.zeroCode - 0x30) + end + end + -- insert grouping separators (operating on the codepoint array; positions = char count) + if format.regular then + local pos = format.groupingSeparators.position + local n = math.floor((#digits - 1) / pos) + for ii = n, 1, -1 do + local at = #digits - ii * pos -- 0-based index to insert before + table.insert(digits, at + 1, format.groupingSeparators.character) + end + else + -- explicit separators, applied right-to-left (reverse order) + for i = #format.groupingSeparators, 1, -1 do + local sep = format.groupingSeparators[i] + local at = #digits - sep.position -- 0-based + table.insert(digits, at + 1, sep.character) + end + end + local s = table.concat(digits) + -- ordinal suffix + if format.ordinal then + local suffix123 = { ["1"] = "st", ["2"] = "nd", ["3"] = "rd" } + local last = s:sub(-1) + local suffix = suffix123[last] + if (not suffix) or (#s > 1 and s:sub(-2, -2) == "1") then + suffix = "th" + end + s = s .. suffix + end + return s +end + +-- ---- decimal parse (jsonata generateRegex DECIMAL .parse) ------------------ +local function parse_decimal(value, format) + local digits = value + if format.ordinal then + digits = digits:sub(1, #digits - 2) + end + if format.regular then + digits = digits:gsub(",", "") -- jsonata strips literal ',' for regular grouping + else + for _, sep in ipairs(format.groupingSeparators) do + digits = digits:gsub("%" .. sep.character, "") -- escape; sep chars are single + end + end + if format.zeroCode ~= 0x30 then + local chars = H.utf8_chars(digits) + local out = {} + for _, ch in ipairs(chars) do + out[#out + 1] = string.char(H.codepoint(ch) - format.zeroCode + 0x30) + end + digits = table.concat(out) + end + return tonumber(digits) +end + +-- ---- format dispatch (jsonata _formatInteger) ----------------------------- +local function format_integer_spec(value, format) + local negative = value < 0 + value = math.abs(value) + local out + if format.primary == "decimal" then + out = format_decimal(value, format) + elseif format.primary == "sequence" then + H.err("D3130", { value = format.token }) + else + -- roman / letters / words filled in Tasks 2-3 + H.err("D3130", { value = format.token or format.primary }) + end + if negative then + out = "-" .. out + end + return out +end + +-- ---- parse dispatch (jsonata generateRegex integer branch + parseInteger) -- +local function integer_parser(format) + if format.primary == "decimal" then + return function(value) + return parse_decimal(value, format) + end + elseif format.primary == "sequence" then + return function() + H.err("D3130", { value = format.token }) + end + else + -- roman / letters / words filled in Tasks 2-3 + return function() + H.err("D3130", { value = format.token or format.primary }) + end + end +end + +-- ---- builtins ------------------------------------------------------------- +local R = {} + +R.formatInteger = H.def(function(value, picture) + if V.is_nothing(value) then + return V.NOTHING + end + value = math.floor(value) + return format_integer_spec(value, analyse_integer_picture(picture)) +end, 2, 2, "") + +R.parseInteger = H.def(function(value, picture) + if V.is_nothing(value) then + return V.NOTHING + end + return integer_parser(analyse_integer_picture(picture))(value) +end, 2, 2, "") + +-- exported for M8c reuse +R._internal = { + analyse = analyse_integer_picture, + format = format_integer_spec, + parser = integer_parser, +} + +return R diff --git a/src/jsonata/functions/formatnumber.lua b/src/jsonata/functions/formatnumber.lua index 6ddb678..bbbb6db 100644 --- a/src/jsonata/functions/formatnumber.lua +++ b/src/jsonata/functions/formatnumber.lua @@ -135,36 +135,6 @@ local function slice(cs, a, b) return substring(cs, a, b) end --- Codepoint of the first character of a single-char string (UTF-8 decode). -local function codepoint(ch) - local b1 = ch:byte(1) - if not b1 then - return 0 - end - if b1 < 0x80 then - return b1 - elseif b1 < 0xE0 then - return (b1 - 0xC0) * 0x40 + (ch:byte(2) - 0x80) - elseif b1 < 0xF0 then - return (b1 - 0xE0) * 0x1000 + (ch:byte(2) - 0x80) * 0x40 + (ch:byte(3) - 0x80) - else - return (b1 - 0xF0) * 0x40000 + (ch:byte(2) - 0x80) * 0x1000 + (ch:byte(3) - 0x80) * 0x40 + (ch:byte(4) - 0x80) - end -end - --- UTF-8 encode a codepoint -> string. -local function from_codepoint(cp) - if cp < 0x80 then - return string.char(cp) - elseif cp < 0x800 then - return string.char(0xC0 + math.floor(cp / 0x40), 0x80 + (cp % 0x40)) - elseif cp < 0x10000 then - return string.char(0xE0 + math.floor(cp / 0x1000), 0x80 + (math.floor(cp / 0x40) % 0x40), 0x80 + (cp % 0x40)) - else - return string.char(0xF0 + math.floor(cp / 0x40000), 0x80 + (math.floor(cp / 0x1000) % 0x40), 0x80 + (math.floor(cp / 0x40) % 0x40), 0x80 + (cp % 0x40)) - end -end - R.formatNumber = H.def(function(value, picture, options) -- undefined inputs always return undefined if V.is_nothing(value) then @@ -179,7 +149,7 @@ R.formatNumber = H.def(function(value, picture, options) ["minus-sign"] = "-", ["NaN"] = "NaN", ["percent"] = "%", - ["per-mille"] = from_codepoint(0x2030), + ["per-mille"] = H.from_codepoint(0x2030), ["zero-digit"] = "0", ["digit"] = "#", ["pattern-separator"] = ";", @@ -193,9 +163,9 @@ R.formatNumber = H.def(function(value, picture, options) end local decimalDigitFamily = {} - local zeroCharCode = codepoint(properties["zero-digit"]) + local zeroCharCode = H.codepoint(properties["zero-digit"]) for ii = zeroCharCode, zeroCharCode + 9 do - decimalDigitFamily[#decimalDigitFamily + 1] = from_codepoint(ii) + decimalDigitFamily[#decimalDigitFamily + 1] = H.from_codepoint(ii) end local activeChars = {} diff --git a/src/jsonata/functions/helpers.lua b/src/jsonata/functions/helpers.lua index ad300d3..38e66d4 100644 --- a/src/jsonata/functions/helpers.lua +++ b/src/jsonata/functions/helpers.lua @@ -104,6 +104,36 @@ function H.utf8_len(s) return #H.utf8_chars(s) end +-- UTF-8 decode a single-codepoint character string -> codepoint number. +function H.codepoint(ch) + local b1 = ch:byte(1) + if not b1 then + return nil + end + if b1 < 0x80 then + return b1 + elseif b1 < 0xE0 then + return (b1 - 0xC0) * 0x40 + (ch:byte(2) - 0x80) + elseif b1 < 0xF0 then + return (b1 - 0xE0) * 0x1000 + (ch:byte(2) - 0x80) * 0x40 + (ch:byte(3) - 0x80) + else + return (b1 - 0xF0) * 0x40000 + (ch:byte(2) - 0x80) * 0x1000 + (ch:byte(3) - 0x80) * 0x40 + (ch:byte(4) - 0x80) + end +end + +-- UTF-8 encode a codepoint number -> string. +function H.from_codepoint(cp) + if cp < 0x80 then + return string.char(cp) + elseif cp < 0x800 then + return string.char(0xC0 + math.floor(cp / 0x40), 0x80 + (cp % 0x40)) + elseif cp < 0x10000 then + return string.char(0xE0 + math.floor(cp / 0x1000), 0x80 + (math.floor(cp / 0x40) % 0x40), 0x80 + (cp % 0x40)) + else + return string.char(0xF0 + math.floor(cp / 0x40000), 0x80 + (math.floor(cp / 0x1000) % 0x40), 0x80 + (math.floor(cp / 0x40) % 0x40), 0x80 + (cp % 0x40)) + end +end + -- Raise a JSONata runtime error. function H.err(code, info) errors.raise(code, info or {}) diff --git a/src/jsonata/functions/init.lua b/src/jsonata/functions/init.lua index 24161b7..1c63ba6 100644 --- a/src/jsonata/functions/init.lua +++ b/src/jsonata/functions/init.lua @@ -8,6 +8,7 @@ local categories = { require("jsonata.functions.string"), require("jsonata.functions.numeric"), require("jsonata.functions.formatnumber"), + require("jsonata.functions.formatinteger"), require("jsonata.functions.aggregation"), require("jsonata.functions.array"), require("jsonata.functions.object"), From 036106681644f75b67d0a1e2497f2d38e4c48656 Mon Sep 17 00:00:00 2001 From: fl Date: Fri, 26 Jun 2026 02:23:42 +0800 Subject: [PATCH 2/6] fix(M8b): $formatInteger large integers render without exponent format_decimal used tostring(value) which yields scientific notation for large integer-valued floats (1000000000000000 -> "1e+15"). Use H.num_to_str, which formats integer-valued numbers via %d. Matches the oracle. Co-Authored-By: Claude Opus 4.8 --- spec/formatinteger_spec.lua | 4 ++++ src/jsonata/functions/formatinteger.lua | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/formatinteger_spec.lua b/spec/formatinteger_spec.lua index a7e2200..b7faf44 100644 --- a/spec/formatinteger_spec.lua +++ b/spec/formatinteger_spec.lua @@ -19,6 +19,10 @@ describe("M8b: $formatInteger decimal-digit-pattern", function() assert.are.equal("1234:567,890", run("$formatInteger(1234567890, '#:###,##0')")) assert.are.equal("12345,67,890", run("$formatInteger(1234567890, '##,##,##0')")) end) + it("large integers render without exponent", function() + assert.are.equal("1000000000000000", run("$formatInteger(1000000000000000, '0')")) + assert.are.equal("123,456,789,012,345", run("$formatInteger(123456789012345, '#,##0')")) + end) it("ordinal suffix + teen exception", function() assert.are.equal("1st", run("$formatInteger(1, '0;o')")) assert.are.equal("123rd", run("$formatInteger(123, '000;o')")) diff --git a/src/jsonata/functions/formatinteger.lua b/src/jsonata/functions/formatinteger.lua index 179ef62..754e069 100644 --- a/src/jsonata/functions/formatinteger.lua +++ b/src/jsonata/functions/formatinteger.lua @@ -189,7 +189,7 @@ end -- ---- decimal format (jsonata _formatInteger DECIMAL branch) --------------- local function format_decimal(value, format) -- value is a non-negative integer here (sign handled by caller) - local digits = H.utf8_chars(tostring(value)) -- ASCII digits + local digits = H.utf8_chars(H.num_to_str(value)) -- ASCII digits -- left-pad with '0' to mandatoryDigits local padLength = format.mandatoryDigits - #digits if padLength > 0 then From b0f47f913f0e80cc2000e0d941b1fa6cba9b9c22 Mon Sep 17 00:00:00 2001 From: fl Date: Fri, 26 Jun 2026 02:26:11 +0800 Subject: [PATCH 3/6] feat(M8b): roman + letters integer format/parse decimalToRoman/romanToDecimal (subtractive) and bijective base-26 decimalToLetters/lettersToDecimal, both directions; D3130 for unsupported sequence tokens. Co-Authored-By: Claude Opus 4.8 --- spec/formatinteger_spec.lua | 37 ++++++++++ src/jsonata/functions/formatinteger.lua | 90 ++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/spec/formatinteger_spec.lua b/spec/formatinteger_spec.lua index b7faf44..c4beda3 100644 --- a/spec/formatinteger_spec.lua +++ b/spec/formatinteger_spec.lua @@ -63,3 +63,40 @@ describe("M8b: $parseInteger decimal-digit-pattern", function() assert.is_nil(run("$parseInteger(blah, '0')", {})) end) end) + +describe("M8b: roman + letters", function() + it("formatInteger roman", function() + assert.are.equal("", run("$formatInteger(0, 'I')")) + assert.are.equal("MCMLXXXIV", run("$formatInteger(1984, 'I')")) + assert.are.equal("xcix", run("$formatInteger(99, 'i')")) + end) + it("parseInteger roman", function() + assert.are.equal(0, run("$parseInteger('', 'I')")) + assert.are.equal(1984, run("$parseInteger('MCMLXXXIV', 'I')")) + assert.are.equal(99, run("$parseInteger('xcix', 'i')")) + end) + it("formatInteger letters", function() + assert.are.equal("A", run("$formatInteger(1, 'A')")) + assert.are.equal("l", run("$formatInteger(12, 'a')")) + assert.are.equal("z", run("$formatInteger(26, 'a')")) + assert.are.equal("aa", run("$formatInteger(27, 'a')")) + assert.are.equal("KN", run("$formatInteger(300, 'A')")) + assert.are.equal("FZPH", run("$formatInteger(123456, 'A')")) + end) + it("parseInteger letters", function() + assert.are.equal(1, run("$parseInteger('A', 'A')")) + assert.are.equal(12, run("$parseInteger('l', 'a')")) + assert.are.equal(26, run("$parseInteger('z', 'a')")) + assert.are.equal(27, run("$parseInteger('aa', 'a')")) + assert.are.equal(300, run("$parseInteger('KN', 'A')")) + assert.are.equal(123456, run("$parseInteger('FZPH', 'A')")) + end) + it("D3130 sequence", function() + local ok, err = pcall(run, "$formatInteger(123456, 'α')") + assert.is_false(ok) + assert.are.equal("D3130", err.code) + local ok2, err2 = pcall(run, "$parseInteger('50', '#')") + assert.is_false(ok2) + assert.are.equal("D3130", err2.code) + end) +end) diff --git a/src/jsonata/functions/formatinteger.lua b/src/jsonata/functions/formatinteger.lua index 754e069..3891028 100644 --- a/src/jsonata/functions/formatinteger.lua +++ b/src/jsonata/functions/formatinteger.lua @@ -262,6 +262,67 @@ local function parse_decimal(value, format) return tonumber(digits) end +-- ---- roman (jsonata decimalToRoman / romanToDecimal) ---------------------- +local ROMAN_NUMERALS = { + { 1000, "m" }, + { 900, "cm" }, + { 500, "d" }, + { 400, "cd" }, + { 100, "c" }, + { 90, "xc" }, + { 50, "l" }, + { 40, "xl" }, + { 10, "x" }, + { 9, "ix" }, + { 5, "v" }, + { 4, "iv" }, + { 1, "i" }, +} +local ROMAN_VALUES = { M = 1000, D = 500, C = 100, L = 50, X = 10, V = 5, I = 1 } + +local function decimal_to_roman(value) + for _, numeral in ipairs(ROMAN_NUMERALS) do + if value >= numeral[1] then + return numeral[2] .. decimal_to_roman(value - numeral[1]) + end + end + return "" +end + +local function roman_to_decimal(roman) + local decimal, max = 0, 1 + for i = #roman, 1, -1 do + local value = ROMAN_VALUES[roman:sub(i, i)] + if value < max then + decimal = decimal - value + else + max = value + decimal = decimal + value + end + end + return decimal +end + +-- ---- letters (jsonata decimalToLetters / lettersToDecimal) ---------------- +local function decimal_to_letters(value, aChar) + local aCode = aChar:byte(1) + local letters = {} + while value > 0 do + table.insert(letters, 1, string.char((value - 1) % 26 + aCode)) + value = math.floor((value - 1) / 26) + end + return table.concat(letters) +end + +local function letters_to_decimal(letters, aChar) + local aCode = aChar:byte(1) + local decimal = 0 + for i = 0, #letters - 1 do + decimal = decimal + (letters:byte(#letters - i) - aCode + 1) * 26 ^ i + end + return decimal +end + -- ---- format dispatch (jsonata _formatInteger) ----------------------------- local function format_integer_spec(value, format) local negative = value < 0 @@ -269,11 +330,17 @@ local function format_integer_spec(value, format) local out if format.primary == "decimal" then out = format_decimal(value, format) + elseif format.primary == "letters" then + out = decimal_to_letters(value, format.case == "upper" and "A" or "a") + elseif format.primary == "roman" then + out = decimal_to_roman(value) + if format.case == "upper" then + out = out:upper() + end + elseif format.primary == "words" then + H.err("D3130", { value = format.primary }) -- filled in Task 3 elseif format.primary == "sequence" then H.err("D3130", { value = format.token }) - else - -- roman / letters / words filled in Tasks 2-3 - H.err("D3130", { value = format.token or format.primary }) end if negative then out = "-" .. out @@ -287,14 +354,21 @@ local function integer_parser(format) return function(value) return parse_decimal(value, format) end - elseif format.primary == "sequence" then + elseif format.primary == "letters" then + return function(value) + return letters_to_decimal(value, format.case == "upper" and "A" or "a") + end + elseif format.primary == "roman" then + return function(value) + return roman_to_decimal(format.case == "upper" and value or value:upper()) + end + elseif format.primary == "words" then return function() - H.err("D3130", { value = format.token }) + H.err("D3130", { value = format.primary }) -- filled in Task 3 end - else - -- roman / letters / words filled in Tasks 2-3 + elseif format.primary == "sequence" then return function() - H.err("D3130", { value = format.token or format.primary }) + H.err("D3130", { value = format.token }) end end end From 27614ec710164432979855d5d7a3352415132dfd Mon Sep 17 00:00:00 2001 From: fl Date: Fri, 26 Jun 2026 02:33:23 +0800 Subject: [PATCH 4/6] feat(M8b): words integer engine (cardinal + ordinal + case), both directions numberToWords recursive engine (magnitudes to trillion, clamped; and/comma joins; ordinal mutations) + wordsToNumber (wordValues table + segment-accumulate) for w/W/Ww. Completes $formatInteger/$parseInteger. Co-Authored-By: Claude Opus 4.8 --- spec/formatinteger_spec.lua | 46 +++++++ src/jsonata/functions/formatinteger.lua | 171 +++++++++++++++++++++++- 2 files changed, 214 insertions(+), 3 deletions(-) diff --git a/spec/formatinteger_spec.lua b/spec/formatinteger_spec.lua index c4beda3..3c83011 100644 --- a/spec/formatinteger_spec.lua +++ b/spec/formatinteger_spec.lua @@ -100,3 +100,49 @@ describe("M8b: roman + letters", function() assert.are.equal("D3130", err2.code) end) end) + +describe("M8b: words cardinal", function() + it("basic + decades + hundreds + magnitudes", function() + assert.are.equal("twelve", run("$formatInteger(12, 'w')")) + assert.are.equal("twenty", run("$formatInteger(20, 'w')")) + assert.are.equal("thirty-four", run("$formatInteger(34, 'w')")) + assert.are.equal("NINETY-NINE", run("$formatInteger(99, 'W')")) + assert.are.equal("one hundred", run("$formatInteger(100, 'w')")) + assert.are.equal("FIVE HUNDRED AND FIFTY-FIVE", run("$formatInteger(555, 'W')")) + assert.are.equal("Five Hundred and Fifty-Five", run("$formatInteger(555, 'Ww')")) + assert.are.equal("nine hundred and nineteen", run("$formatInteger(919, 'w')")) + assert.are.equal("one thousand", run("$formatInteger(1000, 'w')")) + assert.are.equal("three thousand, seven hundred and thirty", run("$formatInteger(3730, 'w')")) + assert.are.equal("four million, three hundred and twenty-seven thousand, seven hundred and thirty", run("$formatInteger(4327730, 'w')")) + assert.are.equal("one trillion and one", run("$formatInteger(1000000000001, 'w')")) + assert.are.equal("one thousand trillion", run("$formatInteger(1000000000000000, 'w')")) + assert.are.equal("ten billion trillion trillion trillion", run("$formatInteger(1e46, 'w')")) + end) +end) +describe("M8b: words ordinal", function() + it("ordinal forms", function() + assert.are.equal("twelfth", run("$formatInteger(12, 'w;o')")) + assert.are.equal("twentieth", run("$formatInteger(20, 'w;o')")) + assert.are.equal("thirty-fourth", run("$formatInteger(34, 'w;o')")) + assert.are.equal("NINETY-NINTH", run("$formatInteger(99, 'W;o')")) + assert.are.equal("one hundredth", run("$formatInteger(100, 'w;o')")) + assert.are.equal("seven hundred and thirtieth", run("$formatInteger(730, 'w;o')")) + assert.are.equal("one thousandth", run("$formatInteger(1000, 'w;o')")) + assert.are.equal("one trillion and first", run("$formatInteger(1000000000001, 'w;o')")) + end) +end) +describe("M8b: words parse", function() + it("cardinal + ordinal + title", function() + assert.are.equal(12, run("$parseInteger('twelve', 'w')")) + assert.are.equal(34, run("$parseInteger('thirty-four', 'w')")) + assert.are.equal(99, run("$parseInteger('NINETY-NINE', 'W')")) + assert.are.equal(555, run("$parseInteger('Five Hundred and Fifty-Five', 'Ww')")) + assert.are.equal(3730, run("$parseInteger('three thousand, seven hundred and thirty', 'w')")) + assert.are.equal(1000000000001, run("$parseInteger('one trillion and one', 'w')")) + assert.are.equal(1000000000000000, run("$parseInteger('one thousand trillion', 'w')")) + assert.are.equal(12, run("$parseInteger('twelfth', 'w;o')")) + assert.are.equal(20, run("$parseInteger('twentieth', 'w;o')")) + assert.are.equal(733, run("$parseInteger('Seven Hundred and Thirty-Third', 'Ww;o')")) + assert.are.equal(1000000000001, run("$parseInteger('one trillion and first', 'w;o')")) + end) +end) diff --git a/src/jsonata/functions/formatinteger.lua b/src/jsonata/functions/formatinteger.lua index 3891028..19fb867 100644 --- a/src/jsonata/functions/formatinteger.lua +++ b/src/jsonata/functions/formatinteger.lua @@ -323,6 +323,165 @@ local function letters_to_decimal(letters, aChar) return decimal end +-- ---- words (jsonata numberToWords / wordsToNumber) ------------------------ +local FEW = { + [0] = "Zero", + [1] = "One", + [2] = "Two", + [3] = "Three", + [4] = "Four", + [5] = "Five", + [6] = "Six", + [7] = "Seven", + [8] = "Eight", + [9] = "Nine", + [10] = "Ten", + [11] = "Eleven", + [12] = "Twelve", + [13] = "Thirteen", + [14] = "Fourteen", + [15] = "Fifteen", + [16] = "Sixteen", + [17] = "Seventeen", + [18] = "Eighteen", + [19] = "Nineteen", +} +local ORDINALS = { + [0] = "Zeroth", + [1] = "First", + [2] = "Second", + [3] = "Third", + [4] = "Fourth", + [5] = "Fifth", + [6] = "Sixth", + [7] = "Seventh", + [8] = "Eighth", + [9] = "Ninth", + [10] = "Tenth", + [11] = "Eleventh", + [12] = "Twelfth", + [13] = "Thirteenth", + [14] = "Fourteenth", + [15] = "Fifteenth", + [16] = "Sixteenth", + [17] = "Seventeenth", + [18] = "Eighteenth", + [19] = "Nineteenth", +} +local DECADES = { + [0] = "Twenty", + [1] = "Thirty", + [2] = "Forty", + [3] = "Fifty", + [4] = "Sixty", + [5] = "Seventy", + [6] = "Eighty", + [7] = "Ninety", + [8] = "Hundred", +} +local MAGNITUDES = { [0] = "Thousand", [1] = "Million", [2] = "Billion", [3] = "Trillion" } +local MAGNITUDES_LEN = 4 + +local function number_to_words(value, ordinal) + local function lookup(num, prev, ord) + local words = "" + if num <= 19 then + words = (prev and " and " or "") .. (ord and ORDINALS[num] or FEW[num]) + elseif num < 100 then + local tens = math.floor(num / 10) + local remainder = num % 10 + words = (prev and " and " or "") .. DECADES[tens - 2] + if remainder > 0 then + words = words .. "-" .. lookup(remainder, false, ord) + elseif ord then + words = words:sub(1, #words - 1) .. "ieth" + end + elseif num < 1000 then + local hundreds = math.floor(num / 100) + local remainder = num % 100 + words = (prev and ", " or "") .. FEW[hundreds] .. " Hundred" + if remainder > 0 then + words = words .. lookup(remainder, true, ord) + elseif ord then + words = words .. "th" + end + else + local mag = math.floor(math.log10(num) / 3) + if mag > MAGNITUDES_LEN then + mag = MAGNITUDES_LEN + end + local factor = 10 ^ (mag * 3) + local mant = math.floor(num / factor) + local remainder = num - mant * factor + words = (prev and ", " or "") .. lookup(mant, false, false) .. " " .. MAGNITUDES[mag - 1] + if remainder > 0 then + words = words .. lookup(remainder, true, ord) + elseif ord then + words = words .. "th" + end + end + return words + end + return lookup(value, false, ordinal) +end + +-- wordValues lookup table (built once) +local WORD_VALUES = {} +for i = 0, 19 do + WORD_VALUES[FEW[i]:lower()] = i +end +for i = 0, 19 do + WORD_VALUES[ORDINALS[i]:lower()] = i +end +for i = 0, 8 do + local lword = DECADES[i]:lower() + WORD_VALUES[lword] = (i + 2) * 10 + WORD_VALUES[lword:sub(1, #lword - 1) .. "ieth"] = WORD_VALUES[lword] +end +WORD_VALUES["hundredth"] = 100 +for i = 0, 3 do + local lword = MAGNITUDES[i]:lower() + local val = 10 ^ ((i + 1) * 3) + WORD_VALUES[lword] = val + WORD_VALUES[lword .. "th"] = val +end + +-- split mimicking jsonata's /,\s|\sand\s|[\s\\-]/ +local function split_words(text) + text = text:gsub(",%s", "\1"):gsub("%sand%s", "\1"):gsub("[%s\\%-]", "\1") + local parts = {} + for p in (text .. "\1"):gmatch("(.-)\1") do + parts[#parts + 1] = p + end + return parts +end + +local function words_to_number(text) + local parts = split_words(text) + local segs = { 0 } + for _, part in ipairs(parts) do + local value = WORD_VALUES[part] + if value < 100 then + local top = table.remove(segs) + if top >= 1000 then + segs[#segs + 1] = top + top = 0 + end + segs[#segs + 1] = top + value + else + -- pop first (Lua evaluates the LHS index before the RHS, so use a local + -- to mirror JS's segs.push(segs.pop() * value) ordering) + local popped = table.remove(segs) + segs[#segs + 1] = popped * value + end + end + local result = 0 + for _, s in ipairs(segs) do + result = result + s + end + return result +end + -- ---- format dispatch (jsonata _formatInteger) ----------------------------- local function format_integer_spec(value, format) local negative = value < 0 @@ -338,7 +497,13 @@ local function format_integer_spec(value, format) out = out:upper() end elseif format.primary == "words" then - H.err("D3130", { value = format.primary }) -- filled in Task 3 + out = number_to_words(value, format.ordinal) + if format.case == "upper" then + out = out:upper() + elseif format.case == "lower" then + out = out:lower() + end + -- "title" leaves the table's natural Title-case elseif format.primary == "sequence" then H.err("D3130", { value = format.token }) end @@ -363,8 +528,8 @@ local function integer_parser(format) return roman_to_decimal(format.case == "upper" and value or value:upper()) end elseif format.primary == "words" then - return function() - H.err("D3130", { value = format.primary }) -- filled in Task 3 + return function(value) + return words_to_number(value:lower()) end elseif format.primary == "sequence" then return function() From 82d56f70063e3dd712b6746b4663b55f51f0222e Mon Sep 17 00:00:00 2001 From: fl Date: Fri, 26 Jun 2026 02:38:23 +0800 Subject: [PATCH 5/6] =?UTF-8?q?test(M8b):=20regen=20baseline=20=E2=80=94?= =?UTF-8?q?=20$formatInteger=2065/65=20+=20$parseInteger=2061/61,=20zero?= =?UTF-8?q?=20regressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- spec/jsonata-suite/baseline.lua | 123 ++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/spec/jsonata-suite/baseline.lua b/spec/jsonata-suite/baseline.lua index 4803fea..a4e1e47 100644 --- a/spec/jsonata-suite/baseline.lua +++ b/spec/jsonata-suite/baseline.lua @@ -425,8 +425,71 @@ return { ["function-formatBase/case006"] = true, ["function-formatBase/case007"] = true, ["function-formatBase/case008"] = true, + ["function-formatInteger/formatInteger/0"] = true, + ["function-formatInteger/formatInteger/1"] = true, + ["function-formatInteger/formatInteger/10"] = true, + ["function-formatInteger/formatInteger/11"] = true, + ["function-formatInteger/formatInteger/12"] = true, + ["function-formatInteger/formatInteger/13"] = true, + ["function-formatInteger/formatInteger/14"] = true, ["function-formatInteger/formatInteger/15"] = true, + ["function-formatInteger/formatInteger/16"] = true, + ["function-formatInteger/formatInteger/17"] = true, + ["function-formatInteger/formatInteger/18"] = true, + ["function-formatInteger/formatInteger/19"] = true, + ["function-formatInteger/formatInteger/2"] = true, + ["function-formatInteger/formatInteger/20"] = true, + ["function-formatInteger/formatInteger/21"] = true, + ["function-formatInteger/formatInteger/22"] = true, + ["function-formatInteger/formatInteger/23"] = true, + ["function-formatInteger/formatInteger/24"] = true, + ["function-formatInteger/formatInteger/25"] = true, + ["function-formatInteger/formatInteger/26"] = true, + ["function-formatInteger/formatInteger/27"] = true, + ["function-formatInteger/formatInteger/28"] = true, + ["function-formatInteger/formatInteger/29"] = true, + ["function-formatInteger/formatInteger/3"] = true, + ["function-formatInteger/formatInteger/30"] = true, + ["function-formatInteger/formatInteger/31"] = true, + ["function-formatInteger/formatInteger/32"] = true, + ["function-formatInteger/formatInteger/33"] = true, + ["function-formatInteger/formatInteger/34"] = true, + ["function-formatInteger/formatInteger/35"] = true, + ["function-formatInteger/formatInteger/36"] = true, + ["function-formatInteger/formatInteger/37"] = true, + ["function-formatInteger/formatInteger/38"] = true, + ["function-formatInteger/formatInteger/39"] = true, + ["function-formatInteger/formatInteger/4"] = true, + ["function-formatInteger/formatInteger/40"] = true, + ["function-formatInteger/formatInteger/41"] = true, + ["function-formatInteger/formatInteger/42"] = true, + ["function-formatInteger/formatInteger/43"] = true, + ["function-formatInteger/formatInteger/44"] = true, + ["function-formatInteger/formatInteger/45"] = true, + ["function-formatInteger/formatInteger/46"] = true, + ["function-formatInteger/formatInteger/47"] = true, + ["function-formatInteger/formatInteger/48"] = true, + ["function-formatInteger/formatInteger/49"] = true, + ["function-formatInteger/formatInteger/5"] = true, + ["function-formatInteger/formatInteger/50"] = true, + ["function-formatInteger/formatInteger/51"] = true, + ["function-formatInteger/formatInteger/52"] = true, + ["function-formatInteger/formatInteger/53"] = true, + ["function-formatInteger/formatInteger/54"] = true, + ["function-formatInteger/formatInteger/55"] = true, + ["function-formatInteger/formatInteger/56"] = true, + ["function-formatInteger/formatInteger/57"] = true, + ["function-formatInteger/formatInteger/58"] = true, + ["function-formatInteger/formatInteger/59"] = true, + ["function-formatInteger/formatInteger/6"] = true, + ["function-formatInteger/formatInteger/60"] = true, + ["function-formatInteger/formatInteger/61"] = true, + ["function-formatInteger/formatInteger/62"] = true, + ["function-formatInteger/formatInteger/63"] = true, ["function-formatInteger/formatInteger/64"] = true, + ["function-formatInteger/formatInteger/7"] = true, + ["function-formatInteger/formatInteger/8"] = true, + ["function-formatInteger/formatInteger/9"] = true, ["function-formatNumber/case000"] = true, ["function-formatNumber/case001"] = true, ["function-formatNumber/case002"] = true, @@ -593,7 +656,67 @@ return { ["function-pad/case009"] = true, ["function-pad/case010"] = true, ["function-pad/case011"] = true, + ["function-parseInteger/parseInteger/0"] = true, + ["function-parseInteger/parseInteger/1"] = true, + ["function-parseInteger/parseInteger/10"] = true, + ["function-parseInteger/parseInteger/11"] = true, + ["function-parseInteger/parseInteger/12"] = true, + ["function-parseInteger/parseInteger/13"] = true, + ["function-parseInteger/parseInteger/14"] = true, + ["function-parseInteger/parseInteger/15"] = true, + ["function-parseInteger/parseInteger/16"] = true, + ["function-parseInteger/parseInteger/17"] = true, + ["function-parseInteger/parseInteger/18"] = true, + ["function-parseInteger/parseInteger/19"] = true, + ["function-parseInteger/parseInteger/2"] = true, + ["function-parseInteger/parseInteger/20"] = true, + ["function-parseInteger/parseInteger/21"] = true, + ["function-parseInteger/parseInteger/22"] = true, + ["function-parseInteger/parseInteger/23"] = true, + ["function-parseInteger/parseInteger/24"] = true, + ["function-parseInteger/parseInteger/25"] = true, + ["function-parseInteger/parseInteger/26"] = true, + ["function-parseInteger/parseInteger/27"] = true, + ["function-parseInteger/parseInteger/28"] = true, + ["function-parseInteger/parseInteger/29"] = true, + ["function-parseInteger/parseInteger/3"] = true, + ["function-parseInteger/parseInteger/30"] = true, + ["function-parseInteger/parseInteger/31"] = true, + ["function-parseInteger/parseInteger/32"] = true, + ["function-parseInteger/parseInteger/33"] = true, + ["function-parseInteger/parseInteger/34"] = true, + ["function-parseInteger/parseInteger/35"] = true, + ["function-parseInteger/parseInteger/36"] = true, + ["function-parseInteger/parseInteger/37"] = true, + ["function-parseInteger/parseInteger/38"] = true, + ["function-parseInteger/parseInteger/39"] = true, + ["function-parseInteger/parseInteger/4"] = true, + ["function-parseInteger/parseInteger/40"] = true, + ["function-parseInteger/parseInteger/41"] = true, + ["function-parseInteger/parseInteger/42"] = true, + ["function-parseInteger/parseInteger/43"] = true, + ["function-parseInteger/parseInteger/44"] = true, + ["function-parseInteger/parseInteger/45"] = true, + ["function-parseInteger/parseInteger/46"] = true, + ["function-parseInteger/parseInteger/47"] = true, + ["function-parseInteger/parseInteger/48"] = true, + ["function-parseInteger/parseInteger/49"] = true, + ["function-parseInteger/parseInteger/5"] = true, + ["function-parseInteger/parseInteger/50"] = true, + ["function-parseInteger/parseInteger/51"] = true, + ["function-parseInteger/parseInteger/52"] = true, + ["function-parseInteger/parseInteger/53"] = true, + ["function-parseInteger/parseInteger/54"] = true, + ["function-parseInteger/parseInteger/55"] = true, + ["function-parseInteger/parseInteger/56"] = true, + ["function-parseInteger/parseInteger/57"] = true, + ["function-parseInteger/parseInteger/58"] = true, + ["function-parseInteger/parseInteger/59"] = true, + ["function-parseInteger/parseInteger/6"] = true, ["function-parseInteger/parseInteger/60"] = true, + ["function-parseInteger/parseInteger/7"] = true, + ["function-parseInteger/parseInteger/8"] = true, + ["function-parseInteger/parseInteger/9"] = true, ["function-power/case000"] = true, ["function-power/case001"] = true, ["function-power/case002"] = true, From a6ef2e8bc3fb3b3e83b20b1e4156982617b0d0f7 Mon Sep 17 00:00:00 2001 From: fl Date: Fri, 26 Jun 2026 02:43:30 +0800 Subject: [PATCH 6/6] fix(M8b): $parseInteger words returns NaN on unknown token, not a crash words_to_number crashed (nil < 100) on unrecognized tokens; jsonata's wordsToNumber maps them to undefined, whose lenient coercion yields NaN. Mirror that (nil routes to the multiply branch, * NaN). Matches the oracle (null). Co-Authored-By: Claude Opus 4.8 --- spec/formatinteger_spec.lua | 9 +++++++++ src/jsonata/functions/formatinteger.lua | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/spec/formatinteger_spec.lua b/spec/formatinteger_spec.lua index 3c83011..5abf373 100644 --- a/spec/formatinteger_spec.lua +++ b/spec/formatinteger_spec.lua @@ -145,4 +145,13 @@ describe("M8b: words parse", function() assert.are.equal(733, run("$parseInteger('Seven Hundred and Thirty-Third', 'Ww;o')")) assert.are.equal(1000000000001, run("$parseInteger('one trillion and first', 'w;o')")) end) + + it("unrecognized words token -> NaN (not a crash), matching oracle", function() + local r = run("$parseInteger('foo', 'w')") + assert.is_true(type(r) == "number" and r ~= r) -- NaN + local r2 = run("$parseInteger('-twelve', 'w')") + assert.is_true(type(r2) == "number" and r2 ~= r2) + -- valid input still works + assert.are.equal(34, run("$parseInteger('thirty-four', 'w')")) + end) end) diff --git a/src/jsonata/functions/formatinteger.lua b/src/jsonata/functions/formatinteger.lua index 19fb867..e6dfd28 100644 --- a/src/jsonata/functions/formatinteger.lua +++ b/src/jsonata/functions/formatinteger.lua @@ -461,7 +461,7 @@ local function words_to_number(text) local segs = { 0 } for _, part in ipairs(parts) do local value = WORD_VALUES[part] - if value < 100 then + if value ~= nil and value < 100 then local top = table.remove(segs) if top >= 1000 then segs[#segs + 1] = top @@ -471,8 +471,9 @@ local function words_to_number(text) else -- pop first (Lua evaluates the LHS index before the RHS, so use a local -- to mirror JS's segs.push(segs.pop() * value) ordering) + -- nil value (unknown token) mirrors JS undefined: * undefined -> NaN local popped = table.remove(segs) - segs[#segs + 1] = popped * value + segs[#segs + 1] = popped * (value or (0 / 0)) end end local result = 0