diff --git a/lib/lua/lexer.ex b/lib/lua/lexer.ex index 8882d5d..39299a2 100644 --- a/lib/lua/lexer.ex +++ b/lib/lua/lexer.ex @@ -400,6 +400,21 @@ defmodule Lua.Lexer do end end + # \ line continuation: a backslash before a real end-of-line yields a + # single \n byte and advances one line. All four line endings (\n, \r, \r\n, + # \n\r) collapse to one newline, matching PUC-Lua's `read_string`. + defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do + scan_string(rest, str_acc <> "\n", acc, advance_string_line(pos, 3), start_pos, quote) + end + + defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do + scan_string(rest, str_acc <> "\n", acc, advance_string_line(pos, 3), start_pos, quote) + end + + defp scan_string(<>, str_acc, acc, pos, start_pos, quote) when nl in [?\n, ?\r] do + scan_string(rest, str_acc <> "\n", acc, advance_string_line(pos, 2), start_pos, quote) + end + defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do # Escape sequence case escape_char(esc) do @@ -828,7 +843,12 @@ defmodule Lua.Lexer do end else {num, ""} = Integer.parse(num_str) - {:ok, num} + # Lua 5.3.3 §3.1: a decimal integer literal that overflows the signed + # 64-bit range converts to a float (a leading sign is a separate token, + # so `num` here is always the non-negative magnitude). Hex integer + # literals instead wrap via wrap_int64; this branch is decimal only. + # @sign_bit is 2^63, i.e. max_int + 1, so `>= @sign_bit` means overflow. + if num >= @sign_bit, do: {:ok, num * 1.0}, else: {:ok, num} end end @@ -836,4 +856,10 @@ defmodule Lua.Lexer do defp advance_column(pos, n) do %{pos | column: pos.column + n, byte_offset: pos.byte_offset + n} end + + # Advance one source line, consuming `n` raw bytes (the backslash plus the + # one- or two-byte line ending of a \ continuation). + defp advance_string_line(pos, n) do + %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + n} + end end diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 00f32fa..92cd8cb 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -3599,6 +3599,20 @@ defmodule Lua.VM.Executor do defp to_integer!(v, line, source, hint, state) when is_float(v), do: float_to_integer!(v, line, source, hint, state) + # The `:nan` sentinel stands in for an IEEE NaN float (e.g. `0/0`). Per Lua + # 5.3 §3.4.3 a float converts to integer for bitwise ops only when it has an + # exact integer value; NaN never does, so it raises like any other + # fractional/out-of-range float rather than as a non-number type. + defp to_integer!(:nan, line, source, hint, state) do + raise TypeError, + value: "number has no integer representation" <> format_target_hint(hint), + line: line, + source: source, + error_kind: :bitwise_on_non_integer, + value_type: :number, + state: state + end + defp to_integer!(v, line, source, hint, state) when is_binary(v) do case Value.parse_number(v) do nil -> diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index a3657a5..a1caca2 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -10,6 +10,7 @@ defmodule Lua.VM.Stdlib do alias Lua.VM.AssertionError alias Lua.VM.Executor alias Lua.VM.Limits + alias Lua.VM.Numeric alias Lua.VM.ProtectedCall alias Lua.VM.RuntimeError alias Lua.VM.State @@ -137,10 +138,7 @@ defmodule Lua.VM.Stdlib do Value.parse_number(v) {v, b} when is_binary(v) and is_integer(b) and b >= 2 and b <= 36 -> - case Integer.parse(v, b) do - {n, ""} -> n - _ -> nil - end + parse_in_base(v, b) _ -> nil @@ -151,6 +149,24 @@ defmodule Lua.VM.Stdlib do defp lua_tonumber([], state), do: {[nil], state} + # tonumber(s, base) per Lua 5.3 §6.1: surrounding whitespace is ignored, an + # optional sign is allowed, digits a-z/A-Z map to 10-35 (case-insensitive), + # and the result is always an integer (wrapping into the signed 64-bit range + # like an integer literal). The whole trimmed string must be valid digits. + defp parse_in_base(str, base) do + {sign, body} = + case String.trim(str) do + "-" <> rest -> {-1, rest} + "+" <> rest -> {1, rest} + other -> {1, other} + end + + case Integer.parse(body, base) do + {n, ""} when body != "" -> Numeric.to_signed_int64(sign * n) + _ -> nil + end + end + # print(...) — prints values separated by tabs, followed by a newline defp lua_print(args, state) do {strings, state} = diff --git a/lib/lua/vm/stdlib/math.ex b/lib/lua/vm/stdlib/math.ex index 0a296df..f605449 100644 --- a/lib/lua/vm/stdlib/math.ex +++ b/lib/lua/vm/stdlib/math.ex @@ -37,8 +37,12 @@ defmodule Lua.VM.Stdlib.Math do import Bitwise alias Lua.VM.ArgumentError + alias Lua.VM.Numeric alias Lua.VM.State alias Lua.VM.Stdlib.Util + alias Lua.VM.Value + + @max_int 9_223_372_036_854_775_807 @impl true def lib_name, do: "math" @@ -52,6 +56,7 @@ defmodule Lua.VM.Stdlib.Math do "atan" => {:native_func, &math_atan/2}, "ceil" => {:native_func, &math_ceil/2}, "cos" => {:native_func, &math_cos/2}, + "deg" => {:native_func, &math_deg/2}, "exp" => {:native_func, &math_exp/2}, "floor" => {:native_func, &math_floor/2}, "fmod" => {:native_func, &math_fmod/2}, @@ -60,6 +65,7 @@ defmodule Lua.VM.Stdlib.Math do "min" => {:native_func, &math_min/2}, "modf" => {:native_func, &math_modf/2}, "pi" => :math.pi(), + "rad" => {:native_func, &math_rad/2}, "random" => {:native_func, &math_random/2}, "randomseed" => {:native_func, &math_randomseed/2}, "sin" => {:native_func, &math_sin/2}, @@ -77,8 +83,14 @@ defmodule Lua.VM.Stdlib.Math do State.set_global(state, "math", tref) end - # math.abs(x) - defp math_abs([x], state) when is_number(x) do + # math.abs(x). Integer arithmetic wraps modulo 2^64 (Lua 5.3 §3.4.2), so + # `math.abs(math.mininteger)` is `math.mininteger` itself rather than a value + # that escapes the signed 64-bit range. + defp math_abs([x], state) when is_integer(x) do + {[Numeric.to_signed_int64(abs(x))], state} + end + + defp math_abs([x], state) when is_float(x) do {[abs(x)], state} end @@ -157,9 +169,15 @@ defmodule Lua.VM.Stdlib.Math do raise ArgumentError.value_expected("math.atan", 1) end - # math.ceil(x) - defp math_ceil([x], state) when is_number(x) do - {[trunc(Float.ceil(x / 1))], state} + # math.ceil(x). An integer argument is already integral and is returned + # unchanged; routing it through a float would lose precision near the 64-bit + # limits (e.g. `maxint` rounds up to 2^63 as a float). + defp math_ceil([x], state) when is_integer(x) do + {[x], state} + end + + defp math_ceil([x], state) when is_float(x) do + {[integral_float_result(Float.ceil(x))], state} end defp math_ceil([x | _], _state) do @@ -191,6 +209,40 @@ defmodule Lua.VM.Stdlib.Math do raise ArgumentError.value_expected("math.cos", 1) end + # math.deg(x) — radians to degrees + defp math_deg([x], state) when is_number(x) do + {[x / 1 * (180.0 / :math.pi())], state} + end + + defp math_deg([x | _], _state) do + raise ArgumentError, + function_name: "math.deg", + arg_num: 1, + expected: "number", + got: Util.typeof(x) + end + + defp math_deg([], _state) do + raise ArgumentError.value_expected("math.deg", 1) + end + + # math.rad(x) — degrees to radians + defp math_rad([x], state) when is_number(x) do + {[x / 1 * (:math.pi() / 180.0)], state} + end + + defp math_rad([x | _], _state) do + raise ArgumentError, + function_name: "math.rad", + arg_num: 1, + expected: "number", + got: Util.typeof(x) + end + + defp math_rad([], _state) do + raise ArgumentError.value_expected("math.rad", 1) + end + # math.exp(x) defp math_exp([x], state) when is_number(x) do {[:math.exp(x / 1)], state} @@ -208,9 +260,15 @@ defmodule Lua.VM.Stdlib.Math do raise ArgumentError.value_expected("math.exp", 1) end - # math.floor(x) - defp math_floor([x], state) when is_number(x) do - {[trunc(Float.floor(x / 1))], state} + # math.floor(x). An integer argument is already integral and is returned + # unchanged; routing it through a float would lose precision near the 64-bit + # limits (e.g. `maxint` rounds up to 2^63 as a float). + defp math_floor([x], state) when is_integer(x) do + {[x], state} + end + + defp math_floor([x], state) when is_float(x) do + {[integral_float_result(Float.floor(x))], state} end defp math_floor([x | _], _state) do @@ -225,6 +283,13 @@ defmodule Lua.VM.Stdlib.Math do raise ArgumentError.value_expected("math.floor", 1) end + # Lua 5.3 §6.7: `math.floor`/`math.ceil` return an integer when the integral + # result fits the signed 64-bit range, otherwise the (already integral) float. + defp integral_float_result(f) do + truncated = trunc(f) + if Numeric.signed?(truncated), do: truncated, else: f + end + # math.fmod(x, y) # # Returns the remainder of the division of x by y that rounds the quotient @@ -373,6 +438,16 @@ defmodule Lua.VM.Stdlib.Math do {[:rand.uniform(m)], state} end + # Lua 5.3 §6.7: the interval `[m, n]` is too large when its span `n - m` + # exceeds the signed 64-bit range (e.g. `math.random(minint, 0)`). The span + # is computed in unbounded integer arithmetic before the range check so the + # overflow is detected rather than silently wrapping. + defp math_random([m, n], _state) when is_integer(m) and is_integer(n) and m <= n and n - m > @max_int do + raise ArgumentError, + function_name: "math.random", + details: "interval too large" + end + defp math_random([m, n], state) when is_integer(m) and is_integer(n) and m <= n do # Returns an integer in [m, n] range = n - m + 1 @@ -483,16 +558,22 @@ defmodule Lua.VM.Stdlib.Math do raise ArgumentError.value_expected("math.tan", 1) end - # math.tointeger(x) + # math.tointeger(x). Returns x as an integer when it has an exact integer + # value within the signed 64-bit range, otherwise nil. Strings are coerced + # via the standard numeric-string rules (Lua 5.3 `lua_tointegerx`). defp math_tointeger([x], state) when is_integer(x) do {[x], state} end defp math_tointeger([x], state) when is_float(x) do - if Float.floor(x) == x do - {[trunc(x)], state} - else - {[nil], state} + {[float_to_integer_or_nil(x)], state} + end + + defp math_tointeger([x], state) when is_binary(x) do + case Value.parse_number(x) do + n when is_integer(n) -> {[n], state} + n when is_float(n) -> {[float_to_integer_or_nil(n)], state} + _ -> {[nil], state} end end @@ -504,6 +585,15 @@ defmodule Lua.VM.Stdlib.Math do raise ArgumentError.value_expected("math.tointeger", 1) end + # A float converts to an integer only when it is integral and fits the signed + # 64-bit range (the `1.0e308` `math.huge` stand-in does not). + defp float_to_integer_or_nil(f) do + if Float.floor(f) == f do + truncated = trunc(f) + if Numeric.signed?(truncated), do: truncated + end + end + # math.type(x) defp math_type([x], state) when is_integer(x) do {["integer"], state} diff --git a/lib/lua/vm/stdlib/os.ex b/lib/lua/vm/stdlib/os.ex index d77e04d..3eea7a1 100644 --- a/lib/lua/vm/stdlib/os.ex +++ b/lib/lua/vm/stdlib/os.ex @@ -16,7 +16,8 @@ defmodule Lua.VM.Stdlib.Os do - `os.difftime(t2, t1)` - Difference in seconds between two times. - `os.date([format [, time]])` - Formats a time as a string or table. - `os.getenv(name)` - Value of an environment variable, or nil. - - `os.setlocale([locale [, category]])` - No-op returning "C". + - `os.setlocale([locale [, category]])` - Only the "C" locale is available; + returns "C" for a query or a "C"/"" request and nil for any other locale. - `os.tmpname()` - A name usable for a temporary file. - `os.exit([code [, close]])` - Raises to unwind; sandbox cannot exit. """ @@ -168,8 +169,14 @@ defmodule Lua.VM.Stdlib.Os do raise ArgumentError.value_expected("os.getenv", 1) end - # os.setlocale([locale [, category]]) — no locale support in the - # sandbox; report the "C" locale as active. + # os.setlocale([locale [, category]]) — the sandbox only carries the default + # "C" locale. A query (nil locale) and a request for "C" or "" all report + # "C"; any other named locale is unavailable, so return nil like a C runtime + # whose locale data is missing. + defp os_setlocale([locale | _], state) when locale not in [nil, "C", ""] do + {[nil], state} + end + defp os_setlocale(_args, state), do: {["C"], state} # os.tmpname() — a name usable for a temporary file. diff --git a/lib/lua/vm/stdlib/string.ex b/lib/lua/vm/stdlib/string.ex index 76d70cd..a15f43d 100644 --- a/lib/lua/vm/stdlib/string.ex +++ b/lib/lua/vm/stdlib/string.ex @@ -364,32 +364,49 @@ defmodule Lua.VM.Stdlib.String do defp format_directive(rest, args, acc) do {spec, rest2} = parse_format_spec(rest) - [arg | remaining_args] = args - str = apply_format_spec(spec, arg) - format_string(rest2, remaining_args, [acc, str]) + + case args do + [arg | remaining_args] -> + str = apply_format_spec(spec, arg) + format_string(rest2, remaining_args, [acc, str]) + + [] -> + raise ArgumentError, function_name: "string.format", details: "no value" + end end - # PUC-Lua copies at most two width and two precision digits into the - # conversion spec (`scanformat`), erroring on anything longer. We enforce - # the same bound so a spec like `%.2000000000f` cannot drive a giant - # `String.duplicate`/padding allocation. - @max_format_field 99 + # PUC-Lua's `scanformat` reads at most two width and two precision digits and + # errors on a third. Mirror that with a digit-count limit (not a value limit) + # so the error message matches and a spec like `%.2000000000f` cannot drive a + # giant `String.duplicate`/padding allocation. + @max_format_digits 2 + + # PUC-Lua's flag scan tolerates the five flag characters once each before + # erroring; six or more flag characters trip the "repeated flags" guard. + @max_format_flags 5 # Parse a format spec: [flags][width][.precision]specifier defp parse_format_spec(str) do - {flags, str} = parse_flags(str, 0) - {width, str} = parse_width(str) - {precision, str} = parse_precision(str) + {flags, flag_count, str} = parse_flags(str, 0, 0) + + if flag_count > @max_format_flags do + raise ArgumentError, function_name: "string.format", details: "repeated flags" + end + + {width, width_digits, str} = parse_width(str) + {precision, precision_digits, str} = parse_precision(str) {specifier, str} = parse_specifier(str) - check_format_field!(width) - check_format_field!(precision) + check_format_field!(width_digits) + check_format_field!(precision_digits) {{flags, width, precision, specifier}, str} end - defp check_format_field!(n) when is_nil(n) or n <= @max_format_field, do: :ok + defp check_format_field!(digits) when digits <= @max_format_digits, do: :ok - defp check_format_field!(_n) do - raise ArgumentError, function_name: "string.format", details: "invalid conversion" + defp check_format_field!(_digits) do + raise ArgumentError, + function_name: "string.format", + details: "invalid conversion (width or precision too long)" end # Flags are parsed once into an integer bitmask so the apply path reads @@ -403,37 +420,37 @@ defmodule Lua.VM.Stdlib.String do @flag_space 0b01000 @flag_hash 0b10000 - defp parse_flags(<>, mask), do: parse_flags(rest, mask ||| @flag_minus) - defp parse_flags(<>, mask), do: parse_flags(rest, mask ||| @flag_zero) - defp parse_flags(<>, mask), do: parse_flags(rest, mask ||| @flag_plus) - defp parse_flags(<>, mask), do: parse_flags(rest, mask ||| @flag_space) - defp parse_flags(<>, mask), do: parse_flags(rest, mask ||| @flag_hash) + defp parse_flags(<>, mask, n), do: parse_flags(rest, mask ||| @flag_minus, n + 1) + defp parse_flags(<>, mask, n), do: parse_flags(rest, mask ||| @flag_zero, n + 1) + defp parse_flags(<>, mask, n), do: parse_flags(rest, mask ||| @flag_plus, n + 1) + defp parse_flags(<>, mask, n), do: parse_flags(rest, mask ||| @flag_space, n + 1) + defp parse_flags(<>, mask, n), do: parse_flags(rest, mask ||| @flag_hash, n + 1) - defp parse_flags(str, mask), do: {mask, str} + defp parse_flags(str, mask, n), do: {mask, n, str} defp parse_width(<> = str) when c in ?0..?9 do - parse_number(str, 0) + parse_number(str, 0, 0) end - defp parse_width(str), do: {nil, str} + defp parse_width(str), do: {nil, 0, str} defp parse_precision("." <> rest) do case rest do <> when c in ?0..?9 -> - parse_number(rest, 0) + parse_number(rest, 0, 0) _ -> - {0, rest} + {0, 0, rest} end end - defp parse_precision(str), do: {nil, str} + defp parse_precision(str), do: {nil, 0, str} - defp parse_number(<>, acc) when c in ?0..?9 do - parse_number(rest, acc * 10 + (c - ?0)) + defp parse_number(<>, acc, digits) when c in ?0..?9 do + parse_number(rest, acc * 10 + (c - ?0), digits + 1) end - defp parse_number(str, acc), do: {acc, str} + defp parse_number(str, acc, digits), do: {acc, digits, str} # Keep the conversion char as a raw integer code point so apply_format_spec/2 # dispatches on BEAM integer patterns rather than one-byte binaries. @@ -460,15 +477,37 @@ defmodule Lua.VM.Stdlib.String do ?x -> format_spec_hex(arg, :lower) ?X -> format_spec_hex(arg, :upper) ?o -> format_spec_octal(arg) + ?a -> format_spec_hexfloat(arg, precision, :lower) + ?A -> format_spec_hexfloat(arg, precision, :upper) ?c -> format_char(arg) - ?s -> format_spec_string(arg, precision) + ?s -> format_spec_string(arg, precision, flags, width) ?q -> format_quoted(arg) _ -> raise ArgumentError, function_name: "string.format", details: "invalid option '%#{<>}'" end - apply_width_flags(raw, flags, width) + raw + |> apply_sign_flag(flags, specifier) + |> apply_width_flags(flags, width) + end + + # The `+` and space flags only affect signed conversions (PUC-Lua passes them + # to C printf, where they are no-ops for %u/%o/%x and the rest). `+` forces a + # leading sign on non-negatives; a space reserves a blank where the sign would + # be. `+` wins when both are present, matching C. + @signed_numeric [?d, ?i, ?f, ?e, ?E, ?g, ?G, ?a, ?A] + + defp apply_sign_flag(<> = raw, _flags, _specifier), do: raw + + defp apply_sign_flag(raw, flags, specifier) when specifier in @signed_numeric do + cond do + (flags &&& @flag_plus) != 0 -> "+" <> raw + (flags &&& @flag_space) != 0 -> " " <> raw + true -> raw + end end + defp apply_sign_flag(raw, _flags, _specifier), do: raw + defp format_spec_integer(val) when is_integer(val), do: Integer.to_string(val) defp format_spec_integer(val) when is_float(val), do: Integer.to_string(trunc(val)) @@ -630,7 +669,7 @@ defmodule Lua.VM.Stdlib.String do end defp format_spec_hex(val, case_style) when is_integer(val) do - str = Integer.to_string(val, 16) + str = Integer.to_string(as_unsigned64(val), 16) if case_style == :lower, do: String.downcase(str), else: String.upcase(str) end @@ -640,22 +679,62 @@ defmodule Lua.VM.Stdlib.String do raise ArgumentError, function_name: "string.format", expected: "number" end - defp format_spec_octal(val) when is_integer(val), do: Integer.to_string(val, 8) - defp format_spec_octal(val) when is_float(val), do: Integer.to_string(trunc(val), 8) + defp format_spec_octal(val) when is_integer(val), do: Integer.to_string(as_unsigned64(val), 8) + defp format_spec_octal(val) when is_float(val), do: format_spec_octal(trunc(val)) defp format_spec_octal(_) do raise ArgumentError, function_name: "string.format", expected: "number" end - defp format_spec_string(arg, precision) do + # PUC-Lua's %x/%X/%o/%u read the integer as an unsigned 64-bit value, so a + # negative argument prints its two's-complement bit pattern. + defp as_unsigned64(val) when val < 0, do: val + 0x10000000000000000 + defp as_unsigned64(val), do: val + + # %a/%A render a float as a C99 hexadecimal float literal. We support the + # plain conversion only: a precision modifier (%.3a) would require digit + # rounding, so we reject it, which lets callers detect the gap via pcall the + # way PUC-Lua's own suite does. + defp format_spec_hexfloat(_arg, precision, _case_style) when not is_nil(precision) do + raise ArgumentError, + function_name: "string.format", + details: "precision not supported for %a" + end + + defp format_spec_hexfloat(:nan, _precision, _case_style), do: "nan" + + defp format_spec_hexfloat(arg, _precision, case_style) when is_number(arg) do + str = float_to_hexfloat(arg / 1) + if case_style == :upper, do: String.upcase(str), else: str + end + + defp format_spec_hexfloat(_, _, _) do + raise ArgumentError, function_name: "string.format", expected: "number" + end + + defp format_spec_string(arg, precision, flags, width) do str = Util.to_lua_string(arg) + # PUC-Lua routes a bare `%s` straight to the buffer (embedded zeros are + # fine) but sends any modified spec through C `sprintf`, which truncates at + # the first NUL; it guards that path by rejecting strings that contain a + # zero byte. + if (flags != 0 or width != nil or precision != nil) and contains_zero?(str) do + raise ArgumentError, + function_name: "string.format", + details: "string contains zeros" + end + case precision do nil -> str - n -> String.slice(str, 0, n) + n -> binary_part(str, 0, min(n, byte_size(str))) end end + defp contains_zero?(<<>>), do: false + defp contains_zero?(<<0, _::binary>>), do: true + defp contains_zero?(<<_, rest::binary>>), do: contains_zero?(rest) + defp format_char(val) when is_integer(val) and val >= 0 and val <= 255, do: <> defp format_char(_) do @@ -664,23 +743,110 @@ defmodule Lua.VM.Stdlib.String do details: "invalid value for %c" end + # %q emits a literal that the Lua reader parses back to the same value. + # Strings are quoted byte-by-byte following PUC-Lua's `addquoted`; numbers, + # nil and booleans follow `addliteral`. Other types have no literal form. defp format_quoted(val) when is_binary(val) do - escaped = - val - |> String.replace("\\", "\\\\") - |> String.replace("\"", "\\\"") - |> String.replace("\n", "\\n") - |> String.replace("\r", "\\r") - |> String.replace("\t", "\\t") + IO.iodata_to_binary([?", quote_string_bytes(val), ?"]) + end - "\"#{escaped}\"" + defp format_quoted(val) when is_integer(val) do + # math.mininteger has no decimal literal form (its negation overflows), so + # PUC-Lua emits it as a hexadecimal literal that wraps to the same value. + if val == -0x8000000000000000 do + "0x8000000000000000" + else + Integer.to_string(val) + end end + defp format_quoted(val) when is_float(val), do: quote_float(val) + defp format_quoted(:nan), do: "(0/0)" + defp format_quoted(nil), do: "nil" + defp format_quoted(true), do: "true" + defp format_quoted(false), do: "false" + defp format_quoted(_) do raise ArgumentError, function_name: "string.format", - expected: "string", - details: "for %q" + details: "value has no literal form" + end + + # Escape a string body following PUC-Lua `addquoted`: `"`, `\` and newline + # take a backslash before the literal byte; other control bytes become a + # decimal escape, zero-padded to three digits only when the next byte is a + # digit (so the reader cannot fold it into the escape). + defp quote_string_bytes(<<>>), do: [] + + defp quote_string_bytes(<>) when c in [?", ?\\, ?\n] do + [?\\, c | quote_string_bytes(rest)] + end + + defp quote_string_bytes(<>) when c < 32 or c == 127 do + digits = + case rest do + <> when d in ?0..?9 -> String.pad_leading(Integer.to_string(c), 3, "0") + _ -> Integer.to_string(c) + end + + [?\\, digits | quote_string_bytes(rest)] + end + + defp quote_string_bytes(<>), do: [c | quote_string_bytes(rest)] + + # %q on a finite float emits a C99 hexadecimal float literal so the value + # round-trips through the reader with no rounding. Infinities use the same + # overflowing/underflowing decimal literals PUC-Lua's `quotefloat` does. + @float_inf <<0::1, 0x7FF::11, 0::52>> + + defp quote_float(val) do + case <> do + @float_inf -> "1e9999" + <<1::1, 0x7FF::11, 0::52>> -> "-1e9999" + _ -> float_to_hexfloat(val) + end + end + + defp float_to_hexfloat(val) do + sign = if float_sign(val) == "-", do: "-", else: "" + <<_::1, exp::11, mantissa::52>> = <> + + cond do + # +/-0.0 + exp == 0 and mantissa == 0 -> + sign <> "0x0p+0" + + # Subnormals: leading digit is 0, unbiased exponent fixed at -1022. + exp == 0 -> + sign <> "0x0" <> fraction(mantissa) <> "p-1022" + + # Normals: implicit leading 1, unbiased exponent is exp - 1023. + true -> + unbiased = exp - 1023 + sign <> "0x1" <> fraction(mantissa) <> exponent_suffix(unbiased) + end + end + + # The fractional part of a hex float, omitting the radix point entirely when + # the mantissa has no significant hex digits (so `1.0` renders `0x1p+0`). + defp fraction(mantissa) do + case hex_mantissa(mantissa) do + "" -> "" + digits -> "." <> digits + end + end + + defp exponent_suffix(e) when e >= 0, do: "p+#{e}" + defp exponent_suffix(e), do: "p-#{-e}" + + # Render the 52-bit mantissa as 13 hex digits, trimming trailing zero digits + # (an empty fraction renders as no digits, matching `0x1p+0`). + defp hex_mantissa(mantissa) do + mantissa + |> Integer.to_string(16) + |> String.downcase() + |> String.pad_leading(13, "0") + |> String.replace(~r/0+$/, "") end # string.find(s, pattern [, init [, plain]]) @@ -853,11 +1019,14 @@ defmodule Lua.VM.Stdlib.String do [str, pad] else # Right justify (default) - # Handle zero-padding with sign — the sign must stay leftmost. - if pad_char == "0" and String.starts_with?(str, "-") do - ["-", pad, binary_part(str, 1, byte_size(str) - 1)] - else - [pad, str] + # Handle zero-padding with sign — the sign (-, + or a reserved space) + # must stay leftmost, ahead of the zero fill. + case str do + <> when pad_char == "0" and sign in [?-, ?+, ?\s] -> + [<>, pad, body] + + _ -> + [pad, str] end end end diff --git a/lib/lua/vm/stdlib/table.ex b/lib/lua/vm/stdlib/table.ex index a7e1339..b2ae9d1 100644 --- a/lib/lua/vm/stdlib/table.ex +++ b/lib/lua/vm/stdlib/table.ex @@ -30,11 +30,16 @@ defmodule Lua.VM.Stdlib.Table do alias Lua.VM.ArgumentError alias Lua.VM.Executor alias Lua.VM.Limits + alias Lua.VM.Numeric alias Lua.VM.RuntimeError alias Lua.VM.State alias Lua.VM.Stdlib.Util alias Lua.VM.Table + # ltablib.c sort rejects arrays whose length reaches INT_MAX (2^31 - 1) + # with "array too big"; we match that ceiling exactly. + @max_sort_length 2_147_483_647 + @impl true def lib_name, do: "table" @@ -275,6 +280,16 @@ defmodule Lua.VM.Stdlib.Table do # than mutating the raw table mid-sort. {len, state} = Executor.table_length(tref, state) + # Mirror ltablib.c sort: reject lengths at/above INT_MAX before + # touching the table, so a `__len` that returns a huge value raises + # "array too big" instead of materialising billions of slots. + if len > 1 and len >= @max_sort_length do + raise ArgumentError, + function_name: "table.sort", + arg_num: 1, + details: "array too big" + end + table = Map.fetch!(state.tables, id) if table.metatable == nil do @@ -509,32 +524,23 @@ defmodule Lua.VM.Stdlib.Table do if f > e do state else - Limits.check_range_count!(e - f + 1, "table.move") - - # Read each src slot via __index on tref1, write to dst via - # __newindex on tref2. Aliasing (tref1 == tref2 with overlapping - # ranges) is handled by reading every value first, then writing - # — matching ltablib.c's tmove which preserves overlap-safety - # only when src and dst are distinct or t <= f. - {values, state} = - Enum.reduce(f..e//1, {[], state}, fn idx, {acc, st} -> - {v, st} = Executor.table_index(tref1, idx, st) - {[v | acc], st} - end) - - values = Enum.reverse(values) - - {final_state, _} = - Enum.reduce(values, {state, t}, fn v, {st, dst_idx} -> - {Executor.table_newindex(tref2, dst_idx, v, st), dst_idx + 1} - end) - - final_state + move_range(tref1, tref2, f, e, t, state) end {[tref2], state} end + # All of f, e, t are valid integers but arg1 is not a table. Mirrors + # ltablib.c tmove, which checks the indices (args 2-4) before the + # source table (arg 1): `table.move(1, 2, 3, 4)` blames arg #1. + defp table_move([tref1, f, e, t | _rest], _state) when is_integer(f) and is_integer(e) and is_integer(t) do + raise ArgumentError, + function_name: "table.move", + arg_num: 1, + expected: "table", + got: Util.typeof(tref1) + end + defp table_move([_tref1, f, e, t | _rest], _state) when is_integer(f) and is_integer(e) do raise ArgumentError, function_name: "table.move", @@ -570,4 +576,67 @@ defmodule Lua.VM.Stdlib.Table do defp table_move([], _state) do raise ArgumentError.value_expected("table.move", 1) end + + # Mirrors ltablib.c tmove for a non-empty range (f <= e). First the two + # overflow argchecks PUC-Lua applies before touching either table, then + # an interleaved read-via-__index / write-via-__newindex loop. The + # interleaving (rather than reading the whole slice up front) is what + # lets a __newindex error abort after the first element — which the + # suite verifies for ranges as wide as 1..maxinteger. + defp move_range(tref1, tref2, f, e, t, state) do + max_int = Numeric.max_int() + + # "too many elements to move": e - f + 1 must not overflow an int. + if !(f > 0 or e < max_int + f) do + raise ArgumentError, + function_name: "table.move", + arg_num: 3, + details: "too many elements to move" + end + + n = e - f + 1 + + # "destination wrap around": t + n - 1 must not overflow an int. + if !(t <= max_int - n + 1) do + raise ArgumentError, + function_name: "table.move", + arg_num: 4, + details: "destination wrap around" + end + + # Host-protection ceiling: when neither table has a metatable, no + # __index/__newindex can interrupt the loop, so a pathological count + # would build tens of millions of slots and exhaust the BEAM. Refuse + # those up front. Metatable-backed moves skip this — the suite drives + # ranges as wide as 1..maxinteger that abort on the first metamethod. + if plain_tables?(tref1, tref2, state) do + Limits.check_range_count!(n, "table.move") + end + + # PUC chooses copy direction so an in-place overlapping move stays + # coherent: copy forward when the destination cannot clobber an + # un-read source slot (t > e || t <= f), otherwise copy backward. + indices = + if t > e or t <= f do + 0..(n - 1)//1 + else + (n - 1)..0//-1 + end + + Enum.reduce(indices, state, fn i, st -> + {v, st} = Executor.table_index(tref1, f + i, st) + Executor.table_newindex(tref2, t + i, v, st) + end) + end + + defp plain_tables?({:tref, id1}, {:tref, id2}, state) do + no_metatable?(id1, state) and no_metatable?(id2, state) + end + + defp no_metatable?(id, state) do + case Map.fetch(state.tables, id) do + {:ok, table} -> table.metatable == nil + :error -> true + end + end end diff --git a/lib/lua/vm/value.ex b/lib/lua/vm/value.ex index 0ca6bd9..91dde89 100644 --- a/lib/lua/vm/value.ex +++ b/lib/lua/vm/value.ex @@ -82,34 +82,62 @@ defmodule Lua.VM.Value do def parse_number(str) do str = String.trim(str) + # A sign must be adjacent to the numeral; Lua's `l_str2d` does not allow + # whitespace between the sign and the digits (`tonumber("+ 1")` is nil). {sign, body} = case str do - "-" <> rest -> {-1, String.trim_leading(rest)} - "+" <> rest -> {1, String.trim_leading(rest)} + "-" <> rest -> {-1, rest} + "+" <> rest -> {1, rest} _ -> {1, str} end - parsed = - if String.starts_with?(body, "0x") or String.starts_with?(body, "0X") do - parse_hex_number(String.slice(body, 2..-1//1)) - else - case Integer.parse(body) do - {n, ""} -> - n - - _ -> - case Float.parse(body) do - {f, ""} -> f - _ -> nil - end - end + if String.starts_with?(body, "0x") or String.starts_with?(body, "0X") do + # Hex literals wrap modulo 2^64 (Lua 5.3 §3.1); the sign is applied after + # wrapping the magnitude into the signed 64-bit range. + case parse_hex_number(String.slice(body, 2..-1//1)) do + nil -> nil + n when sign == 1 -> n + n when is_integer(n) -> Numeric.to_signed_int64(-n) + n when is_float(n) -> -n + end + else + case Integer.parse(body) do + {n, ""} -> decimal_int(sign * n) + _ -> parse_float(sign, body) end + end + end + + # Lua 5.3.3 §3.1: a *decimal* integer literal converts to a float only when + # its signed value overflows the 64-bit range (unlike hex literals, which + # wrap). The sign is applied before the range check, so `-2^63` stays the + # integer `minint` while `2^63` and `-2^63 - 1` become floats. + defp decimal_int(n) when is_integer(n) do + if Numeric.signed?(n), do: n, else: n * 1.0 + end - case parsed do - nil -> nil - n when sign == 1 -> n - n when is_integer(n) -> Numeric.to_signed_int64(-n) - n when is_float(n) -> -n + # Lua's `l_str2d` accepts leading-dot (`.01`) and trailing-dot (`1.`, `1.e2`) + # forms that Elixir's stricter `Float.parse` rejects. Normalise the numeral + # to a fully-formed decimal before delegating; a body with no digit at all + # (`.`, `.e1`) still fails to parse and yields nil. + defp parse_float(sign, body) do + # Reject numerals with no digit in the mantissa (`.`, `.e1`); after + # normalisation these would otherwise parse as 0.0. A digit appearing only + # in the exponent (`.e1`) does not count. + mantissa = body |> String.split(~r/[eE]/, parts: 2) |> hd() + + if Regex.match?(~r/[0-9]/, mantissa) do + normalized = + body + # "1." → "1.0", "1.e2" → "1.0e2" + |> String.replace(~r/\.([eE]|$)/, ".0\\1") + # ".01" → "0.01" + |> then(fn b -> if String.starts_with?(b, "."), do: "0" <> b, else: b end) + + case Float.parse(normalized) do + {f, ""} -> sign * f + _ -> nil + end end end @@ -118,6 +146,13 @@ defmodule Lua.VM.Value do # * fractional: "FF.8" → 255.5 # * exponent only: "FFp4" → 4080.0 # * fractional+exp: "1.8p3" → 12.0 + # `0x.` with no digit at all is not a number. + defp parse_hex_number("."), do: nil + + # A leading dot (`.FF`, `.ABCDEFp+24`) is a valid hex float with no integer + # part, e.g. `tonumber("0x.1")`. Treat it as integer part 0. + defp parse_hex_number("." <> rest), do: parse_hex_frac(0, rest) + defp parse_hex_number(body) do case parse_hex_int(body) do {int_val, ""} -> @@ -137,6 +172,14 @@ defmodule Lua.VM.Value do defp parse_hex_int(""), do: :error defp parse_hex_int(body), do: Integer.parse(body, 16) + # A trailing dot with no fractional digits (`FF.`, `1.p3`) is still a valid + # hex float; the fractional part contributes 0. + defp parse_hex_frac(int_val, ""), do: int_val + 0.0 + + defp parse_hex_frac(int_val, <>) when p in [?p, ?P] do + parse_hex_exp(int_val + 0.0, exp_rest) + end + defp parse_hex_frac(int_val, frac_and_rest) do case Integer.parse(frac_and_rest, 16) do {frac_int, rest} -> diff --git a/test/lua/vm/stdlib/math_test.exs b/test/lua/vm/stdlib/math_test.exs index 99be389..861d504 100644 --- a/test/lua/vm/stdlib/math_test.exs +++ b/test/lua/vm/stdlib/math_test.exs @@ -300,4 +300,83 @@ defmodule Lua.VM.Stdlib.MathTest do assert r3 >= 5 and r3 <= 15 end end + + # Regression tests for Lua 5.3 suite: math.lua. Each pins a numeric-tower + # conformance fix uncovered while triaging that file. + describe "math.lua suite conformance" do + defp run!(code) do + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, results, _state} = VM.execute(proto, Stdlib.install(State.new())) + results + end + + test "math.abs(mininteger) wraps to mininteger (math.lua:579)" do + assert run!("return math.abs(math.mininteger) == math.mininteger") == [true] + assert run!("return math.type(math.abs(math.mininteger))") == ["integer"] + end + + test "math.floor/ceil return integer args unchanged near the 64-bit limit (math.lua:608)" do + assert run!("return math.floor(math.maxinteger) == math.maxinteger") == [true] + assert run!("return math.ceil(math.maxinteger) == math.maxinteger") == [true] + assert run!("return math.floor(math.mininteger) == math.mininteger") == [true] + end + + test "math.floor of a float beyond the 64-bit range stays a float (math.lua:614)" do + assert run!("return math.floor(1e50) == 1e50") == [true] + assert run!("return math.type(math.floor(1e50))") == ["float"] + end + + test "math.tointeger coerces numeric strings and rejects out-of-range floats (math.lua:627)" do + assert run!(~s|return math.tointeger("34.0")|) == [34] + assert run!(~s|return math.tointeger(math.mininteger .. "") == math.mininteger|) == [true] + assert run!(~s|return math.tointeger("34.3")|) == [nil] + assert run!("return math.tointeger(math.huge)") == [nil] + end + + test "math.deg and math.rad convert between degrees and radians (math.lua:577)" do + assert run!("return math.deg(math.pi)") == [180.0] + assert run!("return math.rad(180) == math.pi") == [true] + end + + test "math.random raises on an interval that overflows the 64-bit range (math.lua:819)" do + assert run!("return not pcall(math.random, math.mininteger, 0)") == [true] + assert run!("return not pcall(math.random, -1, math.maxinteger)") == [true] + end + + test "bitwise op on NaN reports no integer representation (math.lua:289)" do + code = "local ok, err = pcall(function() return (0/0) | 0 end) return ok, err" + assert [false, err] = run!(code) + assert err =~ "number has no integer representation" + end + + test "decimal integer literal overflow becomes a float (math.lua:356)" do + assert run!("return math.type(10000000000000000000000)") == ["float"] + assert run!("return 10000000000000000000000.0 == 10000000000000000000000") == [true] + # -9223372036854775808 is unary minus on an overflowing literal, so float + assert run!("return math.type(-10000000000000000000000)") == ["float"] + end + + test "tonumber overflow, sign, and dotted forms match Lua (math.lua:342, 376)" do + assert run!(~s|return math.type(tonumber("9223372036854775808"))|) == ["float"] + assert run!(~s|return tonumber("-9223372036854775808") == math.mininteger|) == [true] + assert run!(~s|return math.type(tonumber("-9223372036854775808"))|) == ["integer"] + assert run!(~s|return tonumber(".01")|) == [0.01] + assert run!(~s|return tonumber("-1.")|) == [-1.0] + assert run!(~s|return tonumber("+ 0.01")|) == [nil] + assert run!(~s|return tonumber(".e1")|) == [nil] + end + + test "tonumber with a base handles whitespace and signs (math.lua:390)" do + assert run!(~s|return tonumber(" 001010 ", 2)|) == [10] + assert run!(~s|return tonumber(" -1010 ", 2)|) == [-10] + assert run!(~s|return tonumber(" +1Z ", 36)|) == [71] + end + + test "hex float strings with leading or trailing dots parse (math.lua:491, 498)" do + assert run!(~s|return tonumber("0x1.")|) == [1.0] + assert run!(~s|return tonumber("0x.1")|) == [0.0625] + assert run!(~s|return tonumber("0x.")|) == [nil] + end + end end diff --git a/test/lua/vm/stdlib/table_test.exs b/test/lua/vm/stdlib/table_test.exs index 5012a3f..e4a257e 100644 --- a/test/lua/vm/stdlib/table_test.exs +++ b/test/lua/vm/stdlib/table_test.exs @@ -204,6 +204,30 @@ defmodule Lua.VM.Stdlib.TableTest do assert {:ok, [10, 20, 30, "hi", "sparse"], _state} = VM.execute(proto, state) end + # ltablib.c sort rejects a length reaching INT_MAX with "array too + # big" before touching the table, so a `__len` returning a huge value + # raises promptly instead of trying to read billions of slots. + test "table.sort rejects an INT_MAX-length array with 'array too big'" do + code = """ + local a = setmetatable({}, {__len = function () return math.maxinteger end}) + table.sort(a) + """ + + assert_raise Lua.RuntimeException, ~r/array too big/, fn -> + Lua.eval!(Lua.new(), code) + end + end + + # A negative __len leaves nothing to sort: the comparator is never + # invoked (here `error` would raise if it were). + test "table.sort with a negative __len compares nothing" do + assert run!(""" + local a = setmetatable({}, {__len = function () return -1 end}) + table.sort(a, error) + return #a + """) == [-1] + end + test "table.move copies elements" do code = """ local t1 = {1, 2, 3, 4, 5} @@ -234,6 +258,58 @@ defmodule Lua.VM.Stdlib.TableTest do end end + describe "table.move argument validation (Lua 5.3 §6.6, sort.lua)" do + # ltablib.c tmove checks the indices (args 2-4) before the source + # table (arg 1), so a non-table first arg with valid integer indices + # is blamed on arg #1, not arg #4. + test "non-table source with valid indices blames arg #1" do + assert_raise Lua.RuntimeException, ~r/#1.*table expected, got number/, fn -> + Lua.eval!(Lua.new(), "table.move(1, 2, 3, 4)") + end + end + + test "overflowing element count raises 'too many'" do + assert_raise Lua.RuntimeException, ~r/too many elements to move/, fn -> + Lua.eval!(Lua.new(), "table.move({}, math.mininteger, math.maxinteger, 1)") + end + end + + test "overflowing destination raises 'wrap around'" do + assert_raise Lua.RuntimeException, ~r/destination wrap around/, fn -> + Lua.eval!(Lua.new(), "table.move({}, 1, math.maxinteger, 2)") + end + end + + # A move over a huge range interleaves read/write per element, so a + # __newindex error aborts after the first slot rather than first + # materialising the whole (impossibly large) slice. + test "metamethod error interrupts a maxinteger-wide move after the first slot" do + code = """ + local pos1, pos2 + local a = setmetatable({}, { + __index = function (_, k) pos1 = k end, + __newindex = function (_, k) pos2 = k; error() end, + }) + local st = pcall(table.move, a, 1, math.maxinteger, 0) + return st, pos1, pos2 + """ + + assert run!(code) == [false, 1, 0] + end + + # Overlapping in-place moves stay coherent: PUC copies backward when + # the destination would otherwise clobber an unread source slot. + test "overlapping forward move with t inside the source range stays coherent" do + code = """ + local t = {10, 20, 30} + table.move(t, 1, 3, 3) + return t[1], t[2], t[3], t[4], t[5] + """ + + assert run!(code) == [10, 20, 10, 20, 30] + end + end + describe "table constructor backfill" do test "constructor entries iterate in insertion order via pairs" do # The constructor backfill writes consecutive integer keys in one @@ -614,6 +690,25 @@ defmodule Lua.VM.Stdlib.TableTest do assert run!(code) == [5, 4, 3, 2, 1] end + test "table.sort comparator drives a __lt metamethod on the elements" do + # The default (no-comparator) sort compares only numbers and + # strings, so element ordering through a __lt metamethod is only + # reachable when the comparator itself uses `<` on the elements. + # This pins that the comparator's `<` dispatches __lt and the + # elements end up ordered by their `val` field. + code = """ + local tt = {__lt = function(a, b) return a.val < b.val end} + local t = {} + for i = 1, 5 do + t[i] = setmetatable({val = 6 - i}, tt) + end + table.sort(t, function(a, b) return a < b end) + return t[1].val, t[2].val, t[3].val, t[4].val, t[5].val + """ + + assert run!(code) == [1, 2, 3, 4, 5] + end + test "__index as a function metamethod is invoked for table.concat" do # When __index is a function (not a table), the stdlib must call # it with (proxy, key) and use the returned value. diff --git a/test/lua/vm/string_test.exs b/test/lua/vm/string_test.exs index 8771c6e..a1dacee 100644 --- a/test/lua/vm/string_test.exs +++ b/test/lua/vm/string_test.exs @@ -288,7 +288,9 @@ defmodule Lua.VM.StringTest do assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") state = Stdlib.install(State.new()) assert {:ok, [result], _state} = VM.execute(proto, state) - assert result == "Quoted: \"hello\\nworld\"" + # %q escapes a newline as a backslash before a literal newline byte (the + # form the Lua reader parses back), not the "\\n" mnemonic. + assert result == "Quoted: \"hello\\\nworld\"" end test "string.format with %% escapes percent" do @@ -308,6 +310,126 @@ defmodule Lua.VM.StringTest do end end + # Regression tests for Lua 5.3 suite: strings.lua (string.format conformance) + describe "string.format %q literal form" do + setup do + %{state: Stdlib.install(State.new())} + end + + test "control bytes use decimal escapes, padded only before a digit", %{state: state} do + code = ~s{return string.format("%q", "\\0") .. "|" .. string.format("%q", "\\0009")} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + # "\0" alone -> \0 (next byte is the closing quote); "\0" before '9' -> + # \000 so the reader cannot fold the digit into the escape. + assert {:ok, [~S{"\0"|"\0009"}], _state} = VM.execute(proto, state) + end + + test "backslash, quote and newline take a backslash before a literal byte", %{state: state} do + code = ~s{return string.format("%q", "\\\\\\"\\n")} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, [result], _state} = VM.execute(proto, state) + assert result == "\"\\\\\\\"\\\n\"" + end + + test "integers render as decimal, math.mininteger as a hex literal", %{state: state} do + code = ~s{return string.format("%q", 42) .. "|" .. string.format("%q", math.mininteger)} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, ["42|0x8000000000000000"], _state} = VM.execute(proto, state) + end + + test "floats render as round-trippable hex floats", %{state: state} do + code = ~s{return string.format("%q", 0.1) .. "|" .. string.format("%q", 1.0)} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, ["0x1.999999999999ap-4|0x1p+0"], _state} = VM.execute(proto, state) + end + + test "nil and booleans render as keywords", %{state: state} do + code = ~s{return string.format("%q %q %q", nil, true, false)} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, ["nil true false"], _state} = VM.execute(proto, state) + end + + test "a value with no literal form errors", %{state: state} do + code = ~s|return string.format("%q", {})| + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + + assert_raise ArgumentError, ~r/no literal form/, fn -> + VM.execute(proto, state) + end + end + end + + describe "string.format conversions and flags" do + setup do + %{state: Stdlib.install(State.new())} + end + + test "%a renders a C99 hex float", %{state: state} do + code = ~s{return string.format("%a", 1.0) .. "|" .. string.format("%A", 0.5)} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, ["0x1p+0|0X1P-1"], _state} = VM.execute(proto, state) + end + + test "%x/%o read a negative integer as unsigned 64-bit", %{state: state} do + code = ~s{return string.format("%x", -1) .. "|" .. string.format("%o", -1)} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, ["ffffffffffffffff|1777777777777777777777"], _state} = VM.execute(proto, state) + end + + test "the + flag forces a sign and stays left of zero padding", %{state: state} do + code = ~s{return string.format("%+08d", 31501) .. "|" .. string.format("% d", 7)} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, ["+0031501| 7"], _state} = VM.execute(proto, state) + end + + test "a modified %s rejects an embedded zero byte", %{state: state} do + code = ~s{return string.format("%10s", "\\0")} + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + + assert_raise ArgumentError, ~r/string contains zeros/, fn -> + VM.execute(proto, state) + end + end + + test "format errors name the specific cause", %{state: state} do + for {code, fragment} <- [ + {~s{return string.format("%100.3d", 10)}, ~r/too long/}, + {~s{return string.format("%000000d", 10)}, ~r/repeated flags/}, + {~s{return string.format("%d %d", 10)}, ~r/no value/} + ] do + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + + assert_raise ArgumentError, fragment, fn -> + VM.execute(proto, state) + end + end + end + end + + describe "string literal line continuation" do + setup do + %{state: Stdlib.install(State.new())} + end + + test "a backslash before a newline collapses to a single newline", %{state: state} do + code = "return \"a\\\nb\"" + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + assert {:ok, ["a\nb"], _state} = VM.execute(proto, state) + end + end + describe "string.format with width/precision" do setup do %{state: Stdlib.install(State.new())} diff --git a/test/lua53_skips.exs b/test/lua53_skips.exs index d1ce977..2c3d75f 100644 --- a/test/lua53_skips.exs +++ b/test/lua53_skips.exs @@ -208,10 +208,31 @@ "locals.lua" => [], "math.lua" => [ %{ - lines: :all, - category: :stdlib, + lines: 409..422, + category: :unimplemented, + reason: + "very-long hex/decimal numerals (2^1200, 16^301) exceed the BEAM IEEE-754 float range (~2^1023), so tonumber and 2.0^exp overflow where PUC-Lua produces a finite or inf result", + issue: nil + }, + %{ + lines: 550..553, + category: :unimplemented, + reason: + "`math.huge % x` should be NaN, but math.huge is the finite 1.0e308 stand-in so the modulo returns a real remainder (no true IEEE infinity on the BEAM)", + issue: nil + }, + %{ + lines: 695..695, + category: :unimplemented, + reason: + "signed-zero division `1/-0.0` should yield -inf; the BEAM does not preserve the sign of zero through division and the inf stand-in is always positive", + issue: nil + }, + %{ + lines: 700..718, + category: :unimplemented, reason: - "triage candidate: fails a checkerror near line 47. NB the prior `math.huge is a finite stand-in` reason was inaccurate — `math.huge + 1 == math.huge` and `1/0 == math.huge` both hold with the 1.0e308 value. Real first failure is unclassified; needs a triage pass.", + "true-infinity arithmetic (math.huge*2+1, inf-inf=NaN) and NaN-keyed table rawset depend on IEEE infinity/NaN the BEAM lacks; math.huge is a finite 1.0e308 stand-in", issue: nil } ], @@ -262,19 +283,39 @@ ], "sort.lua" => [ %{ - lines: :all, - category: :stdlib, + lines: 201..209, + category: :semantic, reason: - "triage candidate: the first checkerror (line 19, table.insert arg count) passes; a later assertion fails. Also has O(n^2) table.sort / 2000-element unpack sections that may need range skips. Needs a triage pass.", + "table.sort with a deliberately-inconsistent comparator should raise 'invalid order function'; our insertion sort never detects it (PUC's quicksort-specific bounds check)", + issue: 262 + }, + %{ + lines: 260..308, + category: :performance, + reason: + "50000-element table.sort timing block: the comparator path is O(n^2) and one comparator calls load() (excluded in the sandbox). The perm block above (lines 240-249) and test/lua/vm/stdlib/table_test.exs cover plain `<` ordering on numbers/strings and explicit-comparator dispatch (including a comparator that drives a __lt metamethod). The trailing `setmetatable(.., {__lt=..}); table.sort(a)` case is NOT covered: our default (no-comparator) sort compares only numbers and strings and never dispatches __lt, so a default sort over table elements raises 'attempt to compare table with table' instead of ordering through __lt.", issue: 262 } ], "strings.lua" => [ %{ - lines: :all, + lines: 199..206, category: :stdlib, reason: - "triage candidate: tostring(function) now prints `function: 0x...` (was bare `function`), clearing line 126. Next blocker is `string.format('%q', ...)` escaping at line 153; later string.rep sections may also need range skips. A format chain, not a one-shot.", + "string.format %s/%q does not dispatch the __tostring/__name metamethods (no per-arg tostring), so a table argument cannot be coerced to its literal form.", + issue: nil + }, + %{ + lines: 275..280, + category: :semantic, + reason: + "%a/%A of 1/0 and 0/0 expect 'inf'/'nan', but the VM clamps division by zero to a finite 1.0e308 instead of an IEEE infinity.", + issue: nil + }, + %{ + lines: 371..376, + category: :unimplemented, + reason: "coroutine library is not implemented (coroutine.wrap).", issue: nil } ]