diff --git a/README.md b/README.md index 4119cd7b..537dea8b 100644 --- a/README.md +++ b/README.md @@ -161,10 +161,7 @@ var results = await state.DoStringAsync("return math.pi"); Console.WriteLine(results[0]); // 3.141592653589793 ``` -For more details on standard libraries, refer to the [Lua official manual](https://www.lua.org/manual/5.2/manual.html#6). - -> [!WARNING] -> Lua-CSharp does not support all functions of the standard libraries. For details, refer to the [Compatibility](#compatibility) section. +See the [standard library API reference](docs/stdlib/README.md) for each function's inputs/results, official Lua 5.2 links, and implementation differences with their reasons. It also lists unavailable APIs and current limitations. The [Lua official manual](https://www.lua.org/manual/5.2/manual.html#6) defines the full language/library contract. ## Functions diff --git a/README_JA.md b/README_JA.md index 4aa75c47..988ee086 100644 --- a/README_JA.md +++ b/README_JA.md @@ -161,10 +161,7 @@ var results = await state.DoStringAsync("return math.pi"); Console.WriteLine(results[0]); // 3.141592653589793 ``` -標準ライブラリについては[Lua公式のマニュアル](https://www.lua.org/manual/5.2/manual.html#6)を参照してください。 - -> [!WARNING] -> Lua-CSharpは標準ライブラリの全ての関数をサポートしているわけではありません。詳細は[互換性](#互換性)の項目を参照してください。 +各APIの引数・戻り値、Lua 5.2公式リファレンスへのリンク、実装上の違いと理由は[標準ライブラリAPIリファレンス(英語)](docs/stdlib/README.md)を参照してください。未対応APIと現在の制限も記載しています。言語・ライブラリの完全な仕様は[Lua公式のマニュアル](https://www.lua.org/manual/5.2/manual.html#6)を参照してください。 ## 関数 diff --git a/docs/stdlib/README.md b/docs/stdlib/README.md new file mode 100644 index 00000000..5305a2f1 --- /dev/null +++ b/docs/stdlib/README.md @@ -0,0 +1,78 @@ +# Lua standard library reference + +This reference describes the Lua-facing APIs installed by Lua-CSharp. Each API links to its entry in the [official Lua 5.2 manual](https://www.lua.org/manual/5.2/manual.html#6). The manual defines the Lua contract; these pages explain the managed implementation, available configuration, and known limitations. Comparisons with C Lua use the [official Lua 5.2.4 source](https://www.lua.org/source/5.2/), with platform/build differences called out where relevant. + +## Open libraries + +```csharp +using Lua; +using Lua.Platforms; +using Lua.Standard; + +using var state = LuaState.Create(); +state.OpenStandardLibraries(); + +// For Lua byte strings and raw stream contents: +using var bytes = LuaState.Create(LuaPlatform.DefaultByteStrings with +{ + NumberFormat = LuaNumberFormat.Lua, +}); +bytes.OpenStandardLibraries(); +``` + +Libraries are opt-in. `LuaState.Create()` alone does not install them. To install a subset, call the corresponding extension in the table below. Configure standard I/O before opening its library: changing the provider later does not replace existing handles. Existing module closures also retain their captured package table. + +Opening a library again republishes its globals/table; it is not an ensure-loaded operation. For example, reopening math replaces its random generator, and reopening I/O resets the default handles. + +| Reference | C# initialization | Official library/global | +| --- | --- | --- | +| [Basic functions and globals](basic.md) | `OpenBasicLibrary()` | [Basic library](https://www.lua.org/manual/5.2/manual.html#6.1) | +| [Coroutines](coroutine.md) | `OpenCoroutineLibrary()` | [`coroutine`](https://www.lua.org/manual/5.2/manual.html#pdf-coroutine) | +| [Packages and modules](package.md) | `OpenModuleLibrary()` | [`package`](https://www.lua.org/manual/5.2/manual.html#pdf-package) | +| [Strings](string.md) | `OpenStringLibrary()` | [`string`](https://www.lua.org/manual/5.2/manual.html#pdf-string) | +| [Tables](table.md) | `OpenTableLibrary()` | [`table`](https://www.lua.org/manual/5.2/manual.html#pdf-table) | +| [Mathematics](math.md) | `OpenMathLibrary()` | [`math`](https://www.lua.org/manual/5.2/manual.html#pdf-math) | +| [Bitwise operations](bit32.md) | `OpenBitwiseLibrary()` | [`bit32`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32) | +| [Files and standard I/O](io.md) | `OpenIOLibrary()` | [`io`](https://www.lua.org/manual/5.2/manual.html#pdf-io) | +| [Operating system](os.md) | `OpenOperatingSystemLibrary()` | [`os`](https://www.lua.org/manual/5.2/manual.html#pdf-os) | +| [Debugging](debug.md) | `OpenDebugLibrary()` | [`debug`](https://www.lua.org/manual/5.2/manual.html#pdf-debug) | + +## Reading the API tables + +Signatures use `[optional]` arguments and `...` for multiple values. The last column includes both implementation details and compatibility differences; a managed backend alone does not imply different Lua behavior. A blank notes cell means no additional note is documented for that API; the shared behavior below still applies. These are implementation notes, not a guarantee that every edge case matches every C Lua build. + +Notes marked **Limitation** describe incomplete compatibility, not a recommended Lua idiom. Notes marked **Host-dependent** depend on a replaceable provider. Explanations identify the implementation mechanism; they do not imply that an existing limitation is intentional. + +## Shared behavior + +### Strings and positions + +The default `LuaStringMode.Utf16` uses .NET UTF-16 code units. `LuaStringMode.Bytes` stores one byte per C# `char`, matching Lua 5.2's byte-string representation. This affects lengths, slices, pattern positions, character classes, and stream counts. Neither mode counts grapheme clusters. See [String modes](../string-modes.md) for conversions and stream configuration. + +This difference allows ordinary C# strings to be used directly in the default mode. Choose byte mode for Lua scripts that depend on byte positions or binary file contents. The `b` in `io.open` does not select the state's string mode. + +### Numbers and text + +Lua numbers use .NET `double`. `LuaPlatform.NumberFormat` defaults to `DotNet`, using invariant .NET numeric text. `Lua` selects 14 significant digits for implicit conversions and portable signed `inf`/`nan` spellings. This is a formatting policy; it does not change arithmetic or parsing. + +This setting affects numeric `tostring`, `print`, numeric I/O, `table.concat`, `gsub` replacements, and applicable `string.format` conversions. A particular C runtime may spell special values differently, including Windows `nan(ind)` or `nan(snan)`; the `Lua` policy deliberately uses portable spellings. See [number formatting](../../README.md#number-formatting). + +### Async calls, cancellation, and errors + +C# functions and platform providers can await asynchronous work. Lua execution awaits them; an incomplete `Task` is not a Lua `coroutine.yield`. Lua callbacks entered through non-yieldable library operations, such as a sort comparator or a `gsub` replacement, cannot yield across that boundary. This preserves Lua 5.2 behavior while allowing C# async integration. + +Host execution cancellation propagates to C# rather than becoming a normal `pcall` result. Built-in stream reads check cancellation at entry and before buffer fills; cancellation cannot interrupt an already blocking synchronous `Stream.Read`. Error wording and tracebacks can contain .NET exception messages and C# frames. + +### Memory and host objects + +The .NET garbage collector owns object lifetimes. Lua weak-table `__mode` semantics and Lua `__gc` finalizers are not implemented; .NET collection does not supply these Lua protocols. Lua GC tuning is also unavailable, and `collectgarbage` has significant limitations documented in [Basic functions](basic.md). Close files explicitly when their close timing matters. + +C# objects can be exposed as userdata, and host functions are wrapped as `LuaFunction` values. Their identity strings and debug metadata describe managed objects rather than native pointers. Host-frozen tables, including generated C# metatables, reject mutation even through raw table operations; this keeps their published interop definitions immutable. + +Host userdata implementing `ILuaUserData` has a per-object metatable. Light-userdata object tokens, including `debug.upvalueid` results, instead share one type metatable within a global state and its coroutines, as in Lua 5.2. `debug.setmetatable` can replace or remove that shared metatable; independent states have separate metatables. + +## Scope + +The pages include missing APIs from Lua 5.2, not just registered functions. Legacy Lua 5.1 compatibility aliases and later-version additions are listed separately where useful. This reference covers Lua script APIs; it does not replace the C# API documentation. + +Implementation entry point: [OpenLibsExtensions.cs](../../src/Lua/Standard/OpenLibsExtensions.cs). diff --git a/docs/stdlib/basic.md b/docs/stdlib/basic.md new file mode 100644 index 00000000..e40c25e2 --- /dev/null +++ b/docs/stdlib/basic.md @@ -0,0 +1,36 @@ +# Basic functions and globals + +[Reference index](README.md) · [Official Lua 5.2 basic library](https://www.lua.org/manual/5.2/manual.html#6.1) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/lbaselib.c.html) · [Implementation](../../src/Lua/Standard/BasicLibrary.cs) + +Install with `state.OpenBasicLibrary()`. Functions are globals. The [shared string, numeric-text, cancellation, and host-object behavior](README.md#shared-behavior) applies throughout. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`assert(v [, message])`](https://www.lua.org/manual/5.2/manual.html#pdf-assert) | Returns all arguments when `v` is truthy; otherwise raises an assertion error. The default message is `assertion failed!`. | The host receives a `LuaAssertionException`. Lua 5.2's message is string-like: a number is converted to text; an arbitrary table is not an assertion error object. | +| [`collectgarbage([opt [, arg]])`](https://www.lua.org/manual/5.2/manual.html#pdf-collectgarbage) | Recognizes `collect`, `stop`, `restart`, `count`, `step`, `setpause`, `setstepmul`, `setmajorinc`, `isrunning`, `incremental`, and `generational`. | **Limitation:** every recognized option calls `GC.Collect()` and returns **no values**. `arg` is ignored. Thus `count` does not report memory, `stop` does not stop GC, and tuning/mode options do not tune it. The implementation delegates to .NET GC without implementing Lua's collector control protocol. | +| [`dofile([filename])`](https://www.lua.org/manual/5.2/manual.html#pdf-dofile) | Loads and executes a file, or standard input for an omitted/nil filename; returns its results and raises load/runtime failures. | Files use `LuaPlatform.FileSystem`; standard input uses `LuaPlatform.StandardIO.Input` and remains open. Paths are decoded to .NET text in byte mode. | +| [`error(message [, level])`](https://www.lua.org/manual/5.2/manual.html#pdf-error) | Raises the supplied error object. At positive levels, numbers become strings and string messages receive a source prefix when that level exists. At level 0 or below, the original object is preserved. | Host-facing errors use `LuaRuntimeException`; stack descriptions include managed frames. Numeric text follows `NumberFormat`. | +| [`_G`](https://www.lua.org/manual/5.2/manual.html#pdf-_G) | The initial global environment table. | Installed from the state's managed environment. Assigning `_G` does not rewrite a function's `_ENV`. | +| [`getmetatable(value)`](https://www.lua.org/manual/5.2/manual.html#pdf-getmetatable) | Returns the metatable, its protected `__metatable` value, or nil. | Also works with managed userdata. | +| [`ipairs(t)`](https://www.lua.org/manual/5.2/manual.html#pdf-ipairs) | Uses `__ipairs` when present; otherwise returns an iterator, the table, and `0`, stopping at the first missing integer element. The default iterator converts its control value to an integer and returns one nil at exhaustion, as in Lua 5.2. | `__ipairs` cannot yield across the library callback boundary. | +| [`load(ld [, source [, mode [, env]]])`](https://www.lua.org/manual/5.2/manual.html#pdf-load) | Compiles source/bytecode from a string or a reader function. Returns a function, or `nil, error`; an empty reader result ends input. `mode` defaults to `bt`. Explicit `env`, including nil, initializes the first upvalue. | **Limitation:** reader pieces are collected before parsing, so readers can be called past the point where C Lua would detect a syntax error or finish a binary chunk. This follows the current whole-input compiler interface. Reader callbacks cannot Lua-yield, but C# readers can await. Raw binary strings work in either mode; see [dumping](string.md#dumping-and-loading). | +| [`loadfile([filename [, mode [, env]]])`](https://www.lua.org/manual/5.2/manual.html#pdf-loadfile) | Compiles a file, or standard input for an omitted/nil filename; returns a function or `nil, error`. Recognizes an initial UTF-8 BOM and first-line `#` comment. | Uses the configured filesystem, with byte-mode paths decoded to .NET text. Standard input uses `LuaPlatform.StandardIO.Input`, independently of `io.input`/`io.stdin` assignments, has chunk name `=stdin`, and remains open on success or failure. Mode/environment handling is the same for both sources; bytecode requires an input provider exposing raw bytes through `ILuaByteStream`, as the default console provider does. | +| [`next(t [, index])`](https://www.lua.org/manual/5.2/manual.html#pdf-next) | Returns the next key/value, or nil after the last key. Omitted/nil `index` starts traversal. | Order follows the managed table storage and is unspecified. **Limitation:** an unknown hash key such as `next({}, 'missing')` returns nil instead of C Lua's invalid-key error: the managed lookup treats that missing iteration position as the end. | +| [`pairs(t)`](https://www.lua.org/manual/5.2/manual.html#pdf-pairs) | Uses `__pairs`, or returns the default iterator, table, and nil. | `__pairs` cannot Lua-yield across the library callback boundary. | +| [`pcall(f [, ...])`](https://www.lua.org/manual/5.2/manual.html#pdf-pcall) | Returns `true, ...results`, or `false, errorObject`. Callable values with `__call` are supported. | Host execution cancellation escapes to C# rather than being consumed as a script error. | +| [`print(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-print) | Calls the global `tostring` for each argument and writes tab-separated output plus a newline. | **Host-dependent:** writes to `LuaPlatform.StandardIO.Output`, which may be a console, redirected stream, or Unity logger. As in C Lua, selecting `io.output` does not redirect `print`. Numeric text uses `NumberFormat`; the `tostring` callbacks cannot Lua-yield. | +| [`rawequal(a, b)`](https://www.lua.org/manual/5.2/manual.html#pdf-rawequal) | Compares values without `__eq`; strings compare by contents. | Tables, functions, threads, and userdata use managed object identity, corresponding to Lua's identity comparison for these types. | +| [`rawget(t, key)`](https://www.lua.org/manual/5.2/manual.html#pdf-rawget) | Reads a table slot without `__index`. | | +| [`rawlen(v)`](https://www.lua.org/manual/5.2/manual.html#pdf-rawlen) | Returns the raw table length or string length without `__len`. | String length counts UTF-16 code units in default mode and bytes in byte mode. | +| [`rawset(t, key, value)`](https://www.lua.org/manual/5.2/manual.html#pdf-rawset) | Writes without `__newindex` and returns `t`. | Host-frozen tables still reject writes; raw access bypasses metamethods, not the managed table's read-only state. | +| [`select(index, ...)`](https://www.lua.org/manual/5.2/manual.html#pdf-select) | `"#"` returns the argument count. A numeric index selects a suffix; negative indices count from the end. | | +| [`setmetatable(t, mt)`](https://www.lua.org/manual/5.2/manual.html#pdf-setmetatable) | Assigns a table metatable or removes it with nil; returns `t`. Protected metatables cannot be changed. | Host-frozen tables also prevent replacing their metatable. | +| [`tonumber(v [, base])`](https://www.lua.org/manual/5.2/manual.html#pdf-tonumber) | Parses numeric text or returns nil. With an explicit base, accepts bases 2–36 and a signed integer string. | A numeric `v` with an explicit base is first formatted using `NumberFormat`, which can affect parsing. Explicit-base digits accumulate in a double, as in the default C Lua 5.2 implementation; no newer Lua integer-subtype arithmetic is used. | +| [`tostring(v)`](https://www.lua.org/manual/5.2/manual.html#pdf-tostring) | Uses `__tostring` when present; otherwise produces the value's standard text. | Numeric output uses `NumberFormat`; managed identities replace C pointer-like representations. `__tostring` cannot Lua-yield through this callback. | +| [`type(v)`](https://www.lua.org/manual/5.2/manual.html#pdf-type) | Reports a Lua type name, including `userdata` for host objects and `thread` for Lua states. | C# functions are reported as `function`; .NET types are not exposed as separate Lua type names. | +| [`_VERSION`](https://www.lua.org/manual/5.2/manual.html#pdf-_VERSION) | The string `"Lua 5.2"`. | Reports the language target, not the Lua-CSharp NuGet/package version. | +| [`xpcall(f, msgh [, ...])`](https://www.lua.org/manual/5.2/manual.html#pdf-xpcall) | Calls `f` protected; on failure invokes `msgh(error)` before unwinding and returns `false, handledError`. On success returns `true, ...results`. | A handler failure produces `error in error handling`. The implementation terminates handler failure directly instead of repeatedly re-entering the same handler. Host execution cancellation propagates to C#. | + +## Compatibility names + +Lua-CSharp does not install the deprecated Lua 5.1 names `loadstring`, global `unpack`, `getfenv`, `setfenv`, or `module`. Use `load`, `table.unpack`, and Lua 5.2 `_ENV`-based modules. See the [official library changes](https://www.lua.org/manual/5.2/manual.html#8.2). diff --git a/docs/stdlib/bit32.md b/docs/stdlib/bit32.md new file mode 100644 index 00000000..23a57b2b --- /dev/null +++ b/docs/stdlib/bit32.md @@ -0,0 +1,24 @@ +# Bitwise operations + +[Reference index](README.md) · [Official Lua 5.2 bit32 library](https://www.lua.org/manual/5.2/manual.html#6.7) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/lbitlib.c.html) · [Implementation](../../src/Lua/Standard/BitwiseLibrary.cs) + +Install with `state.OpenBitwiseLibrary()`. Results are doubles representing unsigned 32-bit words, not a separate integer type. + +Word arguments are rounded with .NET `Math.Round` (nearest, ties to even) and reduced modulo 2^32. Lua 5.2 permits implementation-dependent conversion of fractional words; these are Lua-CSharp's rules, and a C build's conversion may differ. Shift amounts, field positions, and widths are converted separately by truncating to managed integers. Use integral arguments when matching results across implementations. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`bit32.arshift(x, disp)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.arshift) | Arithmetic right shift with sign extension; negative displacement shifts left. | Uses signed 32-bit shifting internally and returns the unsigned word. | +| [`bit32.band(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.band) | Bitwise AND; no arguments returns all 32 bits set. | | +| [`bit32.bnot(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.bnot) | Inverts all 32 bits. | | +| [`bit32.bor(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.bor) | Bitwise OR; no arguments returns zero. | | +| [`bit32.btest(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.btest) | Reports whether the AND of its arguments is nonzero. | | +| [`bit32.bxor(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.bxor) | Bitwise XOR; no arguments returns zero. | | +| [`bit32.extract(n, field [, width])`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.extract) | Extracts a field at bit offset `field`, with width defaulting to 1. | Uses managed integer field/width conversion and rejects fields outside the 32-bit word. | +| [`bit32.lrotate(x, disp)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.lrotate) | Rotates left; negative displacement rotates right. | Rotation displacement is masked modulo 32. | +| [`bit32.lshift(x, disp)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.lshift) | Logical left shift; negative displacement shifts right. Magnitudes at least 32 produce zero. | | +| [`bit32.replace(n, v, field [, width])`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.replace) | Replaces the selected field using the low bits of `v`; default width is 1. | Uses managed integer field/width conversion and checks the 32-bit field boundary. | +| [`bit32.rrotate(x, disp)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.rrotate) | Rotates right; negative displacement rotates left. | Rotation displacement is masked modulo 32. | +| [`bit32.rshift(x, disp)`](https://www.lua.org/manual/5.2/manual.html#pdf-bit32.rshift) | Logical right shift with zero fill; negative displacement shifts left. Magnitudes at least 32 produce zero. | | + +These library calls provide Lua 5.2 bit operations. Lua 5.3 integer bitwise operators and 64-bit integer-subtype behavior are outside this API's contract. diff --git a/docs/stdlib/coroutine.md b/docs/stdlib/coroutine.md new file mode 100644 index 00000000..1b6df75c --- /dev/null +++ b/docs/stdlib/coroutine.md @@ -0,0 +1,16 @@ +# Coroutines + +[Reference index](README.md) · [Official Lua 5.2 coroutine library](https://www.lua.org/manual/5.2/manual.html#6.2) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/lcorolib.c.html) · [Implementation](../../src/Lua/Standard/CoroutineLibrary.cs) + +Install with `state.OpenCoroutineLibrary()`. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`coroutine.create(f)`](https://www.lua.org/manual/5.2/manual.html#pdf-coroutine.create) | Creates a suspended coroutine whose body is `f`; returns a `thread`. | A C# `LuaFunction` can be the body, analogous to using a C function in C Lua. Host functions participate in the managed call machinery. | +| [`coroutine.resume(co [, ...])`](https://www.lua.org/manual/5.2/manual.html#pdf-coroutine.resume) | Starts/resumes `co`; returns `true` followed by yielded or final results, or `false, error` for a script failure. Resume arguments become body arguments or yield results. | Can await asynchronous C# work. Host cancellation follows the managed execution cancellation protocol rather than becoming an ordinary script failure. | +| [`coroutine.running()`](https://www.lua.org/manual/5.2/manual.html#pdf-coroutine.running) | Returns the current thread and a boolean identifying the main thread. | Threads are represented by `LuaState` objects. They are not dedicated OS threads. | +| [`coroutine.status(co)`](https://www.lua.org/manual/5.2/manual.html#pdf-coroutine.status) | Reports `running`, `suspended`, `normal`, or `dead`. | Awaiting C# work does not itself create a Lua yield/resume exchange. | +| [`coroutine.wrap(f)`](https://www.lua.org/manual/5.2/manual.html#pdf-coroutine.wrap) | Returns a resume function that forwards results and raises script failures instead of returning a success boolean. | The wrapper is a managed closure capturing the coroutine. Its capture is visible to the supported debug upvalue APIs. | +| [`coroutine.yield(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-coroutine.yield) | Suspends the current coroutine and passes values to its resumer. Later resume arguments become the yield's results. | Main-thread yields and yields across non-yieldable library callbacks fail. C# `await` is separate: an async host callback can await without being allowed to call Lua `yield`. | + +See [async and cancellation](README.md#async-calls-cancellation-and-errors) and the [C# coroutine guide](../../README.md#coroutines). No Lua 5.3/5.4 additions such as `coroutine.isyieldable` or `coroutine.close` are installed; this library targets Lua 5.2. diff --git a/docs/stdlib/debug.md b/docs/stdlib/debug.md new file mode 100644 index 00000000..9e4521ea --- /dev/null +++ b/docs/stdlib/debug.md @@ -0,0 +1,40 @@ +# Debugging + +[Reference index](README.md) · [Official Lua 5.2 debug library](https://www.lua.org/manual/5.2/manual.html#6.10) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/ldblib.c.html) · [Implementation](../../src/Lua/Standard/DebugLibrary.cs) + +Install with `state.OpenDebugLibrary()`. Thread-aware functions accept an optional first `thread`; otherwise they inspect the current state. Metadata describes Lua bytecode and managed call frames, not a native Lua C stack. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`debug.debug()`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.debug) | Officially enters an interactive debug command loop. | **Unavailable:** not registered. Interactive console/debugger integration must be provided by the host. | +| [`debug.gethook([thread])`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.gethook) | Returns the installed hook, mask, and count. After removing the hook: nil, empty mask, and zero count. | A host-installed hook is returned as its managed `LuaFunction`; C Lua reports a hook installed through its C API as the string `"external hook"`. The host hook's execution policy can differ from a Lua-installed hook; see below. | +| [`debug.getinfo([thread,] f [, what])`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.getinfo) | Returns function/frame metadata selected by `n`, `S`, `l`, `u`, `t`, `f`, and `L`, or nil for a missing stack level. Numeric-string levels are accepted and fractions truncate toward zero, as in Lua 5.2. | C# functions report `what="C#"` and `source="=[C#]"` instead of C-function labels. `u` exposes managed closure capture counts. | +| [`debug.getlocal([thread,] f, index)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.getlocal) | Inspects a frame local/temporary/vararg, or a Lua function's parameter name; returns nil when no such local exists. | Managed frames expose their Lua-facing argument/temporary slots, not arbitrary C# local variables. The implementation operates on Lua-CSharp stack metadata. | +| [`debug.getmetatable(value)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.getmetatable) | Returns the actual metatable, bypassing `__metatable` protection. | May return a host-frozen generated metatable. Reading the actual table does not make its contents writable. | +| [`debug.getregistry()`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.getregistry) | Returns the state's registry table. | Registry contents are managed runtime state; C API integer-slot conventions and native pointers are not a portable contract for Lua-CSharp. | +| [`debug.getupvalue(f, index)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.getupvalue) | Returns an upvalue name/value, or no values for an invalid slot. | Supports Lua closures and `CSharpClosure.UpValues`. Managed capture names and stripped Lua upvalue names are empty strings, as with C-function captures and stripped Lua names in C Lua 5.2.4. | +| [`debug.getuservalue(u)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.getuservalue) | Reads a userdata's first user-value slot, or nil when absent. | Uses `ILuaUserData.UserValues`. Host userdata can expose no slots; values are `LuaValue` entries rather than Lua 5.2's table/nil-only uservalue model. | +| [`debug.sethook([thread,] hook, mask [, count])`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.sethook) | Installs/removes a hook; mask letters are `c`, `r`, and `l`, with an optional instruction count. | Lua-installed hooks cannot Lua-yield. They can call C# code that awaits. Counting is based on VM instructions, not time spent inside a C# function. See [hook details](#hook-details). | +| [`debug.setlocal([thread,] level, index, value)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.setlocal) | Writes a frame local and returns its name, or nil when unavailable. | Writes Lua-facing managed stack slots; it cannot set arbitrary CLR locals. | +| [`debug.setmetatable(value, mt)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.setmetatable) | Replaces/removes the actual metatable, bypassing `__metatable` protection; returns the value. | Host-frozen tables still reject changing their metatable because `LuaTable.Freeze()` protects managed storage. Light userdata, including `debug.upvalueid` tokens and plain host-object values, share one type metatable within a global state and its coroutines, as in Lua 5.2; independent states have separate metatables. | +| [`debug.setupvalue(f, index, value)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.setupvalue) | Writes an upvalue and returns its name, or no values for an invalid slot. | Can update managed closure capture slots; their names are empty. Stripped Lua names are also empty, consistently with `getupvalue` and C Lua 5.2.4. | +| [`debug.setuservalue(u, value)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.setuservalue) | Writes the first user-value slot and returns the userdata. | **Difference/limitation:** any Lua value is accepted, rather than Lua 5.2's table/nil restriction. Userdata exposing no `UserValues` storage returns nil. This reflects the host-defined `Span` storage API. | +| [`debug.traceback([thread,] [message [, level]])`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.traceback) | Builds a traceback; non-string/non-number messages pass through unchanged without inspecting the level. | Formats managed/C# frames and Lua source metadata rather than reproducing C Lua's exact trace text. See [traceback details](#traceback-details). | +| [`debug.upvalueid(f, index)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.upvalueid) | Returns an identity token for an upvalue slot; joined Lua slots share an identity. | Uses weakly associated managed identity objects instead of a native address. Retaining the token does not retain the captured payload; this fits managed GC without exposing movable CLR storage addresses. | +| [`debug.upvaluejoin(f1, n1, f2, n2)`](https://www.lua.org/manual/5.2/manual.html#pdf-debug.upvaluejoin) | Makes one Lua closure's slot share another Lua closure's upvalue. | Restricted to Lua closures, as in Lua 5.2. Managed capture arrays can be inspected/updated but are not joined by this API. | + +## Hook details + +Hook callbacks receive an event string and a line number or nil. Events include `call`, `return`, `tail call`, `line`, and `count`. Count hooks measure executed Lua instructions; they do not tick while execution is waiting inside a C# function. Nested Lua execution from C# can execute instructions and trigger hooks. + +Hooks installed from C# through `LuaState.SetHook` retain the host hook policy, including supported async/yield behavior; they are distinguished internally from `debug.sethook` callbacks. This lets host instrumentation integrate with async execution while keeping Lua-installed callbacks compatible with non-yieldable Lua 5.2 hooks. See the [C# debug-hook guide](../../README.md#debug-hooks). + +Stripped bytecode has no line/local names to recover. Source/line fields and local lookup reflect the metadata actually present; dumping and reloading a stripped chunk cannot reconstruct it. As in [C Lua 5.2](https://www.lua.org/source/5.2/ldebug.c.html#currentline), `getinfo` reports `0` for an active stripped Lua frame, and `-1` for a function-only query or a C# frame. + +## Traceback details + +The default level is 1 for the current thread, including when that thread is supplied explicitly, and 0 for a different thread. Explicit levels skip that many frames. Negative levels and levels beyond the stack return only the message and traceback heading, as in C Lua 5.2. + +Deep Lua-facing tracebacks use Lua 5.2's head/tail abbreviation and insert `...` without formatting the omitted frames. The marker follows Lua 5.2's absolute stack-level rule; an explicit starting level past that marker can produce a longer trace. C# `Traceback` APIs retain complete traces. + +Numeric messages follow `NumberFormat`. Other non-string/non-number messages are returned unchanged; for example, `debug.traceback({}, 'bad')` returns the table without validating the level. A thread in the first argument position selects the target thread; to pass a thread as the message, supply the target thread first. diff --git a/docs/stdlib/io.md b/docs/stdlib/io.md new file mode 100644 index 00000000..618ed9eb --- /dev/null +++ b/docs/stdlib/io.md @@ -0,0 +1,56 @@ +# Files and standard I/O + +[Reference index](README.md) · [Official Lua 5.2 I/O library](https://www.lua.org/manual/5.2/manual.html#6.8) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/liolib.c.html) · [Library implementation](../../src/Lua/Standard/IOLibrary.cs) · [File methods](../../src/Lua/Standard/FileHandle.cs) + +Install with `state.OpenIOLibrary()`. Operations use `LuaPlatform.FileSystem` and the standard-stream handles captured from `LuaPlatform.StandardIO` when the library opens. A custom provider can use memory, assets, or another storage system. + +## Library APIs + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`io.close([file])`](https://www.lua.org/manual/5.2/manual.html#pdf-io.close) | Closes the supplied file, or the default output when no argument is supplied. Explicit nil is an error. | Default console standard handles reject closing. File failures use managed messages/error codes; see below. | +| [`io.flush()`](https://www.lua.org/manual/5.2/manual.html#pdf-io.flush) | Flushes the current default output. | Calls the configured stream's flush operation; it is not necessarily an OS file stream. | +| [`io.input([file])`](https://www.lua.org/manual/5.2/manual.html#pdf-io.input) | Gets the default input, selects an open handle, or opens a filename for reading and selects it. | **Host-dependent:** filenames go through the filesystem provider. Omitted/nil input is a getter; invalid/closed handles are rejected before changing the default. | +| [`io.lines([filename, ...formats])`](https://www.lua.org/manual/5.2/manual.html#pdf-io.lines) | Iterates using read formats. A filename creates an automatically closed file; omitted/nil filename uses default input without automatic close. Default format is `*l`. A failed first read returns no values; successful earlier formats followed by a failure retain their results and the final nil. | Closed default input is rejected when creating the iterator. Automatic close occurs when the first read fails, including a failed numeric conversion, as in Lua 5.2. | +| [`io.open(filename [, mode])`](https://www.lua.org/manual/5.2/manual.html#pdf-io.open) | Opens a file, defaulting to `r`; returns a handle or `nil, message, code` for handled open failures. | String mode, rather than `b`, determines the built-in stream's byte/text encoding. Errors include .NET messages/HResult codes. Custom providers can supply different storage semantics. | +| [`io.output([file])`](https://www.lua.org/manual/5.2/manual.html#pdf-io.output) | Gets/selects default output, or opens a filename for writing and selects it. | Switching output flushes the previous open output in this implementation. This is an additional effect of its explicit managed flush policy. Invalid/closed arguments are validated before that flush. It does not redirect `print`, which uses `StandardIO.Output` directly. | +| [`io.popen(command [, mode])`](https://www.lua.org/manual/5.2/manual.html#pdf-io.popen) | Officially opens a process pipe. | **Unavailable:** not registered; the built-in I/O abstraction does not implement process pipes. A host can expose its own process/stream binding. | +| [`io.read(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-io.read) | Reads from the selected default input using the formats below. | Shares `file:read` semantics, including string units and provider error behavior. | +| [`io.stderr`](https://www.lua.org/manual/5.2/manual.html#pdf-io.stderr) | Initial standard error handle. | **Host-dependent:** wraps the configured error stream; default console handles cannot be closed through Lua. | +| [`io.stdin`](https://www.lua.org/manual/5.2/manual.html#pdf-io.stdin) | Initial standard input handle. | **Host-dependent:** wraps the configured input stream. Selecting another `io.input` does not replace this field. | +| [`io.stdout`](https://www.lua.org/manual/5.2/manual.html#pdf-io.stdout) | Initial standard output handle. | **Host-dependent:** wraps the configured output stream. Selecting another `io.output` does not replace this field. | +| [`io.tmpfile()`](https://www.lua.org/manual/5.2/manual.html#pdf-io.tmpfile) | Creates a temporary read/write file handle, or returns `nil, message, code` for handled I/O/permission failures. | The default provider uses a .NET `FileStream` with `DeleteOnClose`. Error messages/codes come from the managed provider; cancellation still propagates to the host. Close explicitly for deterministic deletion. | +| [`io.type(value)`](https://www.lua.org/manual/5.2/manual.html#pdf-io.type) | Returns `file`, `closed file`, or nil. | Recognizes managed `FileHandle` objects and queries their provider's open state. | +| [`io.write(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-io.write) | Writes string/number arguments to default output and returns that file on success. | Numbers use `NumberFormat`; strings use the configured stream encoding. | + +## File methods + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`file:close()`](https://www.lua.org/manual/5.2/manual.html#pdf-file:close) | Closes the underlying stream; reports success or handled I/O failure. | Calls `ILuaStream.CloseAsync`; close behavior belongs to the provider. The default stream flushes and disposes the underlying .NET stream. Lua GC finalizers are not used to implement deterministic close. | +| [`file:flush()`](https://www.lua.org/manual/5.2/manual.html#pdf-file:flush) | Flushes pending writes. | Calls the provider's flush operation. | +| [`file:lines(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-file:lines) | Returns an iterator over the file using the given formats; does not automatically close the handle. A failed first read returns no values, as with `io.lines`. | Closed handles fail at iterator creation. | +| [`file:read(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-file:read) | Returns one value per successful format, stopping at the first failed conversion/EOF with nil. No formats means `*l`. | Default streams decode UTF-8 to UTF-16, while byte-mode streams preserve raw bytes. Counts use the selected representation. Numeric scanning remains byte-based, even in UTF-16 mode. | +| [`file:seek([whence [, offset]])`](https://www.lua.org/manual/5.2/manual.html#pdf-file:seek) | Seeks using `set`, `cur`, or `end`; defaults are `cur, 0`. Coerces numeric strings but requires an integral offset representable by a managed `long`. Returns the resulting position or handled I/O failure. | Offsets/positions are **bytes** in built-in streams, including UTF-16 mode. Buffered unread bytes are accounted for. If a counted read returned only the high surrogate of a supplementary character, its bytes have already been consumed; a position query retains the pending low surrogate. Underlying seek support is provider-dependent. | +| [`file:setvbuf(mode [, size])`](https://www.lua.org/manual/5.2/manual.html#pdf-file:setvbuf) | Selects `no`, `line`, or `full` buffering policy; returns true when accepted. | **Limitation:** built-in `LuaStream` changes flush policy, not the underlying .NET buffer allocation. `no` flushes each write; `line` flushes writes containing LF; `full` uses a per-write size threshold rather than a cumulative C stdio buffer. `size` does not resize the stream's buffer. Custom providers may implement their own policy. | +| [`file:write(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-file:write) | Writes strings/numbers and returns the file on success. | Default streams encode UTF-16 text as UTF-8; byte-mode streams write raw byte values. Numbers use `NumberFormat`. | + +## Formats, modes, and errors + +The supported read formats are `*n`, `*a`, `*l`, `*L`, and a numeric count. The format dispatcher also accepts `*number`, `*all`, and `*line`. Bare `L` is an additional accepted alias for `*L`; use `*L` for the Lua 5.2 spelling. `*a` returns an empty string at EOF; a count of zero probes EOF without consuming a character. Numeric counts are truncated and must fit a nonnegative managed `int`. + +With built-in streams: + +| Operation | Default UTF-16 mode | Byte mode | +| --- | --- | --- | +| `read(n)` | Up to `n` UTF-16 units; supplementary characters can be split across results | Up to `n` raw bytes | +| `read('*l')` | CR, LF, and CRLF end a line; the ending is removed | LF ends a line; a preceding CR remains in the result | +| `read('*L')` | Keeps the original line ending | Keeps LF and any preceding CR | +| Initial UTF-8 BOM in a text read | Skipped only at the beginning | Preserved as data | +| Invalid UTF-8 | Replaced when decoded | Preserved as bytes | + +`*n` scans decimal/hex numeric bytes and consumes incomplete numeric input items when a conversion fails. It does not decode Unicode whitespace or skip a UTF-8 BOM as a Unicode text read would. This is the numeric scanner's implementation boundary. + +Recognized open modes are `r`, `rb`, `r+`, `r+b`, `w`, `wb`, `w+`, `w+b`, `a`, `ab`, `a+`, and `a+b`. The `b` marker does not change string mode: built-in `LuaStream` encoding was selected by its constructor/platform. Use a matching byte-mode platform for binary files, including `string.dump` output. + +Handled failures generally return `nil, message, code`; closed-file misuse raises an error. Managed error codes are exception `HResult` values, not C `errno` values. Some provider exceptions propagate as Lua errors instead of becoming a result tuple. See [shared cancellation behavior](README.md#async-calls-cancellation-and-errors). diff --git a/docs/stdlib/math.md b/docs/stdlib/math.md new file mode 100644 index 00000000..0fa68b46 --- /dev/null +++ b/docs/stdlib/math.md @@ -0,0 +1,41 @@ +# Mathematics + +[Reference index](README.md) · [Official Lua 5.2 math library](https://www.lua.org/manual/5.2/manual.html#6.6) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/lmathlib.c.html) · [Implementation](../../src/Lua/Standard/MathematicsLibrary.cs) + +Install with `state.OpenMathLibrary()`. Numbers are .NET doubles. Most operations call `System.Math`; decomposition/scaling use managed helpers. Transcendental rounding and exceptional-value details can vary with the .NET runtime/platform, just as C math libraries vary between Lua builds. No C math library or mutable C locale is used. + +Angles are radians unless an API explicitly converts degrees. The [numeric text policy](README.md#numbers-and-text) affects printing results, not the calculations. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`math.abs(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.abs) | Absolute value. | `Math.Abs`. | +| [`math.acos(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.acos) | Inverse cosine. | `Math.Acos`; managed floating-point behavior applies. | +| [`math.asin(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.asin) | Inverse sine. | `Math.Asin`. | +| [`math.atan(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.atan) | Inverse tangent. | One argument, as in Lua 5.2; use `atan2` for two coordinates. | +| [`math.atan2(y, x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.atan2) | Quadrant-aware inverse tangent for `(y, x)`. | `Math.Atan2`. | +| [`math.ceil(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.ceil) | Smallest integral double at least `x`. | | +| [`math.cos(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.cos) | Cosine. | `Math.Cos`. | +| [`math.cosh(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.cosh) | Hyperbolic cosine. | `Math.Cosh`. | +| [`math.deg(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.deg) | Radians to degrees. | Multiplies by `180 / Math.PI`. | +| [`math.exp(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.exp) | Exponential with base e. | `Math.Exp`. | +| [`math.floor(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.floor) | Largest integral double at most `x`. | | +| [`math.fmod(x, y)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.fmod) | Remainder using a quotient truncated toward zero. | Uses the C# double remainder operator. This differs from Lua's `%` operator for some negative inputs, as `fmod` should. | +| [`math.frexp(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.frexp) | Returns mantissa and binary exponent, including subnormal decomposition. | Implemented by a managed double-bit helper rather than C `frexp`. | +| [`math.huge`](https://www.lua.org/manual/5.2/manual.html#pdf-math.huge) | Positive infinity. | Backed by `double.PositiveInfinity`. | +| [`math.ldexp(m, e)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.ldexp) | Scales `m` by a power of two; exponent is converted to an integer. | Uses `Math.ScaleB` on newer targets and a managed fallback on .NET Standard 2.1. | +| [`math.log(x [, base])`](https://www.lua.org/manual/5.2/manual.html#pdf-math.log) | Natural log, or log in the specified base. | Uses `Math.Log`, a base-10 specialization, or the ratio of natural logs. | +| [`math.max(x, ...)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.max) | Maximum of at least one numeric argument. | Uses ordered comparisons, preserving Lua 5.2's argument-order behavior for NaN/equal values. | +| [`math.min(x, ...)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.min) | Minimum of at least one numeric argument. | Uses ordered comparisons rather than a different NaN-selecting aggregate. | +| [`math.modf(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.modf) | Returns integral and fractional parts. | Managed helper preserves signed zero and handles infinities. | +| [`math.pi`](https://www.lua.org/manual/5.2/manual.html#pdf-math.pi) | The double approximation of pi. | Backed by `Math.PI`. | +| [`math.pow(x, y)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.pow) | `x` raised to `y`. | `Math.Pow`. | +| [`math.rad(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.rad) | Degrees to radians. | Multiplies by `Math.PI / 180`. | +| [`math.random([m [, n]])`](https://www.lua.org/manual/5.2/manual.html#pdf-math.random) | Without bounds, a value in `[0,1)`. With bounds, uses Lua 5.2's floor-based interval formula. | Uses `System.Random`; sequences differ from C Lua's C-runtime generator. For fractional bounds, the Lua 5.2 formula is retained; integer-only checks from newer Lua versions are not imposed. The generator is stored in the shared global environment, so coroutines in that state share it. | +| [`math.randomseed(seed)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.randomseed) | Replaces the generator using the converted seed; returns no values. | Uses a 32-bit seed and `System.Random`. Repeatability is tied to the .NET generator/runtime, not C Lua's `srand`/`rand` sequence. | +| [`math.sin(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.sin) | Sine. | `Math.Sin`. | +| [`math.sinh(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.sinh) | Hyperbolic sine. | `Math.Sinh`. | +| [`math.sqrt(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.sqrt) | Square root. | `Math.Sqrt`. | +| [`math.tan(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.tan) | Tangent. | `Math.Tan`. | +| [`math.tanh(x)`](https://www.lua.org/manual/5.2/manual.html#pdf-math.tanh) | Hyperbolic tangent. | `Math.Tanh`. | + +The deprecated `math.log10` alias is absent; use `math.log(x, 10)`. See [official library changes](https://www.lua.org/manual/5.2/manual.html#8.2). Integer-subtype APIs such as `math.type`, `math.tointeger`, and `math.maxinteger` belong to later Lua versions and are not installed. diff --git a/docs/stdlib/os.md b/docs/stdlib/os.md new file mode 100644 index 00000000..67fe2221 --- /dev/null +++ b/docs/stdlib/os.md @@ -0,0 +1,38 @@ +# Operating system + +[Reference index](README.md) · [Official Lua 5.2 OS library](https://www.lua.org/manual/5.2/manual.html#6.9) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/loslib.c.html) · [Implementation](../../src/Lua/Standard/OperatingSystemLibrary.cs) + +Install with `state.OpenOperatingSystemLibrary()`. Time, environment, process exit, and filesystem operations are routed through `LuaPlatform` providers where indicated. Default providers use .NET APIs; Unity/custom providers can behave differently. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`os.clock()`](https://www.lua.org/manual/5.2/manual.html#pdf-os.clock) | Returns elapsed CPU time in seconds. | **Host-dependent:** calls `ILuaOsEnvironment.GetTotalProcessorTime`. The default reads the current .NET process's total processor time, including host work outside Lua. | +| [`os.date([format [, time]])`](https://www.lua.org/manual/5.2/manual.html#pdf-os.date) | Formats a timestamp, or returns date fields for `*t`; a leading `!` selects UTC. Defaults to current time and `%c`. Explicit timestamps are truncated toward zero to whole Unix seconds, as in C Lua 5.2. | Uses managed date/time formatting and the configured `TimeProvider.LocalTimeZone` rather than C `strftime`/process timezone state. Names and composite formats use the C-locale conventions below. | +| [`os.difftime(t2 [, t1])`](https://www.lua.org/manual/5.2/manual.html#pdf-os.difftime) | Returns the difference in whole Unix seconds. Both timestamps truncate toward zero before subtraction; omitted/nil `t1` defaults to zero, as in C Lua 5.2.4's implementation. | Converts to managed 64-bit seconds and subtracts as doubles, without integer subtraction overflow. C `time_t` ranges vary by build; unlike the Windows C runtime, managed arithmetic supports negative timestamps. The official manual lists both arguments, but the C implementation also accepts the zero default. | +| [`os.execute([command])`](https://www.lua.org/manual/5.2/manual.html#pdf-os.execute) | Officially runs a shell command or queries shell availability. | **Unsupported operation:** no command/nil returns false; supplying a command raises a not-supported error. The library has no shell-execution implementation. | +| [`os.exit([code [, close]])`](https://www.lua.org/manual/5.2/manual.html#pdf-os.exit) | Requests termination with a numeric code or boolean success/failure code. Numeric strings are coerced and fractions truncated toward zero to a managed 32-bit code. | **Host-dependent:** default `SystemOsEnvironment` calls `Environment.Exit`; Unity uses its application exit provider. **Limitation:** `close` is ignored, so the Lua state is not explicitly disposed before exiting. | +| [`os.getenv(name)`](https://www.lua.org/manual/5.2/manual.html#pdf-os.getenv) | Returns an environment value or nil. | **Host-dependent:** default provider reads process environment variables; custom/Unity providers may expose a managed dictionary instead. Names/results are converted between byte strings and .NET text when needed. | +| [`os.remove(filename)`](https://www.lua.org/manual/5.2/manual.html#pdf-os.remove) | Removes a file or, on POSIX hosts, an empty directory. Returns true on success or `nil, message, code` for handled I/O/permission failures. | The default provider uses .NET filesystem operations and never removes directories recursively. Windows rejects directory removal, matching its C runtime. Handled errors use .NET HResult codes. | +| [`os.rename(oldname, newname)`](https://www.lua.org/manual/5.2/manual.html#pdf-os.rename) | Moves/renames a file or directory; reports success or handled failure. | **Host-dependent:** default provider uses `File.Move`/`File.Replace` and `Directory.Move`. Directory replacement restrictions follow .NET/host filesystem behavior rather than C `rename`; .NET Standard 2.1 uses `File.Replace` for an existing file destination. | +| [`os.setlocale([locale [, category]])`](https://www.lua.org/manual/5.2/manual.html#pdf-os.setlocale) | Queries the fixed locale with omitted/nil input, or accepts `C`; both return `C`. Validates `all`, `collate`, `ctype`, `monetary`, `numeric`, and `time` categories; the default is `all`. | **Limitation:** other locale strings return nil; no C or .NET locale is changed. Date formatting stays invariant; string casing/classification follows the [string library's rules](string.md). | +| [`os.time([table])`](https://www.lua.org/manual/5.2/manual.html#pdf-os.time) | Without a table, returns current Unix seconds. With a table, reads local date fields through `__index`, accepts numeric strings, truncates fractions toward zero, and normalizes calendar overflow such as day 0 or month 13. Invalid/missing optional hour/minute/second fields default to 12/0/0; invalid/missing required year/month/day fields raise an error. The input table remains unchanged, as in C Lua 5.2.4. | Current time and the local timezone come from `TimeProvider`, consistently with `os.date`. Conversion returns nil for dates outside managed date/time ranges or timestamp -1, Lua 5.2's failure sentinel. `__index` callbacks cannot Lua-yield through this library boundary, but C# callbacks can await. See below for remaining timezone differences. | +| [`os.tmpname()`](https://www.lua.org/manual/5.2/manual.html#pdf-os.tmpname) | Returns a temporary pathname. | **Host-dependent:** default `Path.GetTempFileName` creates an empty file while choosing the name. The caller owns cleanup; unlike `io.tmpfile`, this API does not attach delete-on-close behavior. The name uses the state's string representation. | + +## Date conversion details + +The date formatter implements a managed mapping of [POSIX `strftime` conversions](https://man7.org/linux/man-pages/man3/strftime.3p.html). It uses fixed C-locale forms rather than the host C locale. The following conversions are recognized: + +`%a %A %b %B %c %C %d %D %e %F %g %G %h %H %I %j %m %M %n %p %r %R %S %t %T %u %U %V %w %W %x %X %y %Y %z %Z %%` + +Formatting rules and remaining differences: + +- `%U` and `%W` count weeks starting on Sunday and Monday respectively, with days before the first such day in week 00. `%V`, `%g`, and `%G` use the ISO week number and week-based year. These POSIX conversions are available here even when a non-POSIX C Lua build rejects them. +- `%z` emits a signed offset as `HHMM`, without a colon. `%Z` emits `UTC` for UTC and the configured timezone's .NET standard/daylight name otherwise; these names can differ from C runtime abbreviations. Both omitted and explicit timestamps use the same timezone rules. +- Month/day names are invariant English. `%c` uses `ddd MMM dd HH:mm:ss yyyy` with a space-padded day, `%x` uses `MM/dd/yy`, `%X` uses `HH:mm:ss`, and `%r` uses `hh:mm:ss AM/PM`. These follow POSIX C-locale forms; a particular C runtime's locale-dependent composites can differ. Changing `os.setlocale` has no effect. +- Valid POSIX `%E`/`%O` modifiers use the same C-locale conversions as their unmodified forms: `%E` accepts `cCxXyY`, and `%O` accepts `deHImMSuUVwWy`. Unknown conversions and invalid/incomplete modifier pairs raise an error. + +Timestamp conversion is bounded by .NET date/time ranges. For byte-string states, format literals remain byte-string units and managed timezone names are encoded as text. Use the official link for the complete Lua/C contract and this list for the implemented behavior. + +Date-table normalization uses the first day of the normalized year/month, then adds the day and time offsets. Both that anchor and the resulting date must fit .NET's years 1–9999. C `mktime` ranges and integer field widths depend on the platform; managed conversion does not emulate every C runtime's limits. + +During a repeated local hour, a truthy `isdst` selects the daylight-saving occurrence and false selects standard time. Nil/omitted `isdst` uses .NET's standard-time default for ambiguous dates. This preserves `os.time(os.date('*t', timestamp))` across the repeated hour, including negative daylight-saving adjustments. **Remaining limitation:** for an unambiguous date, `isdst` does not force a seasonal correction as C `mktime` can; the timezone's applicable offset is used. diff --git a/docs/stdlib/package.md b/docs/stdlib/package.md new file mode 100644 index 00000000..6f07703b --- /dev/null +++ b/docs/stdlib/package.md @@ -0,0 +1,32 @@ +# Packages and modules + +[Reference index](README.md) · [Official Lua 5.2 package library](https://www.lua.org/manual/5.2/manual.html#6.3) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/loadlib.c.html) · [Implementation](../../src/Lua/Standard/ModuleLibrary.cs) + +Install with `state.OpenModuleLibrary()`. This installs global `require` and the `package` table. Each opened standard library is registered in the loaded-module table. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`require(name)`](https://www.lua.org/manual/5.2/manual.html#pdf-require) | Returns a truthy cached module or searches for a loader. Calls the loader with `name` and searcher data; caches a non-nil result, or uses the loaded entry/default `true`. A false cache entry allows a later reload. | **Host-dependent:** a configured `ILuaModuleLoader` is consulted before package searchers when it reports the module exists. This lets C# supply resources/assets without native filesystem loading. The package object is captured when the library is opened, so replacing global `package` does not retarget an existing `require`. Loader/searcher callbacks cannot Lua-yield, but host loaders can await. | +| [`package.config`](https://www.lua.org/manual/5.2/manual.html#pdf-package.config) | Five newline-terminated markers describing directory, path-list, substitution, executable-directory, and ignore markers, including the final newline. | The directory character is the host .NET separator; the remaining markers are `;`, `?`, `!`, and `-`. | +| [`package.cpath`](https://www.lua.org/manual/5.2/manual.html#pdf-package.cpath) | Officially configures native C module search paths. | **Unavailable:** no field or native C-module searcher is installed. Lua-CSharp uses managed bindings and host module loaders rather than Lua's C ABI. | +| [`package.loaded`](https://www.lua.org/manual/5.2/manual.html#pdf-package.loaded) | The table used to cache module values. | Backed by the state's managed loaded-module table. As in Lua 5.2, replacing the `package.loaded` field does not replace the cache used by `require`; mutate its entries instead. | +| [`package.loadlib(libname, funcname)`](https://www.lua.org/manual/5.2/manual.html#pdf-package.loadlib) | Officially loads a native library and its Lua C entry point. | **Unavailable:** not registered. A native Lua C function cannot run directly against Lua-CSharp's managed state/stack representation. Expose C# functions or an `ILuaModuleLoader` instead. | +| [`package.path`](https://www.lua.org/manual/5.2/manual.html#pdf-package.path) | A semicolon-separated list of Lua file templates used by the Lua searcher; `?` is replaced with the module path. | Starts as `?.lua`. The library does not initialize it from `LUA_PATH`, versioned environment variables, or an executable installation directory. Configure it explicitly or supply a host module loader. | +| [`package.preload`](https://www.lua.org/manual/5.2/manual.html#pdf-package.preload) | Maps module names to preloaded loader functions, used by searcher 1. | Stored in the managed preload table. Preload entries may be Lua or C# functions. | +| [`package.searchers`](https://www.lua.org/manual/5.2/manual.html#pdf-package.searchers) | Ordered searcher functions returning a loader and optional data, or an error-description string. A missing integer entry ends the search. | Initially contains only preload and Lua-file searchers, in that order. Native C and C-root searchers are absent because there is no native C-module loader. Applications may add Lua/C# searchers. | +| [`package.searchpath(name, path [, sep [, rep]])`](https://www.lua.org/manual/5.2/manual.html#pdf-package.searchpath) | Replaces `sep` (default `.`) in the name with `rep`, tries templates in `path`, and returns the matching filename or `nil, diagnostics`. | **Host-dependent:** readability is decided by `ILuaFileSystem.IsReadable`; the default uses `File.Exists`, not an actual open attempt. Default `rep` comes from the filesystem provider's directory separator. | + +## Host configuration + +Paths returned to Lua use the state's string representation. Paths passed to filesystem/module providers are .NET text, decoded from byte strings where necessary. See [module loading from C#](../../README.md#module-loading). + +The default package initializer does not consult the C Lua path environment variables: + +| Official environment setting | Lua-CSharp behavior and reason | +| --- | --- | +| [`LUA_PATH_5_2`](https://www.lua.org/manual/5.2/manual.html#pdf-LUA_PATH_5_2) | Not read; `package.path` starts as `?.lua` and is configured by Lua/C# code. | +| [`LUA_PATH`](https://www.lua.org/manual/5.2/manual.html#pdf-LUA_PATH) | Not used as a fallback; the initializer has no environment-path expansion step. | +| [`LUA_CPATH_5_2`](https://www.lua.org/manual/5.2/manual.html#pdf-LUA_CPATH_5_2) | Not read; native C-module search/loading is absent. | +| [`LUA_CPATH`](https://www.lua.org/manual/5.2/manual.html#pdf-LUA_CPATH) | Not read for the same native-loader limitation. | + +The deprecated `module`/`package.seeall` compatibility helpers and `package.loaders` alias are not installed. Use Lua 5.2 module tables and `package.searchers`; see [official library changes](https://www.lua.org/manual/5.2/manual.html#8.2). diff --git a/docs/stdlib/string.md b/docs/stdlib/string.md new file mode 100644 index 00000000..0f5580a0 --- /dev/null +++ b/docs/stdlib/string.md @@ -0,0 +1,48 @@ +# Strings + +[Reference index](README.md) · [Official Lua 5.2 string library](https://www.lua.org/manual/5.2/manual.html#6.4) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/lstrlib.c.html) · [Implementation](../../src/Lua/Standard/StringLibrary.cs) + +Install with `state.OpenStringLibrary()`. String method calls such as `s:sub(2)` use this library through the string metatable. + +The central difference is representation: operations use UTF-16 code units by default and bytes in `LuaStringMode.Bytes`. This makes default-mode operations convenient for .NET strings; byte mode preserves Lua's binary-string model. See [String modes](../string-modes.md). Indices are 1-based; negative substring indices count from the end. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`string.byte(s [, i [, j]])`](https://www.lua.org/manual/5.2/manual.html#pdf-string.byte) | Returns the numeric units in the inclusive range, defaulting to the first unit. | Default mode returns UTF-16 code-unit values, including surrogate values. Byte mode returns 0–255. The managed string representation determines the units. | +| [`string.char(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-string.char) | Constructs a string from numeric character values. | Byte mode requires 0–255, as Lua byte strings do. Default mode casts the integer values to UTF-16 `char`, rather than enforcing a byte range; use ordinary text or byte mode according to the intended data. | +| [`string.dump(f)`](https://www.lua.org/manual/5.2/manual.html#pdf-string.dump) | Serializes a Lua function's prototype as a raw binary string. Captured values are not serialized. C# functions cannot be dumped. | Available in both string modes; the result always holds raw byte values. See [dumping and loading](#dumping-and-loading). | +| [`string.find(s, pattern [, init [, plain]])`](https://www.lua.org/manual/5.2/manual.html#pdf-string.find) | Returns match start/end and captures, or nil. `plain` selects literal substring search. | Positions use the state's string units. Pattern character classes use the rules below; plain search uses ordinal string matching. | +| [`string.format(format, ...)`](https://www.lua.org/manual/5.2/manual.html#pdf-string.format) | Formats values using Lua's printf-style conversions, including `%s`, `%q`, integer, floating-point, and hexadecimal floating-point conversions. | Width/precision count managed string units. `%c` constructs a UTF-16 unit in default mode and masks to one byte in byte mode. Numeric text and special values follow `NumberFormat` where applicable. Integer formatting uses 64-bit signed/unsigned ranges; these can differ from a C Lua build's configured integer formatting width. See [format details](#format-details). | +| [`string.gmatch(s, pattern)`](https://www.lua.org/manual/5.2/manual.html#pdf-string.gmatch) | Returns an iterator over captures, or whole matches when there are no captures. | Positions and classes follow the selected string mode. | +| [`string.gsub(s, pattern, replacement [, n])`](https://www.lua.org/manual/5.2/manual.html#pdf-string.gsub) | Returns the replaced string and match count. Replacement may be text, a number, a table, or a function. False/nil replacement results retain the matched text. | Numeric replacements use `NumberFormat`. Table `__index` and replacement callbacks can await C# work but cannot Lua-yield through this library call. | +| [`string.len(s)`](https://www.lua.org/manual/5.2/manual.html#pdf-string.len) | Returns the string length, including embedded zero units. | Counts UTF-16 units by default and bytes in byte mode; neither counts Unicode grapheme clusters. | +| [`string.lower(s)`](https://www.lua.org/manual/5.2/manual.html#pdf-string.lower) | Converts uppercase units to lowercase. | Default mode uses .NET current-culture Unicode casing through `String.ToLower`; byte mode changes ASCII A–Z only. Native Lua uses its C locale. `os.setlocale` does not set .NET's current culture. | +| [`string.match(s, pattern [, init])`](https://www.lua.org/manual/5.2/manual.html#pdf-string.match) | Returns captures or the whole match, or nil. | Positions and classes follow the selected string mode. | +| [`string.rep(s, n [, sep])`](https://www.lua.org/manual/5.2/manual.html#pdf-string.rep) | Repeats `s`, inserting `sep` between copies. Non-positive counts produce an empty string. | Counts are truncated as in Lua 5.2; resulting lengths are bounded by .NET string/int capacity. | +| [`string.reverse(s)`](https://www.lua.org/manual/5.2/manual.html#pdf-string.reverse) | Reverses the sequence of string units. | Default mode can split/reverse surrogate pairs; byte mode reverses bytes and can split UTF-8 sequences. This operation is not Unicode text-element reversal. | +| [`string.sub(s, i [, j])`](https://www.lua.org/manual/5.2/manual.html#pdf-string.sub) | Returns the inclusive slice, with `j` defaulting to the last unit. | Slices UTF-16 units or bytes according to the mode, including partial surrogate pairs/UTF-8 sequences. | +| [`string.upper(s)`](https://www.lua.org/manual/5.2/manual.html#pdf-string.upper) | Converts lowercase units to uppercase. | Default mode uses .NET current-culture Unicode casing through `String.ToUpper`; byte mode changes ASCII a–z only. The host's .NET culture, rather than the process C locale, controls default-mode casing. | + +## Patterns + +[Official pattern syntax](https://www.lua.org/manual/5.2/manual.html#6.4.1) applies: Lua patterns are not .NET regular expressions. Captures, position captures `()`, balanced matches `%b`, frontier patterns `%f`, anchors, and Lua repetition operators are handled by the Lua pattern matcher. + +Default-mode letter, digit, case, control, whitespace, and graphical classes use .NET character classification. `%p` uses ASCII punctuation ranges, `%x` uses ASCII hexadecimal digits, and `%z` matches NUL. In byte mode, all classes use ASCII C-locale rules. Thus non-ASCII letters may match `%a` in default mode but not in byte mode. `os.setlocale` does not change these rules. Pattern recursion and capture counts are bounded by the managed matcher. + +## Format details + +Managed `%s` formatting preserves embedded NUL units. For example, `string.format('%5s', string.char(97, 0, 98))` produces two spaces followed by `a`, NUL, and `b`. C Lua 5.2.4 passes this short string to C `sprintf`, which stops at the NUL and produces four spaces followed by `a`. C Lua has a separate path for long strings without precision, so its `%s` NUL behavior is not uniform across inputs. + +## Dumping and loading + +```lua +local bytes = string.dump(function() return 42 end) +local f = assert(load(bytes, 'saved', 'b')) +assert(f() == 42) +``` + +Use byte-mode streams when saving `bytes` from Lua. In C#, use `LuaByteString.ToBytes` to extract its raw bytes. A UTF-16 stream encodes characters as UTF-8 and can corrupt arbitrary binary strings even if opened with `wb`. + +Chunks use the C Lua 5.2 layout, with the architecture/number sizes recorded in their header. Do not assume portability across different Lua versions or incompatible binary layouts. Older Lua-CSharp chunks generated with the previous incorrect header/tag layout must be regenerated from source. `Prototype.StringMode` preserves the representation of constants during dumping. + +Lua 5.3/5.4 additions such as `string.pack`, `string.unpack`, and `string.packsize` are not part of this Lua 5.2 library. diff --git a/docs/stdlib/table.md b/docs/stdlib/table.md new file mode 100644 index 00000000..b7396f2c --- /dev/null +++ b/docs/stdlib/table.md @@ -0,0 +1,18 @@ +# Tables + +[Reference index](README.md) · [Official Lua 5.2 table library](https://www.lua.org/manual/5.2/manual.html#6.5) · [C Lua 5.2.4 source](https://www.lua.org/source/5.2/ltablib.c.html) · [Implementation](../../src/Lua/Standard/TableLibrary.cs) + +Install with `state.OpenTableLibrary()`. These functions require a Lua table. They access elements raw, following Lua 5.2 rather than later-version proxy-table behavior. Operations that need a default length honor `__len`; that callback cannot Lua-yield across the library call, although C# code can await. + +| API / official reference | Inputs and result | Lua-CSharp implementation notes and differences | +| --- | --- | --- | +| [`table.concat(t [, sep [, i [, j]]])`](https://www.lua.org/manual/5.2/manual.html#pdf-table.concat) | Joins string/number elements in the inclusive range; defaults are empty separator, `i=1`, and `j=#t`. | Numbers use the platform's `NumberFormat`. Managed string capacity bounds the output size. | +| [`table.insert(t, [pos,] value)`](https://www.lua.org/manual/5.2/manual.html#pdf-table.insert) | Inserts at `pos` and shifts following sequence elements; omitting `pos` appends. Returns no values. | Length and insertion positions are bounded by managed integer indices. A host-frozen table rejects mutation because its published contents are immutable. | +| [`table.pack(...)`](https://www.lua.org/manual/5.2/manual.html#pdf-table.pack) | Returns an argument table with an `n` field holding the argument count, including nil arguments. | | +| [`table.remove(t [, pos])`](https://www.lua.org/manual/5.2/manual.html#pdf-table.remove) | Removes the selected sequence element, shifts later elements, and returns the removed value. Defaults to `#t`. | Host-frozen tables reject mutation. | +| [`table.sort(t [, comp])`](https://www.lua.org/manual/5.2/manual.html#pdf-table.sort) | Sorts the sequence in place using `<` or the supplied comparison function; returns no values. Sorting is not stable. | Comparator/ordering metamethod callbacks cannot Lua-yield; C# comparators can await. Host-frozen tables reject writes. These are managed storage and callback constraints. | +| [`table.unpack(t [, i [, j]])`](https://www.lua.org/manual/5.2/manual.html#pdf-table.unpack) | Returns elements from `i` to `j`, including nils. Defaults to `1` and `#t`; an empty range returns no values. | The result range must fit Lua-CSharp's managed stack capacity; oversized ranges raise `too many results to unpack`. | + +As in Lua 5.2, use a sequence when relying on an implicit length. A table with holes does not have a uniquely useful sequence boundary, and a managed table need not choose the same boundary as a particular C Lua table layout. + +The deprecated `table.maxn` helper and global `unpack` alias are not installed. `table.move` is a later-version addition. See [official library changes](https://www.lua.org/manual/5.2/manual.html#8.2). diff --git a/docs/string-modes.md b/docs/string-modes.md index c22135be..1419b3cb 100644 --- a/docs/string-modes.md +++ b/docs/string-modes.md @@ -45,7 +45,7 @@ In byte mode, `string.char` accepts only 0–255. Non-ASCII letters remain uncha Neither mode normalizes Unicode. For example, C# `"é"` and `"e\u0301"` remain different strings and table keys. -This describes string semantics. Existing Lua-CSharp limitations still apply; for example, `string.dump` is unsupported. C# values still use `string` and require the conversions below, with matching stream configuration. +This describes string semantics. See the [standard library API reference](stdlib/README.md) for per-function differences and limitations. `string.dump` is supported and always returns raw bytes in either mode; see [dumping and loading](stdlib/string.md#dumping-and-loading) before writing its result to a stream. C# values still use `string` and require the conversions below, with matching stream configuration. ## Passing values between C# and Lua diff --git a/src/Lua/IO/FileSystem.cs b/src/Lua/IO/FileSystem.cs index f9d57a4e..1ac6a27c 100644 --- a/src/Lua/IO/FileSystem.cs +++ b/src/Lua/IO/FileSystem.cs @@ -1,4 +1,6 @@ -namespace Lua.IO; +using System.Runtime.InteropServices; + +namespace Lua.IO; /// Directory that relative paths are resolved against. /// @@ -121,12 +123,19 @@ public ValueTask Rename(string oldName, string newName, CancellationToken cancel public ValueTask Remove(string path, CancellationToken cancellationToken) { path = GetFullPath(path); - if (!File.Exists(path)) + if (File.Exists(path)) + { + File.Delete(path); + } + else if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Directory.Exists(path)) + { + Directory.Delete(path, recursive: false); + } + else { throw new FileNotFoundException("No such file or directory", path); } - File.Delete(path); return default; } diff --git a/src/Lua/IO/StandardIOStream.cs b/src/Lua/IO/StandardIOStream.cs index 910ec443..85088a69 100644 --- a/src/Lua/IO/StandardIOStream.cs +++ b/src/Lua/IO/StandardIOStream.cs @@ -5,6 +5,8 @@ /// sealed class StandardIOStream(ILuaStream innerStream) : ILuaStream { + internal ILuaStream InnerStream => innerStream; + public LuaFileOpenMode Mode => innerStream.Mode; public bool IsOpen => innerStream.IsOpen; diff --git a/src/Lua/Internal/LuaGlobalState.cs b/src/Lua/Internal/LuaGlobalState.cs index c3983ebc..3301f2df 100644 --- a/src/Lua/Internal/LuaGlobalState.cs +++ b/src/Lua/Internal/LuaGlobalState.cs @@ -39,6 +39,7 @@ sealed class LuaGlobalState LuaTable? booleanMetatable; LuaTable? functionMetatable; LuaTable? stateMetatable; + LuaTable? lightUserDataMetatable; LuaTable? fileHandleMetatable; internal LuaTable FileHandleMetatable => fileHandleMetatable ??= FileHandle.CreateMetatable(); @@ -69,6 +70,7 @@ internal bool TryGetMetatable(LuaValue value, [NotNullWhen(true)] out LuaTable? LuaValueType.Number => numberMetatable, LuaValueType.Function => functionMetatable, LuaValueType.Thread => stateMetatable, + LuaValueType.LightUserData => lightUserDataMetatable, LuaValueType.UserData => value.UnsafeRead().Metatable, LuaValueType.Table => value.UnsafeRead().Metatable, _ => null, @@ -100,6 +102,9 @@ internal void SetMetatable(LuaValue value, LuaTable metatable) case LuaValueType.Thread: stateMetatable = metatable; break; + case LuaValueType.LightUserData: + lightUserDataMetatable = metatable; + break; case LuaValueType.UserData: value.UnsafeRead().Metatable = metatable; break; diff --git a/src/Lua/LuaStateExtensions.cs b/src/Lua/LuaStateExtensions.cs index 0f438050..747603ac 100644 --- a/src/Lua/LuaStateExtensions.cs +++ b/src/Lua/LuaStateExtensions.cs @@ -23,15 +23,30 @@ public static LuaClosure Load(this LuaState state, LuaModule module) : state.Load(module.ReadText(), module.Name); } - /// Loads a file whose name is .NET text, including in byte-string mode. + /// Loads a file whose name is .NET text, or standard input when null. public static async ValueTask LoadFileAsync( this LuaState state, - string fileName, + string? fileName, string mode, LuaTable? environment, CancellationToken cancellationToken ) { + if (fileName is null) + { + var input = state.Platform.StandardIO.Input; + if (input is StandardIOStream standardInput) + input = standardInput.InnerStream; + return await LoadStreamAsync( + state, + input, + "=stdin", + mode, + environment, + cancellationToken + ); + } + var name = state.Platform.EncodeText("@" + fileName); using var stream = await state.GlobalState.Platform.FileSystem.Open( fileName, @@ -39,6 +54,18 @@ CancellationToken cancellationToken cancellationToken ); + return await LoadStreamAsync(state, stream, name, mode, environment, cancellationToken); + } + + static async ValueTask LoadStreamAsync( + LuaState state, + ILuaStream stream, + string name, + string mode, + LuaTable? environment, + CancellationToken cancellationToken + ) + { LuaClosure closure; if (stream is ILuaByteStream byteStream) { @@ -60,15 +87,16 @@ CancellationToken cancellationToken await byteStream.ReadBytesAsync(source, cancellationToken); var bytes = source.WrittenSpan; var chunk = BomUtility.SkipUtf8Bom(bytes); + var text = bytes; if (!chunk.IsEmpty && chunk[0] == '#') { var newline = chunk.IndexOf((byte)'\n'); + text = newline < 0 ? default : chunk[newline..]; chunk = newline < 0 ? default : chunk[(newline + 1)..]; } - // Text keeps its initial comment for line numbers. Binary chunks - // begin after the optional BOM and first-line comment. + // Keep the comment's newline for text source line numbers. closure = state.LoadCore( - !chunk.IsEmpty && chunk[0] == '\e' ? chunk : bytes, + !chunk.IsEmpty && chunk[0] == '\e' ? chunk : text, name, mode, environment @@ -82,7 +110,15 @@ CancellationToken cancellationToken else { var source = await stream.ReadAllAsync(cancellationToken); - closure = state.LoadCore(source, name, environment); + var text = source.AsSpan(); + if (!text.IsEmpty && text[0] == '\uFEFF') + text = text[1..]; + if (!text.IsEmpty && text[0] == '#') + { + var newline = text.IndexOf('\n'); + text = newline < 0 ? default : text[newline..]; + } + closure = state.LoadCore(text, name, environment); } return closure; diff --git a/src/Lua/Runtime/Tracebacks.cs b/src/Lua/Runtime/Tracebacks.cs index f36ce96c..4a6bdfcd 100644 --- a/src/Lua/Runtime/Tracebacks.cs +++ b/src/Lua/Runtime/Tracebacks.cs @@ -148,13 +148,14 @@ internal static string CreateTracebackMessage( LuaGlobalState globalState, ReadOnlySpan stackFrames, LuaValue message, - int skipCount = 0 + int skipCount = 0, + bool abbreviate = false ) { using var list = new PooledList(64); if (message.Type is not LuaValueType.Nil) { - list.AddRange(message.ToString()); + list.AddRange(message.ToLuaString(globalState.Platform.NumberFormat)); list.AddRange("\n"); } @@ -162,8 +163,28 @@ internal static string CreateTracebackMessage( var intFormatBuffer = (stackalloc char[15]); var shortSourceBuffer = (stackalloc char[59]); - for (var index = stackFrames.Length - 1; index >= 0; index--) + const int firstPartSize = 12; + const int lastPartSize = 10; + var firstIndex = stackFrames.Length - 1; + var abbreviationIndex = -1; + if (abbreviate) { + if (firstIndex >= 0 && stackFrames[firstIndex].Function is LuaClosure) + firstIndex--; + // Lua 5.2's marker is at level 11, followed by the oldest 11 frames. + if (firstIndex > firstPartSize + lastPartSize) + abbreviationIndex = firstIndex - firstPartSize + 1; + firstIndex -= Math.Max(skipCount, 0); + skipCount = 0; + } + + for (var index = firstIndex; index >= 0; index--) + { + if (index == abbreviationIndex) + { + list.AddRange("\t...\n"); + index = lastPartSize; + } var lastFunc = stackFrames[index].Function; if (lastFunc is not null and not LuaClosure) { diff --git a/src/Lua/Standard/BasicLibrary.cs b/src/Lua/Standard/BasicLibrary.cs index abad4e90..ece3fa77 100644 --- a/src/Lua/Standard/BasicLibrary.cs +++ b/src/Lua/Standard/BasicLibrary.cs @@ -62,7 +62,7 @@ public BasicLibrary() (context, cancellationToken) => { var table = context.GetArgument(0); - var i = context.GetArgument(1); + var i = Math.Truncate(context.GetArgument(1)); i++; if (table.TryGetValue(i, out var value)) @@ -71,7 +71,7 @@ public BasicLibrary() } else { - return new(context.Return(LuaValue.Nil, LuaValue.Nil)); + return new(context.Return(LuaValue.Nil)); } } ); @@ -131,10 +131,10 @@ public async ValueTask DoFile( CancellationToken cancellationToken ) { - var arg0 = context.GetStringArgument(0); + var arg0 = context.HasArgument(0) ? context.GetStringArgument(0) : null; context.State.Stack.PopUntil(context.ReturnFrameBase); var closure = await context.State.LoadFileAsync( - context.State.Platform.DecodeText(arg0), + arg0 is null ? null : context.State.Platform.DecodeText(arg0), "bt", null, cancellationToken @@ -154,6 +154,8 @@ CancellationToken cancellationToken { var value = context.ArgumentCount == 0 ? LuaValue.Nil : context.Arguments[0]; var level = context.HasArgument(1) ? context.GetIntegerArgument(1) : 1; + if (level > 0 && value.Type == LuaValueType.Number) + value = value.ToLuaString(context.State.Platform.NumberFormat); throw new LuaRuntimeException(context.State, value, level); } @@ -232,7 +234,7 @@ public async ValueTask LoadFile( CancellationToken cancellationToken ) { - var arg0 = context.GetStringArgument(0); + var arg0 = context.HasArgument(0) ? context.GetStringArgument(0) : null; var mode = context.HasArgument(1) ? context.GetStringArgument(1) : "bt"; var hasEnvironment = context.ArgumentCount > 2; var environment = hasEnvironment ? context.GetArgument(2) : LuaValue.Nil; @@ -241,7 +243,7 @@ CancellationToken cancellationToken try { var closure = await context.State.LoadFileAsync( - context.State.Platform.DecodeText(arg0), + arg0 is null ? null : context.State.Platform.DecodeText(arg0), mode, null, cancellationToken diff --git a/src/Lua/Standard/DebugLibrary.cs b/src/Lua/Standard/DebugLibrary.cs index 8b165c0a..e1b92c06 100644 --- a/src/Lua/Standard/DebugLibrary.cs +++ b/src/Lua/Standard/DebugLibrary.cs @@ -372,45 +372,28 @@ CancellationToken cancellationToken var state = GetLuaThread(context, out var argOffset); var message = context.GetArgumentOrDefault(argOffset); - var level = - context.HasArgument(argOffset + 1) ? context.GetIntegerArgument(argOffset + 1) - : argOffset == 0 ? 1 - : 0; - if (message.Type is not (LuaValueType.Nil or LuaValueType.String or LuaValueType.Number)) { return new(context.Return(message)); } - if (level < 0) - { - return new(context.Return(LuaValue.Nil)); - } - - if (state is { IsCoroutine: true, LuaTraceback: { } traceback }) - { - return new( - context.Return( - Runtime.Traceback.CreateTracebackMessage( - context.GlobalState, - traceback.StackFrames, - message, - level - ) - ) - ); - } + var level = + context.HasArgument(argOffset + 1) ? context.GetIntegerArgument(argOffset + 1) + : state == context.State ? 1 + : 0; - var callStack = state.GetCallStackFrames(); - var skipCount = Math.Min(Math.Max(level - 1, 0), callStack.Length); - var frames = callStack[..^skipCount]; + ReadOnlySpan frames = + level < 0 ? [] + : state is { IsCoroutine: true, LuaTraceback: { } traceback } ? traceback.StackFrames + : state.GetCallStackFrames(); return new( context.Return( Runtime.Traceback.CreateTracebackMessage( context.GlobalState, frames, message, - level == 1 ? 1 : 0 + level, + abbreviate: true ) ) ); @@ -571,7 +554,6 @@ public ValueTask GetInfo( CancellationToken cancellationToken ) { - // return new(0); var state = GetLuaThread(context, out var argOffset); var what = context.GetArgumentOrDefault(argOffset + 1, "flnStu"); CallStackFrame? previousFrame = null; @@ -579,12 +561,10 @@ CancellationToken cancellationToken var pc = 0; var arg1 = context.GetArgument(argOffset); - if (arg1.TryReadFunction(out var functionToInspect)) - { - // what = ">" + what; - } - else if (arg1.TryReadNumber(out _)) + if (!arg1.TryReadFunction(out var functionToInspect)) { + if (!arg1.TryReadDouble(out _)) + context.ThrowBadArgument(argOffset, "function or level expected"); var level = context.GetIntegerArgument(argOffset) + 1; var callStack = state.GetCallStackFrames(); @@ -594,19 +574,15 @@ CancellationToken cancellationToken return new(context.Return(LuaValue.Nil)); } - currentFrame = state.GetCallStackFrames()[^level]; + currentFrame = callStack[^level]; previousFrame = level + 1 <= callStack.Length ? callStack[^(level + 1)] : null; if (level != 1) { - pc = state.GetCallStackFrames()[^(level - 1)].CallerInstructionIndex; + pc = callStack[^(level - 1)].CallerInstructionIndex; } functionToInspect = currentFrame.Value.Function; } - else - { - context.ThrowBadArgument(argOffset, "function or level expected"); - } using var debug = LuaDebug.Create( context.GlobalState, diff --git a/src/Lua/Standard/FileHandle.cs b/src/Lua/Standard/FileHandle.cs index 8cd9de93..fdea414d 100644 --- a/src/Lua/Standard/FileHandle.cs +++ b/src/Lua/Standard/FileHandle.cs @@ -249,7 +249,14 @@ static async (context, cancellationToken) => { var file = context.GetArgument(0); var whence = context.HasArgument(1) ? context.GetStringArgument(1) : "cur"; - var offset = context.HasArgument(2) ? context.GetArgument(2) : 0; + var number = context.HasArgument(2) ? context.GetArgument(2) : 0; + if ( + number < long.MinValue + || number >= 9223372036854775808d + || number != Math.Truncate(number) + ) + LuaRuntimeException.BadArgumentNumberIsNotInteger(context.State, 3); + var offset = (long)number; if (whence is not ("set" or "cur" or "end")) { diff --git a/src/Lua/Standard/IOLibrary.cs b/src/Lua/Standard/IOLibrary.cs index ddf7f05d..8444f20e 100644 --- a/src/Lua/Standard/IOLibrary.cs +++ b/src/Lua/Standard/IOLibrary.cs @@ -180,7 +180,6 @@ static async (context, cancellationToken) => var upValues = context.GetCsClosure()!.UpValues; var file = upValues[0].Read(); var formats = upValues.AsMemory(1); - var stack = context.State.Stack; context.Return(); var resultCount = await IOHelper.ReadAsync( context.State, @@ -191,10 +190,7 @@ static async (context, cancellationToken) => true, cancellationToken ); - if ( - resultCount > 0 - && stack.Get(context.ReturnFrameBase).Type is LuaValueType.Nil - ) + if (resultCount == 0) { await file.Close(cancellationToken); } @@ -317,15 +313,26 @@ public async ValueTask TmpFile( CancellationToken cancellationToken ) { - return context.Return( - LuaValue.FromUserData( - new FileHandle( - await context.GlobalState.Platform.FileSystem.OpenTempFileStream( - cancellationToken - ), - context.GlobalState.FileHandleMetatable + try + { + return context.Return( + LuaValue.FromUserData( + new FileHandle( + await context.GlobalState.Platform.FileSystem.OpenTempFileStream( + cancellationToken + ), + context.GlobalState.FileHandleMetatable + ) ) - ) - ); + ); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return context.Return( + LuaValue.Nil, + context.State.Platform.EncodeText(ex.Message), + ex.HResult + ); + } } } diff --git a/src/Lua/Standard/Internal/DateTimeHelper.cs b/src/Lua/Standard/Internal/DateTimeHelper.cs index 01afc7f1..556f66da 100644 --- a/src/Lua/Standard/Internal/DateTimeHelper.cs +++ b/src/Lua/Standard/Internal/DateTimeHelper.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Text; +using Lua.Internal.CompilerServices; +using Lua.Runtime; namespace Lua.Standard.Internal; @@ -27,61 +29,168 @@ public static double GetUnixTime(DateTime dateTime, DateTime epoch) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static DateTime FromUnixTime(double unixTime) { - var ts = TimeSpan.FromSeconds(unixTime); + var ts = TimeSpan.FromSeconds(Math.Truncate(unixTime)); return DateTime.UnixEpoch + ts; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static double GetUnixTimeFromLocalTime(DateTime dateTime) + public static double GetUnixTimeFromLocalTime(DateTime dateTime, TimeZoneInfo zone, bool isDst) { - return new DateTimeOffset( - DateTime.SpecifyKind(dateTime, DateTimeKind.Local) - ).ToUnixTimeSeconds(); + var offset = zone.GetUtcOffset(dateTime); + // GetUtcOffset already selects standard time for an ambiguous clock reading. + if (isDst && zone.IsAmbiguousTime(dateTime)) + { + var offsets = zone.GetAmbiguousTimeOffsets(dateTime); + var first = new DateTimeOffset(dateTime, offsets[0]); + offset = zone.IsDaylightSavingTime(first) ? offsets[0] : offsets[1]; + } + return new DateTimeOffset(dateTime, offset).ToUnixTimeSeconds(); } - public static DateTime ParseTimeTable(LuaState state, LuaTable table) + public static LuaValue GetTime(LuaState state, LuaTable table) { - static int GetTimeField( - LuaState state, - LuaTable table, - string key, - bool required = true, - int defaultValue = 0 - ) - { - if (!table.TryGetValue(key, out var value)) - { - if (required) - { - throw new LuaRuntimeException(state, $"field '{key}' missing in date table"); - } - else - { - return defaultValue; - } - } + var sec = GetTimeField(state, table["sec"], "sec", 0); + var min = GetTimeField(state, table["min"], "min", 0); + var hour = GetTimeField(state, table["hour"], "hour", 12); + var day = GetTimeField(state, table["day"], "day"); + var month = GetTimeField(state, table["month"], "month"); + var year = GetTimeField(state, table["year"], "year"); + var zone = state.Platform.TimeProvider.LocalTimeZone; + return CreateTime( + zone, + sec, + min, + hour, + day, + month, + year, + zone.SupportsDaylightSavingTime && table["isdst"].ToBoolean() + ); + } - if (value.TryRead(out var d) && MathEx.IsInteger(d)) + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + public static async ValueTask GetTimeAsync( + LuaState state, + LuaTable table, + CancellationToken cancellationToken + ) + { + var sec = GetTimeField( + state, + await ReadFieldAsync(state, table, "sec", cancellationToken), + "sec", + 0 + ); + var min = GetTimeField( + state, + await ReadFieldAsync(state, table, "min", cancellationToken), + "min", + 0 + ); + var hour = GetTimeField( + state, + await ReadFieldAsync(state, table, "hour", cancellationToken), + "hour", + 12 + ); + var day = GetTimeField( + state, + await ReadFieldAsync(state, table, "day", cancellationToken), + "day" + ); + var month = GetTimeField( + state, + await ReadFieldAsync(state, table, "month", cancellationToken), + "month" + ); + var year = GetTimeField( + state, + await ReadFieldAsync(state, table, "year", cancellationToken), + "year" + ); + var isDst = (await ReadFieldAsync(state, table, "isdst", cancellationToken)).ToBoolean(); + return CreateTime( + state.Platform.TimeProvider.LocalTimeZone, + sec, + min, + hour, + day, + month, + year, + isDst + ); + } + + static LuaValue CreateTime( + TimeZoneInfo zone, + int sec, + int min, + int hour, + int day, + int month, + int year, + bool isDst + ) + { + try + { + var normalizedYear = Math.DivRem( + (long)year * 12 + month - 1, + 12, + out var normalizedMonth + ); + if (normalizedMonth < 0) { - return (int)d; + normalizedMonth += 12; + normalizedYear--; } - throw new LuaRuntimeException(state, $"field '{key}' is not an integer"); + var date = new DateTime(checked((int)normalizedYear), (int)normalizedMonth + 1, 1); + date = date.AddSeconds(((long)day - 1) * 86400 + hour * 3600L + min * 60L + sec); + var time = GetUnixTimeFromLocalTime(date, zone, isDst); + return time == -1 ? LuaValue.Nil : time; + } + catch (ArgumentOutOfRangeException) + { + return LuaValue.Nil; } + catch (OverflowException) + { + return LuaValue.Nil; + } + } - var day = GetTimeField(state, table, "day"); - var month = GetTimeField(state, table, "month"); - var year = GetTimeField(state, table, "year"); - var sec = GetTimeField(state, table, "sec", false, 0); - var min = GetTimeField(state, table, "min", false, 0); - var hour = GetTimeField(state, table, "hour", false, 12); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static int GetTimeField(LuaState state, LuaValue value, string key, int defaultValue = -1) + { + if (value.Type != LuaValueType.Nil && value.TryReadDouble(out var number)) + return (int)(long)number; + if (defaultValue >= 0) + return defaultValue; + throw new LuaRuntimeException(state, $"field '{key}' missing in date table"); + } - return new(year, month, day, hour, min, sec); + static ValueTask ReadFieldAsync( + LuaState state, + LuaTable table, + string key, + CancellationToken cancellationToken + ) + { + var value = table[key]; + if (value.Type != LuaValueType.Nil || table.Metatable is null) + return new(value); + return LuaVirtualMachine.ExecuteGetTableSlowPath(state, table, key, cancellationToken); } - public static string StrFTime(LuaState state, ReadOnlySpan format, DateTime d) + public static string StrFTime( + LuaState state, + ReadOnlySpan format, + DateTimeOffset time, + TimeZoneInfo zone + ) { - // reference: http://www.cplusplus.com/reference/ctime/strftime/ + var d = time.DateTime; [MethodImpl(MethodImplOptions.NoInlining)] static void ThrowInvalidConversionSpecifier(LuaState state, ReadOnlySpan format) @@ -101,33 +210,29 @@ static void ThrowInvalidConversionSpecifier(LuaState state, ReadOnlySpan f 'A' => "dddd", 'b' => "MMM", 'B' => "MMMM", - 'c' => "f", 'd' => "dd", 'D' => "MM/dd/yy", 'F' => "yyyy-MM-dd", - 'g' => "yy", - 'G' => "yyyy", 'h' => "MMM", 'H' => "HH", 'I' => "hh", 'm' => "MM", 'M' => "mm", 'p' => "tt", - 'r' => "h:mm:ss tt", + 'r' => "hh:mm:ss tt", 'R' => "HH:mm", 'S' => "ss", 'T' => "HH:mm:ss", 'y' => "yy", 'Y' => "yyyy", - 'x' => "d", - 'X' => "T", - 'z' => "zzz", - 'Z' => "zzz", + 'x' => "MM/dd/yy", + 'X' => "HH:mm:ss", _ => null, }; } - ValueStringBuilder builder = new(); + using ValueStringBuilder builder = new(); + Span formatBuffer = stackalloc char[32]; var isEscapeSequence = false; @@ -158,7 +263,10 @@ static void ThrowInvalidConversionSpecifier(LuaState state, ReadOnlySpan f if (c is 'O' or 'E') { - ThrowInvalidConversionSpecifier(state, format); + var conversions = c == 'E' ? "cCxXyY" : "deHImMSuUVwWy"; + if (++i >= format.Length || conversions.IndexOf(format[i]) < 0) + ThrowInvalidConversionSpecifier(state, format); + c = format[i]; } isEscapeSequence = false; @@ -166,11 +274,24 @@ static void ThrowInvalidConversionSpecifier(LuaState state, ReadOnlySpan f var pattern = STANDARD_PATTERNS(c); if (pattern != null) { - builder.Append(d.ToString(pattern, CultureInfo.InvariantCulture)); + d.TryFormat(formatBuffer, out var written, pattern, CultureInfo.InvariantCulture); + builder.Append(formatBuffer[..written]); } else switch (c) { + case 'c': + d.TryFormat( + formatBuffer, + out var written, + "ddd MMM dd HH:mm:ss yyyy", + CultureInfo.InvariantCulture + ); + // C-locale %c space-pads a single-digit day. + if (d.Day < 10) + formatBuffer[8] = ' '; + builder.Append(formatBuffer[..written]); + break; case 'e': { var s = d.ToString("%d", CultureInfo.InvariantCulture); @@ -184,8 +305,7 @@ static void ThrowInvalidConversionSpecifier(LuaState state, ReadOnlySpan f builder.Append('\t'); break; case 'C': - // TODO: reduce allocation - builder.Append((d.Year / 100).ToString(CultureInfo.InvariantCulture)); + builder.Append((d.Year / 100).ToString("00", CultureInfo.InvariantCulture)); break; case 'j': builder.Append(d.DayOfYear.ToString("000", CultureInfo.InvariantCulture)); @@ -208,12 +328,45 @@ static void ThrowInvalidConversionSpecifier(LuaState state, ReadOnlySpan f break; } case 'U': - // ISO 8601 week number (00-53) - case 'V': - // Week number with the first Monday as the first day of week one (00-53) case 'W': - // Week number with the first Sunday as the first day of week one (00-53) - builder.Append("??"); + { + var firstDay = c == 'U' ? 0 : 1; + var weekDay = ((int)d.DayOfWeek + 7 - firstDay) % 7; + var week = (d.DayOfYear + 6 - weekDay) / 7; + builder.Append(week.ToString("00", CultureInfo.InvariantCulture)); + break; + } + case 'V': + builder.Append( + ISOWeek.GetWeekOfYear(d).ToString("00", CultureInfo.InvariantCulture) + ); + break; + case 'g': + case 'G': + { + var year = ISOWeek.GetYear(d); + builder.Append( + (c == 'g' ? year % 100 : year).ToString( + c == 'g' ? "00" : "0000", + CultureInfo.InvariantCulture + ) + ); + break; + } + case 'z': + builder.Append(time.Offset < TimeSpan.Zero ? '-' : '+'); + var offset = time.Offset.Duration(); + builder.Append(offset.Hours.ToString("00", CultureInfo.InvariantCulture)); + builder.Append(offset.Minutes.ToString("00", CultureInfo.InvariantCulture)); + break; + case 'Z': + builder.Append( + state.Platform.EncodeText( + zone == TimeZoneInfo.Utc ? "UTC" + : zone.IsDaylightSavingTime(time) ? zone.DaylightName + : zone.StandardName + ) + ); break; default: ThrowInvalidConversionSpecifier(state, format); @@ -226,6 +379,6 @@ static void ThrowInvalidConversionSpecifier(LuaState state, ReadOnlySpan f ThrowInvalidConversionSpecifier(state, format); } - return builder.ToString(); + return builder.AsSpan().ToString(); } } diff --git a/src/Lua/Standard/Internal/IOHelper.cs b/src/Lua/Standard/Internal/IOHelper.cs index 0f64d3e6..0e8e7310 100644 --- a/src/Lua/Standard/Internal/IOHelper.cs +++ b/src/Lua/Standard/Internal/IOHelper.cs @@ -142,7 +142,7 @@ CancellationToken cancellationToken stack.Push(number ?? LuaValue.Nil); if (number == null) { - return i + 1; + goto ReadFailed; } break; case "*a": @@ -155,7 +155,7 @@ CancellationToken cancellationToken stack.Push(line ?? LuaValue.Nil); if (line == null) { - return i + 1; + goto ReadFailed; } break; case "L": @@ -164,7 +164,7 @@ CancellationToken cancellationToken stack.Push(text == null ? LuaValue.Nil : text); if (text == null) { - return i + 1; + goto ReadFailed; } break; default: @@ -190,7 +190,7 @@ CancellationToken cancellationToken if (ret == null) { stack.Push(default); - return i + 1; + goto ReadFailed; } else { @@ -209,6 +209,14 @@ CancellationToken cancellationToken } return formats.Length; + + ReadFailed: + if (throwError && stack.Get(top).Type == LuaValueType.Nil) + { + stack.PopUntil(top); + return 0; + } + return stack.Count - top; } catch (IOException ex) { diff --git a/src/Lua/Standard/OpenLibsExtensions.cs b/src/Lua/Standard/OpenLibsExtensions.cs index 0d3937f6..6ffca472 100644 --- a/src/Lua/Standard/OpenLibsExtensions.cs +++ b/src/Lua/Standard/OpenLibsExtensions.cs @@ -101,7 +101,7 @@ public static void OpenModuleLibrary(this LuaState state) package["searchers"] = searchers; package["path"] = "?.lua"; package["searchpath"] = moduleLibrary.SearchPathFunction; - package["config"] = $"{Path.DirectorySeparatorChar}\n;\n?\n!\n-"; + package["config"] = $"{Path.DirectorySeparatorChar}\n;\n?\n!\n-\n"; globalState.Environment["package"] = package; globalState.LoadedModules["package"] = package; globalState.Environment["require"] = new CSharpClosure( diff --git a/src/Lua/Standard/OperatingSystemLibrary.cs b/src/Lua/Standard/OperatingSystemLibrary.cs index 51d3433f..ef3e70a6 100644 --- a/src/Lua/Standard/OperatingSystemLibrary.cs +++ b/src/Lua/Standard/OperatingSystemLibrary.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; +using Lua.Internal.CompilerServices; using Lua.Standard.Internal; namespace Lua.Standard; @@ -51,23 +53,12 @@ CancellationToken cancellationToken format = format[1..]; } - DateTime now; - if (context.HasArgument(1)) - { - var time = context.GetArgument(1); - now = DateTimeHelper.FromUnixTime(time); - if (!useUtc) - { - now = now.ToLocalTime(); - } - } - else - { - var provider = context.GlobalState.Platform.TimeProvider; - now = useUtc ? provider.GetUtcNow().DateTime : provider.GetLocalNow().DateTime; - } - - var isDst = !useUtc && now.IsDaylightSavingTime(); + var provider = context.GlobalState.Platform.TimeProvider; + var zone = useUtc ? TimeZoneInfo.Utc : provider.LocalTimeZone; + var now = context.HasArgument(1) + ? new DateTimeOffset(DateTimeHelper.FromUnixTime(context.GetArgument(1))) + : provider.GetUtcNow(); + now = TimeZoneInfo.ConvertTime(now, zone); if (format is "*t") { @@ -81,14 +72,14 @@ CancellationToken cancellationToken ["sec"] = now.Second, ["wday"] = (int)now.DayOfWeek + 1, ["yday"] = now.DayOfYear, - ["isdst"] = isDst, + ["isdst"] = zone.IsDaylightSavingTime(now), }; return new(context.Return(table)); } else { - return new(context.Return(DateTimeHelper.StrFTime(context.State, format, now))); + return new(context.Return(DateTimeHelper.StrFTime(context.State, format, now, zone))); } } @@ -97,9 +88,9 @@ public ValueTask DiffTime( CancellationToken cancellationToken ) { - var t2 = context.GetArgument(0); - var t1 = context.GetArgument(1); - return new(context.Return(t2 - t1)); + var t2 = (long)context.GetArgument(0); + var t1 = context.HasArgument(1) ? (long)context.GetArgument(1) : 0; + return new(context.Return((double)t2 - t1)); } public ValueTask Execute( @@ -134,13 +125,9 @@ CancellationToken cancellationToken { exitCode = b ? 0 : 1; } - else if (code.TryRead(out var d)) - { - exitCode = d; - } else { - LuaRuntimeException.BadArgument(context.State, 1, LuaValueType.Nil, code.Type); + exitCode = context.GetIntegerArgument(0); } } @@ -173,7 +160,7 @@ await context.GlobalState.Platform.FileSystem.Remove( ); return context.Return(true); } - catch (IOException ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return context.Return( LuaValue.Nil, @@ -214,15 +201,12 @@ public ValueTask SetLocale( CancellationToken cancellationToken ) { - // os.setlocale is not supported besides the standard "C" POSIX locale - // Any locale besides "C" will return nil - var locale = context.GetStringArgument(0); - if (locale == "C") - { - return new(context.Return(locale)); - } + var locale = context.HasArgument(0) ? context.GetStringArgument(0) : null; + var category = context.HasArgument(1) ? context.GetStringArgument(1) : "all"; + if (category is not ("all" or "collate" or "ctype" or "monetary" or "numeric" or "time")) + LuaRuntimeException.BadArgument(context.State, 2, $"invalid option '{category}'"); - return new(context.Return(LuaValue.Nil)); + return new(context.Return(locale is null or "C" ? "C" : LuaValue.Nil)); } public ValueTask Time( @@ -233,8 +217,9 @@ CancellationToken cancellationToken if (context.HasArgument(0)) { var table = context.GetArgument(0); - var date = DateTimeHelper.ParseTimeTable(context.State, table); - return new(context.Return(DateTimeHelper.GetUnixTimeFromLocalTime(date))); + if (table.Metatable is not null) + return TimeWithMetatableAsync(context, table, cancellationToken); + return new(context.Return(DateTimeHelper.GetTime(context.State, table))); } else { @@ -247,6 +232,26 @@ CancellationToken cancellationToken } } + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + static async ValueTask TimeWithMetatableAsync( + LuaFunctionExecutionContext context, + LuaTable table, + CancellationToken cancellationToken + ) + { + context.State.NonYieldableCallCount++; + try + { + return context.Return( + await DateTimeHelper.GetTimeAsync(context.State, table, cancellationToken) + ); + } + finally + { + context.State.NonYieldableCallCount--; + } + } + public ValueTask TmpName( LuaFunctionExecutionContext context, CancellationToken cancellationToken diff --git a/tests/Lua.Tests/DateBufferTests.cs b/tests/Lua.Tests/DateBufferTests.cs new file mode 100644 index 00000000..e4bec912 --- /dev/null +++ b/tests/Lua.Tests/DateBufferTests.cs @@ -0,0 +1,66 @@ +using System.Diagnostics.Tracing; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class DateBufferTests(LuaStringMode mode) +{ + [TestCase("%Q")] + [TestCase("%")] + [TestCase("%E")] + [NonParallelizable] + public void InvalidFormatsReturnEveryRentedBuffer(string suffix) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["format"] = new string('x', 4096) + suffix; + var closure = state.Load("assert(not pcall(os.date,format))", "@date-buffer.lua"); + using var events = new PoolEvents(); + // Exclude one-time runtime initialization from buffer ownership tracking. + state.ExecuteAsync(closure, Memory.Empty).GetAwaiter().GetResult(); + events.ThreadId = Environment.CurrentManagedThreadId; + try + { + state.ExecuteAsync(closure, Memory.Empty).GetAwaiter().GetResult(); + } + finally + { + events.ThreadId = 0; + } + Assert.That(events.Rents, Is.GreaterThan(0)); + Assert.That(events.Outstanding, Is.Empty); + } + + sealed class PoolEvents : EventListener + { + public int ThreadId; + public int Rents; + public readonly HashSet<(int Buffer, int Pool)> Outstanding = new(); + + protected override void OnEventSourceCreated(EventSource source) + { + if (source.Name == "System.Buffers.ArrayPoolEventSource") + EnableEvents(source, EventLevel.Verbose); + } + + protected override void OnEventWritten(EventWrittenEventArgs e) + { + if (ThreadId == 0 || ThreadId != Environment.CurrentManagedThreadId) + return; + if (e.EventName == "BufferRented") + { + Rents++; + Outstanding.Add(((int)e.Payload![0]!, (int)e.Payload[2]!)); + } + else if (e.EventName == "BufferReturned") + { + Outstanding.Remove(((int)e.Payload![0]!, (int)e.Payload[2]!)); + } + } + } +} diff --git a/tests/Lua.Tests/DateFormatTests.cs b/tests/Lua.Tests/DateFormatTests.cs new file mode 100644 index 00000000..04c3a555 --- /dev/null +++ b/tests/Lua.Tests/DateFormatTests.cs @@ -0,0 +1,229 @@ +using Lua.Platforms; +using Lua.Standard; +using Microsoft.Extensions.Time.Testing; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class DateFormatTests(LuaStringMode mode) +{ + LuaState CreateState(FakeTimeProvider provider) + { + var platform = + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default; + var state = LuaState.Create(platform with { TimeProvider = provider }); + state.OpenStandardLibraries(); + return state; + } + + [TestCase(2016, 1, 1, "00 00 53 15 2015")] + [TestCase(2016, 1, 3, "01 00 53 15 2015")] + [TestCase(2016, 1, 4, "01 01 01 16 2016")] + [TestCase(2017, 1, 1, "01 00 52 16 2016")] + [TestCase(2018, 12, 31, "52 53 01 19 2019")] + [TestCase(2020, 12, 31, "52 52 53 20 2020")] + [TestCase(2021, 1, 1, "00 00 53 20 2020")] + public async Task WeekNumbersAndIsoYearsRespectYearBoundaries( + int year, + int month, + int day, + string expected + ) + { + var provider = new FakeTimeProvider( + new DateTimeOffset(year, month, day, 12, 0, 0, TimeSpan.Zero) + ); + using var state = CreateState(provider); + var result = await state.DoStringAsync("return os.date('!%U %W %V %g %G')"); + Assert.That(result, Is.EqualTo(new LuaValue[] { expected })); + } + + [Test] + public async Task CompositeFormatsUseCLocaleFieldsAndPadding() + { + var provider = new FakeTimeProvider(new DateTimeOffset(2000, 1, 2, 3, 4, 5, TimeSpan.Zero)); + using var state = CreateState(provider); + var result = await state.DoStringAsync("return os.date('!%c|%x|%X|%r|%C|%%')"); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] { "Sun Jan 2 03:04:05 2000|01/02/00|03:04:05|03:04:05 AM|20|%" } + ) + ); + } + + [TestCase(1, "00")] + [TestCase(999, "09")] + [TestCase(1000, "10")] + public async Task CenturyIsPaddedToTwoDigits(int year, string expected) + { + var provider = new FakeTimeProvider(new DateTimeOffset(year, 1, 1, 0, 0, 0, TimeSpan.Zero)); + using var state = CreateState(provider); + var result = await state.DoStringAsync("return os.date('!%C')"); + Assert.That(result, Is.EqualTo(new LuaValue[] { expected })); + } + + [TestCase(1, "-0800 Test Standard", false, 4)] + [TestCase(7, "-0700 Test Daylight", true, 5)] + public async Task LocalAndUtcDatesUseTheConfiguredZoneForCurrentAndExplicitTimes( + int month, + string expected, + bool daylight, + int hour + ) + { + var zone = CreatePacificZone(TimeSpan.FromHours(1)); + var provider = new FakeTimeProvider( + new DateTimeOffset(2020, month, 15, 12, 0, 0, TimeSpan.Zero) + ); + provider.SetLocalTimeZone(zone); + using var state = CreateState(provider); + state.Environment["timestamp"] = provider.GetUtcNow().ToUnixTimeSeconds(); + var result = await state.DoStringAsync( + """ + local current=os.date('*t') + local explicit=os.date('*t',timestamp) + return os.date('%z %Z'),os.date('%z %Z',timestamp), + current.isdst,explicit.isdst,current.hour,explicit.hour, + os.date('!%z %Z'),os.date('!%z %Z',timestamp),os.date('!*t').isdst, + os.time(current)==timestamp,os.time(explicit)==timestamp + """ + ); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] + { + expected, + expected, + daylight, + daylight, + hour, + hour, + "+0000 UTC", + "+0000 UTC", + false, + true, + true, + } + ) + ); + } + + static TimeZoneInfo CreatePacificZone(TimeSpan daylightDelta) + { + var rule = TimeZoneInfo.AdjustmentRule.CreateAdjustmentRule( + new DateTime(2000, 1, 1), + new DateTime(2099, 12, 31), + daylightDelta, + TimeZoneInfo.TransitionTime.CreateFloatingDateRule( + new DateTime(1, 1, 1, 2, 0, 0), + 3, + 2, + DayOfWeek.Sunday + ), + TimeZoneInfo.TransitionTime.CreateFloatingDateRule( + new DateTime(1, 1, 1, 2, 0, 0), + 11, + 1, + DayOfWeek.Sunday + ) + ); + return TimeZoneInfo.CreateCustomTimeZone( + "Test/Pacific", + TimeSpan.FromHours(-8), + "Test Pacific", + "Test Standard", + "Test Daylight", + [rule] + ); + } + + [TestCase(1, 11, 1, 8)] + [TestCase(-1, 3, 8, 9)] + public async Task RepeatedLocalTimesRoundTripWithTheirDaylightFlag( + int delta, + int month, + int day, + int hour + ) + { + var zone = CreatePacificZone(TimeSpan.FromHours(delta)); + var first = new DateTimeOffset(2020, month, day, hour, 30, 0, TimeSpan.Zero); + var provider = new FakeTimeProvider(first); + provider.SetLocalTimeZone(zone); + using var state = CreateState(provider); + state.Environment["first"] = first.ToUnixTimeSeconds(); + await state.DoStringAsync( + """ + local a=os.date('*t',first) + local b=os.date('*t',first+3600) + local standard=b.isdst and first or first+3600 + assert(a.hour==b.hour and a.min==b.min and a.isdst~=b.isdst) + assert(os.time(a)==first and os.time(b)==first+3600) + assert(os.time(os.date('*t'))==first) + a.isdst=b.isdst + assert(os.time(a)==first+3600) + a.isdst=a.isdst and 0 or false + assert(os.time(a)==first+3600) + a.isdst=nil + assert(os.time(a)==standard) + """ + ); + } + + [TestCase(-1.9, "1969-12-31 23:59:59", 59)] + [TestCase(-0.5, "1970-01-01 00:00:00", 0)] + [TestCase(0.9999999, "1970-01-01 00:00:00", 0)] + [TestCase(1.9, "1970-01-01 00:00:01", 1)] + public async Task ExplicitTimestampsTruncateTowardZero( + double timestamp, + string expected, + int second + ) + { + using var state = CreateState(new FakeTimeProvider()); + state.Environment["timestamp"] = timestamp; + var result = await state.DoStringAsync( + "return os.date('!%Y-%m-%d %H:%M:%S',timestamp),os.date('!*t',timestamp).sec" + ); + Assert.That(result[0], Is.EqualTo(new LuaValue(expected))); + Assert.That(result[1].Read(), Is.EqualTo(second)); + } + + [Test] + public async Task NumericOffsetsIncludeMinutesWithoutAColon() + { + var provider = new FakeTimeProvider(); + provider.SetLocalTimeZone( + TimeZoneInfo.CreateCustomTimeZone( + "Test/Nepal", + TimeSpan.FromMinutes(345), + "Test Nepal", + "Test Nepal" + ) + ); + using var state = CreateState(provider); + var result = await state.DoStringAsync("return os.date('%z')"); + Assert.That(result, Is.EqualTo(new LuaValue[] { "+0545" })); + } + + [Test] + public async Task AlternativeModifiersUseTheSameCLocaleConversions() + { + using var state = CreateState(new FakeTimeProvider()); + await state.DoStringAsync( + """ + for _,set in ipairs({{'E','cCxXyY'},{'O','deHImMSuUVwWy'}}) do + for conversion in set[2]:gmatch('.') do + assert(os.date('!%'..set[1]..conversion)==os.date('!%'..conversion)) + end + end + assert(not pcall(os.date,'!%Ea')) + assert(not pcall(os.date,'!%Oc')) + assert(not pcall(os.date,'!%OY')) + """ + ); + } +} diff --git a/tests/Lua.Tests/DebugLocalMetadataTests.cs b/tests/Lua.Tests/DebugLocalMetadataTests.cs index 297df40e..14799e8f 100644 --- a/tests/Lua.Tests/DebugLocalMetadataTests.cs +++ b/tests/Lua.Tests/DebugLocalMetadataTests.cs @@ -8,6 +8,31 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class DebugLocalMetadataTests(LuaStringMode mode) { + [TestCase("1", false)] + [TestCase(" 1.9 ", false)] + [TestCase("0x1", false)] + [TestCase("-1", false)] + [TestCase("1", true)] + public async Task GetInfoCoercesNumericStringLevels(string index, bool explicitThread) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["index"] = index; + state.Environment["thread"] = state; + var prefix = explicitThread ? "thread," : ""; + await state.DoStringAsync( + $$""" + local a,b=debug.getinfo({{prefix}}index,'flS'),debug.getinfo({{prefix}}tonumber(index),'flS') + if a==nil then assert(b==nil) else + assert(a.func==b.func and a.currentline==b.currentline and a.source==b.source) + end + assert(not pcall(debug.getinfo,'not a level')) + """ + ); + } + [TestCase(false)] [TestCase(true)] public async Task WrappedCoroutineCanInspectItsParentsNativeFrame(bool asynchronous) diff --git a/tests/Lua.Tests/DebugTracebackTests.cs b/tests/Lua.Tests/DebugTracebackTests.cs index 551cb87f..48da4fc2 100644 --- a/tests/Lua.Tests/DebugTracebackTests.cs +++ b/tests/Lua.Tests/DebugTracebackTests.cs @@ -1,4 +1,5 @@ using Lua.Platforms; +using Lua.Runtime; using Lua.Standard; namespace Lua.Tests; @@ -7,6 +8,168 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class DebugTracebackTests(LuaStringMode mode) { + [TestCase(20, 1, 22, 0)] + [TestCase(21, 1, 22, 11)] + [TestCase(100, 0, 23, 12)] + [TestCase(100, 1, 22, 11)] + [TestCase(100, 10, 13, 2)] + [TestCase(100, 11, 12, 1)] + [TestCase(100, 12, 91, 0)] + [TestCase(100, 100, 3, 0)] + [TestCase(100, 103, 0, 0)] + public async Task DeepTracebacksUseLua52AbbreviationAndSkipLevels( + int depth, + int level, + int expectedCount, + int expectedMarker + ) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["depth"] = depth; + state.Environment["level"] = level; + var result = await state.DoStringAsync( + """ + local function recurse(n) + if n==0 then local trace=debug.traceback('message',level);return trace end + local trace=recurse(n-1) + return trace + end + local trace=recurse(depth) + assert(trace:sub(1,25)=='message\nstack traceback:\n' or trace=='message\nstack traceback:') + local count,marker=0,0 + for line in trace:gmatch('[^\n]+') do + if line:sub(1,1)=='\t' then + count=count+1 + if line=='\t...' then marker=count end + end + end + if count>0 then assert(trace:sub(-10)=='main chunk') end + return count,marker + """, + "@deep-traceback.lua" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { expectedCount, expectedMarker })); + } + + [TestCase(false)] + [TestCase(true)] + public async Task DeepCoroutineTracebacksAbbreviateSuspendedAndFailedStacks(bool fail) + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["fail"] = fail; + await state.DoStringAsync( + """ + local function recurse(n) + if n==0 then + if fail then error('failure',0) else coroutine.yield() end + end + local result=recurse(n-1) + return result + end + local co=coroutine.create(function() local result=recurse(100);return result end) + assert(coroutine.resume(co)==not fail) + local trace=debug.traceback(co,'message') + assert(trace:sub(1,25)=='message\nstack traceback:\n') + local count=0 + for line in trace:gmatch('[^\n]+') do if line:sub(1,1)=='\t' then count=count+1 end end + assert(count==23 and trace:find('\n\t...\n',1,true)) + """ + ); + } + + [Test] + public async Task HostTracebacksRetainAllFrames() + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + state.Environment["capture"] = new LuaFunction( + (context, _) => + new(context.Return(Traceback.CreateTracebackMessage(context.State, LuaValue.Nil))) + ); + await state.DoStringAsync( + """ + local function recurse(n) + if n==0 then local trace=capture();return trace end + local trace=recurse(n-1) + return trace + end + local trace=recurse(100) + local count=0 + for line in trace:gmatch('[^\n]+') do if line:sub(1,1)=='\t' then count=count+1 end end + assert(count>100 and not trace:find('\n\t...\n',1,true)) + """ + ); + } + + [TestCase(LuaNumberFormat.DotNet)] + [TestCase(LuaNumberFormat.Lua)] + public async Task TracebackHandlesMessagesAndNegativeLevelsBeforeFormatting( + LuaNumberFormat format + ) + { + var platform = + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default; + using var state = LuaState.Create(platform with { NumberFormat = format }); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local co=coroutine.create(function() end) + for _,object in ipairs({{},false,function() end}) do + assert(debug.traceback(object,'invalid')==object) + assert(debug.traceback(co,object,'invalid')==object) + end + assert(debug.traceback(co,co,'invalid')==co) + local message=tostring(1/3)..'\nstack traceback:' + assert(debug.traceback(1/3,-1)==message) + assert(debug.traceback(co,1/3,-1)==message) + assert(debug.traceback(nil,-1)=='stack traceback:') + assert(debug.traceback(nil,2147483647)=='stack traceback:') + """ + ); + } + + [Test] + public async Task CurrentThreadDefaultsAndExplicitLevelsSkipTheSameFrames() + { + using var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local function leaf() + local co=coroutine.running() + local implicit,explicit=debug.traceback(),debug.traceback(co) + assert(implicit==explicit) + local traces={} + for level=0,5 do + local a,b=debug.traceback(nil,level),debug.traceback(co,nil,level) + assert(a==b) + traces[level]=a + end + assert(traces[0]:find("in function 'debug.traceback'",1,true)) + assert(traces[1]:find("'leaf'",1,true) and not traces[2]:find("'leaf'",1,true)) + assert(traces[2]:find("'parent'",1,true) and not traces[3]:find("'parent'",1,true)) + assert(traces[3]:find("'outer'",1,true) and not traces[4]:find("'outer'",1,true)) + assert(traces[5]=='stack traceback:') + return true + end + local function parent() local result=leaf();return result end + local function outer() local result=parent();return result end + assert(outer()) + """, + "@traceback-levels.lua" + ); + } + [TestCase("failed")] [TestCase("completed")] [TestCase("unstarted")] diff --git a/tests/Lua.Tests/FileSeekTests.cs b/tests/Lua.Tests/FileSeekTests.cs index 14780314..c9e517e2 100644 --- a/tests/Lua.Tests/FileSeekTests.cs +++ b/tests/Lua.Tests/FileSeekTests.cs @@ -5,6 +5,36 @@ namespace Lua.Tests; public class FileSeekTests { + [TestCase("'3'", 3d)] + [TestCase("'3000000000'", 3000000000d)] + [TestCase("'0x10'", 16d)] + public async Task LuaSeekCoercesNumericStrings(string offset, double expected) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var stream = new PositionStream(); + state.Environment["file"] = new LuaValue(new FileHandle(stream)); + var result = await state.DoStringAsync($"return file:seek('set',{offset})"); + Assert.That(result, Is.EqualTo(new LuaValue[] { expected })); + } + + [TestCase("1.5")] + [TestCase("'1.5'")] + [TestCase("0/0")] + [TestCase("math.huge")] + [TestCase("-math.huge")] + [TestCase("9223372036854775808")] + public async Task LuaSeekRejectsFractionalAndUnrepresentableOffsets(string offset) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var stream = new PositionStream(); + state.Environment["file"] = new LuaValue(new FileHandle(stream)); + await state.DoStringAsync( + $"local ok,e=pcall(file.seek,file,'set',{offset});assert(not ok and e:find('integer',1,true))" + ); + } + [Test] public async Task LuaSeekAcceptsOffsetsBeyondInt32() { diff --git a/tests/Lua.Tests/Helpers/NotImplementedExceptionFileSystemBase.cs b/tests/Lua.Tests/Helpers/NotImplementedExceptionFileSystemBase.cs index f3835b7b..107d55fb 100644 --- a/tests/Lua.Tests/Helpers/NotImplementedExceptionFileSystemBase.cs +++ b/tests/Lua.Tests/Helpers/NotImplementedExceptionFileSystemBase.cs @@ -39,7 +39,7 @@ public virtual string GetTempFileName() throw new NotImplementedException(); } - public ValueTask OpenTempFileStream(CancellationToken cancellationToken) + public virtual ValueTask OpenTempFileStream(CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/tests/Lua.Tests/IOTests.cs b/tests/Lua.Tests/IOTests.cs index 436e0298..cf395389 100644 --- a/tests/Lua.Tests/IOTests.cs +++ b/tests/Lua.Tests/IOTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.Text; using Lua.IO; using Lua.Standard; @@ -30,6 +31,36 @@ string GetTestFilePath(string filename) return Path.Combine(testDirectory, filename); } + [TestCase("12", "'*n','*n'")] + [TestCase("word", "'*n'")] + [TestCase("", "'*l'")] + public async Task FilenameLinesReturnsNoValuesAndClosesAtTheFirstFailedRead( + string content, + string formats + ) + { + var path = GetTestFilePath("iterator.txt"); + await File.WriteAllTextAsync(path, content); + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["path"] = path; + state.Environment["hasResult"] = content == "12"; + await state.DoStringAsync( + $$""" + local it=io.lines(path,{{formats}}) + local _,file=debug.getupvalue(it,1) + assert(io.type(file)=='file') + if hasResult then + local values=table.pack(it()) + assert(values.n==2 and values[1]==12 and values[2]==nil) + assert(io.type(file)=='file') + end + assert(select('#',it())==0) + assert(io.type(file)=='closed file') + """ + ); + } + [Test] public async Task TextStream_Write_And_Read_Text() { @@ -413,6 +444,50 @@ public async Task FileSystem_Remove_Works() Assert.That(File.Exists(testFile), Is.False); } + [Test] + public async Task OsRemoveHandlesEmptyDirectoriesAccordingToTheHost() + { + var path = GetTestFilePath("empty"); + Directory.CreateDirectory(path); + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["path"] = path; + var result = await state.DoStringAsync("return os.remove(path)"); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.That(result, Has.Length.EqualTo(3)); + Assert.That(result[0], Is.EqualTo(LuaValue.Nil)); + Assert.That(Directory.Exists(path), Is.True); + } + else + { + Assert.That(result, Is.EqualTo(new LuaValue[] { true })); + Assert.That(Directory.Exists(path), Is.False); + } + } + + [Test] + public async Task OsRemovePreservesNonemptyDirectoriesAndReportsMissingPaths() + { + var path = GetTestFilePath("nonempty"); + Directory.CreateDirectory(path); + var child = Path.Combine(path, "child.txt"); + File.WriteAllText(child, "keep"); + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["path"] = path; + state.Environment["missing"] = GetTestFilePath("missing"); + await state.DoStringAsync( + """ + for _,name in ipairs({path,missing}) do + local values=table.pack(os.remove(name)) + assert(values.n==3 and values[1]==nil and type(values[2])=='string' and type(values[3])=='number') + end + """ + ); + Assert.That(File.ReadAllText(child), Is.EqualTo("keep")); + } + [Test] public void FileSystem_IsReadable_Works() { diff --git a/tests/Lua.Tests/LightUserDataMetatableTests.cs b/tests/Lua.Tests/LightUserDataMetatableTests.cs new file mode 100644 index 00000000..db2de3e5 --- /dev/null +++ b/tests/Lua.Tests/LightUserDataMetatableTests.cs @@ -0,0 +1,60 @@ +using Lua.Standard; + +namespace Lua.Tests; + +public class LightUserDataMetatableTests +{ + [Test] + public async Task MetatableIsSharedByLightUserDataAndCoroutines() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["host"] = LuaValue.FromObject(new object()); + await state.DoStringAsync( + """ + local value=1 + local a=debug.upvalueid(function() return value end,1) + local mt={__index={answer=42},__metatable='protected', + __newindex=function(_,k,v) value=v end, + __add=function() return 12 end,__unm=function() return -12 end, + __len=function() return 7 end,__concat=function() return 'joined' end, + __lt=function() return true end,__le=function() return true end, + __eq=function() error('light userdata equality must use identity') end, + __call=function(_,v) return v end,__tostring=function() return 'token' end} + assert(debug.setmetatable(a,mt)==a) + assert(debug.getmetatable(host)==mt and getmetatable(host)=='protected') + assert(a~=host and a==a) + assert(host.answer==42 and host+1==12 and -host==-12 and #host==7) + assert(host..'x'=='joined' and host<1 and host<=1 and host(9)==9) + assert(tostring(host)=='token') + host.answer=17 + assert(value==17) + local co=coroutine.create(function() + assert(debug.getmetatable(a)==mt) + debug.setmetatable(host,{__index={answer=99}}) + end) + assert(coroutine.resume(co)) + assert(a.answer==99) + debug.setmetatable(a,nil) + assert(debug.getmetatable(host)==nil and getmetatable(a)==nil) + assert(not pcall(function() return host.answer end)) + """ + ); + } + + [Test] + public async Task IndependentStatesHaveIndependentLightUserDataMetatables() + { + var token = LuaValue.FromObject(new object()); + using var first = LuaState.Create(); + using var second = LuaState.Create(); + foreach (var state in new[] { first, second }) + { + state.OpenStandardLibraries(); + state.Environment["token"] = token; + } + await first.DoStringAsync("debug.setmetatable(token,{__index={answer=42}})"); + await second.DoStringAsync("assert(debug.getmetatable(token)==nil)"); + await first.DoStringAsync("assert(token.answer==42)"); + } +} diff --git a/tests/Lua.Tests/ModuleContractTests.cs b/tests/Lua.Tests/ModuleContractTests.cs index 3ccc16fa..e21fe126 100644 --- a/tests/Lua.Tests/ModuleContractTests.cs +++ b/tests/Lua.Tests/ModuleContractTests.cs @@ -9,6 +9,23 @@ namespace Lua.Tests; [TestFixture(LuaStringMode.Bytes)] public class ModuleContractTests(LuaStringMode stringMode) { + [Test] + public async Task PackageConfigContainsFiveNewlineTerminatedMarkers() + { + using var state = CreateState(); + state.Environment["separator"] = Path.DirectorySeparatorChar.ToString(); + await state.DoStringAsync( + """ + assert(package.config==separator..'\n;\n?\n!\n-\n') + local markers={} + for marker in package.config:gmatch('([^\n]*)\n') do + markers[#markers+1]=marker + end + assert(#markers==5 and markers[5]=='-') + """ + ); + } + [TestCase(false)] [TestCase(true)] public async Task StandardLibrariesAreRegisteredForRequire(bool reopen) diff --git a/tests/Lua.Tests/OsExitTests.cs b/tests/Lua.Tests/OsExitTests.cs new file mode 100644 index 00000000..1e69b1b2 --- /dev/null +++ b/tests/Lua.Tests/OsExitTests.cs @@ -0,0 +1,51 @@ +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +public class OsExitTests +{ + [TestCase("", 0)] + [TestCase("nil", 0)] + [TestCase("true", 0)] + [TestCase("false", 1)] + [TestCase("2.9", 2)] + [TestCase("'-2.9'", -2)] + [TestCase("'0x10'", 16)] + public async Task ExitCoercesLuaCodesBeforeCallingTheHost(string argument, int expected) + { + var environment = new ExitEnvironment(); + using var state = LuaState.Create(LuaPlatform.Default with { OsEnvironment = environment }); + state.OpenStandardLibraries(); + await state.DoStringAsync($"pcall(os.exit{(argument.Length == 0 ? "" : "," + argument)})"); + Assert.That(environment.ExitCode, Is.EqualTo(expected)); + } + + [TestCase("{}")] + [TestCase("'invalid'")] + [TestCase("0/0")] + [TestCase("math.huge")] + public async Task InvalidCodesDoNotCallTheHost(string argument) + { + var environment = new ExitEnvironment(); + using var state = LuaState.Create(LuaPlatform.Default with { OsEnvironment = environment }); + state.OpenStandardLibraries(); + await state.DoStringAsync($"assert(not pcall(os.exit,{argument}))"); + Assert.That(environment.ExitCode, Is.Null); + } + + sealed class ExitEnvironment : ILuaOsEnvironment + { + public int? ExitCode { get; private set; } + + public ValueTask Exit(int exitCode, CancellationToken cancellationToken) + { + ExitCode = exitCode; + throw new InvalidOperationException("Exit requested"); + } + + public string? GetEnvironmentVariable(string name) => null; + + public double GetTotalProcessorTime() => 0; + } +} diff --git a/tests/Lua.Tests/StandardInputLoadingTests.cs b/tests/Lua.Tests/StandardInputLoadingTests.cs new file mode 100644 index 00000000..1252b832 --- /dev/null +++ b/tests/Lua.Tests/StandardInputLoadingTests.cs @@ -0,0 +1,151 @@ +using System.Text; +using Lua.IO; +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class StandardInputLoadingTests +{ + sealed class StandardIO(ILuaStream input) : ILuaStandardIO + { + public ILuaStream Input => input; + public ILuaStream Output => input; + public ILuaStream Error => input; + } + + static LuaState CreateState(ILuaStream input, LuaStringMode mode) + { + var state = LuaState.Create(); + state.Platform = state.Platform with + { + StandardIO = new StandardIO(input), + StringMode = mode, + }; + state.OpenBasicLibrary(); + state.OpenDebugLibrary(); + state.OpenStringLibrary(); + return state; + } + + [TestCase("return assert(loadfile())()", LuaStringMode.Utf16)] + [TestCase("return assert(loadfile(nil))()", LuaStringMode.Bytes)] + [TestCase("return dofile()", LuaStringMode.Utf16)] + [TestCase("return dofile(nil)", LuaStringMode.Bytes)] + public async Task OmittedAndNilFilenamesReadStandardInput(string source, LuaStringMode mode) + { + using var bytes = new MemoryStream( + Encoding.UTF8.GetBytes("\uFEFF#!/lua\nreturn 42,nil,'é'") + ); + using var input = new LuaStream(LuaFileOpenMode.Read, bytes, mode); + using var state = CreateState(new StandardIOStream(input), mode); + var result = await state.DoStringAsync(source); + Assert.That( + result, + Is.EqualTo(new LuaValue[] { 42, LuaValue.Nil, state.Platform.EncodeText("é") }) + ); + Assert.That(bytes.CanRead, Is.True); + } + + [TestCase("{answer=42}", "return answer", 42)] + [TestCase("nil", "\uFEFF#!/lua\nreturn _ENV", null)] + public async Task TextProvidersSupportEnvironmentsAndStdinChunkNames( + string environment, + string source, + int? expected + ) + { + using var input = ILuaStream.CreateFromString(source); + using var state = CreateState(input, LuaStringMode.Utf16); + var result = await state.DoStringAsync( + "local f=assert(loadfile(nil,'t'," + + environment + + ")); assert(debug.getinfo(f,'S').source=='=stdin'); return f()" + ); + Assert.That( + result, + Is.EqualTo(new[] { expected.HasValue ? new LuaValue(expected.Value) : LuaValue.Nil }) + ); + Assert.That(input.IsOpen, Is.True); + } + + [TestCase(false, LuaStringMode.Utf16)] + [TestCase(true, LuaStringMode.Bytes)] + public async Task BinaryStandardInputPreservesBytesAndHonorsMode( + bool binaryAllowed, + LuaStringMode mode + ) + { + using var compiler = LuaState.Create(); + var chunk = compiler.Load("return 'binary',42", "@binary").Proto.ToBytecode(); + using var bytes = new MemoryStream(chunk); + using var input = new LuaStream(LuaFileOpenMode.Read, bytes, mode); + using var state = CreateState(new StandardIOStream(input), mode); + var result = await state.DoStringAsync( + binaryAllowed + ? "return assert(loadfile(nil,'b'))()" + : "local f,err=loadfile(nil,'t'); assert(f==nil and err:find('a binary chunk',1,true)); return true" + ); + Assert.That( + result, + Is.EqualTo(binaryAllowed ? new LuaValue[] { "binary", 42 } : new LuaValue[] { true }) + ); + Assert.That(bytes.CanRead, Is.True); + } + + [TestCase("local f,err=loadfile(); assert(f==nil and err:find('stdin',1,true))")] + [TestCase("local ok,err=pcall(dofile); assert(not ok and err:find('stdin',1,true))")] + public async Task SyntaxErrorsPreserveInputOwnership(string source) + { + using var bytes = new MemoryStream(Encoding.UTF8.GetBytes("#!/lua\nlocal =")); + using var input = new LuaStream(LuaFileOpenMode.Read, bytes); + using var state = CreateState(input, LuaStringMode.Utf16); + await state.DoStringAsync(source); + Assert.That(bytes.CanRead, Is.True); + } + + [TestCase("")] + [TestCase("#!/lua")] + public async Task EmptyStandardInputCompilesAnEmptyChunk(string source) + { + using var input = ILuaStream.CreateFromString(source); + using var state = CreateState(input, LuaStringMode.Utf16); + var result = await state.DoStringAsync("return assert(loadfile())()"); + Assert.That(result, Is.Empty); + Assert.That(input.IsOpen, Is.True); + } + + [Test] + public async Task StandardInputIsIndependentOfIoLibrarySelections() + { + using var input = ILuaStream.CreateFromString("return 42"); + using var other = ILuaStream.CreateFromString("return 99"); + using var state = CreateState(input, LuaStringMode.Utf16); + state.OpenIOLibrary(); + state.Environment["other"] = LuaValue.FromUserData( + new FileHandle(other, state.GlobalState.FileHandleMetatable) + ); + var result = await state.DoStringAsync( + """ + io.input(other) + io.stdin=nil + return assert(loadfile())() + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 42 })); + } + + [Test] + public async Task BinaryModeRejectsTextProvidersWithoutClosingThem() + { + using var input = ILuaStream.CreateFromString("return 42"); + using var state = CreateState(input, LuaStringMode.Utf16); + await state.DoStringAsync( + """ + local f,err=loadfile(nil,'b') + assert(f==nil and err:find('a text chunk',1,true)) + """ + ); + Assert.That(input.IsOpen, Is.True); + } +} diff --git a/tests/Lua.Tests/StandardLibraryContractTests.cs b/tests/Lua.Tests/StandardLibraryContractTests.cs index 5aa3414a..59a8f347 100644 --- a/tests/Lua.Tests/StandardLibraryContractTests.cs +++ b/tests/Lua.Tests/StandardLibraryContractTests.cs @@ -18,6 +18,75 @@ LuaState CreateState() return state; } + [TestCase("2.8,1.2", 1)] + [TestCase("-2.8,-1.2", -1)] + [TestCase("-0.5,0.5", 0)] + [TestCase("'2.8','-1.9'", 3)] + [TestCase("2.8", 2)] + [TestCase("2.8,nil", 2)] + public async Task DiffTimeUsesWholeSecondsAndDefaultsItsBaseToZero( + string arguments, + double expected + ) + { + using var state = CreateState(); + var result = await state.DoStringAsync($"return os.difftime({arguments})"); + Assert.That(result, Is.EqualTo(new LuaValue[] { expected })); + if (expected == 0) + Assert.That(1 / result[0].Read(), Is.EqualTo(double.PositiveInfinity)); + } + + [Test] + public async Task SetLocaleQueriesAndValidatesTheFixedCLocale() + { + using var state = CreateState(); + var culture = System.Globalization.CultureInfo.CurrentCulture; + await state.DoStringAsync( + """ + assert(os.setlocale()=='C' and os.setlocale(nil)=='C') + assert(os.setlocale('C',nil)=='C') + for _,category in ipairs({'all','collate','ctype','monetary','numeric','time'}) do + assert(os.setlocale(nil,category)=='C') + assert(os.setlocale('C',category)=='C') + assert(os.setlocale('unsupported locale',category)==nil) + assert(os.setlocale(nil,category)=='C') + end + local ok,e=pcall(os.setlocale,nil,'invalid') + assert(not ok and e:find('invalid option',1,true)) + assert(not pcall(os.setlocale,'unsupported locale','invalid')) + assert(not pcall(os.setlocale,false)) + """ + ); + Assert.That(System.Globalization.CultureInfo.CurrentCulture, Is.SameAs(culture)); + } + + [Test] + public async Task RemovePermissionFailuresReturnTheIoErrorTuple() + { + var error = new UnauthorizedAccessException("denied ế"); + using var state = CreateState(); + state.Platform = state.Platform with { FileSystem = new DeniedRemoveFileSystem(error) }; + var result = await state.DoStringAsync("return pcall(os.remove,'file')"); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] + { + true, + LuaValue.Nil, + state.Platform.EncodeText(error.Message), + error.HResult, + } + ) + ); + } + + sealed class DeniedRemoveFileSystem(Exception error) : NotImplementedExceptionFileSystemBase + { + public override ValueTask Remove(string path, CancellationToken cancellationToken) => + throw error; + } + [Test] public async Task RawSetReturnsTheOriginalTable() { @@ -31,6 +100,94 @@ await state.DoStringAsync( ); } + [TestCase(-1)] + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(10000)] + public async Task ErrorCoercesNumbersOnlyAtPositiveLevels(int level) + { + using var state = CreateState(); + state.Environment["level"] = level; + await state.DoStringAsync( + """ + local function fail() error(42,level) end + local function check(value) + if level>0 then + assert(type(value)=='string' and value:sub(-2)=='42') + else + assert(type(value)=='number' and value==42) + end + return value + end + local ok,value=pcall(fail) + assert(not ok) + check(value) + ok,value=xpcall(fail,check) + assert(not ok) + check(value) + local object={} + ok,value=pcall(error,object,level) + assert(not ok and value==object) + """ + ); + } + + [Test] + public async Task IPairsIteratorTruncatesItsControlAndReturnsOneNilAtExhaustion() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local it,t=ipairs({'first','second'}) + local key,value=it(t,'0.9') + assert(key==1 and value=='first') + assert(select('#',it(t,1))==2) + assert(select('#',it(t,2))==1 and it(t,2)==nil) + """ + ); + } + + [TestCase("file:lines()")] + [TestCase("file:lines('*L')")] + [TestCase("file:lines(100)")] + [TestCase("io.lines()")] + [TestCase("io.lines(nil, '*n', '*l')")] + [TestCase("file:lines('*n', '*l')")] + public async Task LineIteratorsReturnNoValuesWhenTheFirstReadFails(string expression) + { + using var state = CreateState(); + using var stream = ILuaStream.CreateFromString("12 word\n"); + state.Environment["file"] = new LuaValue(new FileHandle(stream)); + await state.DoStringAsync( + $$""" + io.input(file) + local it={{expression}} + assert(it()~=nil) + assert(select('#',it())==0) + assert(select('#',it())==0) + assert(io.type(file)=='file') + assert(select('#',file:read())==1 and file:read()==nil) + """ + ); + } + + [Test] + public async Task LineIteratorsKeepPartialResultsWhenALaterReadFails() + { + using var state = CreateState(); + using var stream = ILuaStream.CreateFromString("12"); + state.Environment["file"] = new LuaValue(new FileHandle(stream)); + await state.DoStringAsync( + """ + local it=file:lines('*n','*n') + local values=table.pack(it()) + assert(values.n==2 and values[1]==12 and values[2]==nil) + assert(select('#',it())==0) + """ + ); + } + [Test] public async Task AssertCoercesNumericMessagesButRejectsTables() { diff --git a/tests/Lua.Tests/TimeTableTests.cs b/tests/Lua.Tests/TimeTableTests.cs new file mode 100644 index 00000000..e0603c09 --- /dev/null +++ b/tests/Lua.Tests/TimeTableTests.cs @@ -0,0 +1,133 @@ +using Lua.Platforms; +using Lua.Standard; +using Microsoft.Extensions.Time.Testing; + +namespace Lua.Tests; + +public class TimeTableTests +{ + static LuaState CreateState() + { + var provider = new FakeTimeProvider(); + provider.SetLocalTimeZone(TimeZoneInfo.Utc); + var state = LuaState.Create(LuaPlatform.Default with { TimeProvider = provider }); + state.OpenStandardLibraries(); + return state; + } + + [TestCase("year=2000,month=3,day=0", "2000-02-29 12:00:00")] + [TestCase("year=2000,month=13,day=1", "2001-01-01 12:00:00")] + [TestCase("year=2000,month=-12,day=1", "1998-12-01 12:00:00")] + [TestCase("year=2000,month=2,day=30,hour=24,min=-1,sec=61", "2000-03-02 00:00:01")] + [TestCase("year='2000.9',month='1.9',day=2.9,hour=3.9,min=4.9,sec=5.9", "2000-01-02 03:04:05")] + [TestCase("year=2000,month=1,day=2,hour=-0.9,min=-1.9,sec=-1.9", "2000-01-01 23:58:59")] + [TestCase("year=2000,month=1,day=2,hour=false,min={},sec='invalid'", "2000-01-02 12:00:00")] + public async Task FieldsAreConvertedAndNormalizedWithoutUpdatingTheTable( + string fields, + string expected + ) + { + using var state = CreateState(); + var result = await state.DoStringAsync( + "local t={" + + fields + + "}; local copy={}; for k,v in pairs(t) do copy[k]=v end; " + + "local timestamp=os.time(t); for k,v in pairs(t) do assert(copy[k]==v) end; " + + "for k,v in pairs(copy) do assert(t[k]==v) end; return os.date('!%Y-%m-%d %H:%M:%S',timestamp)" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { expected })); + } + + [TestCase("day")] + [TestCase("month")] + [TestCase("year")] + public async Task InvalidRequiredFieldsRaiseLuaErrors(string field) + { + using var state = CreateState(); + state.Environment["field"] = field; + await state.DoStringAsync( + """ + local t={year=2000,month=1,day=1} + for _,v in ipairs({false,{},'invalid'}) do + t[field]=v + local ok,err=pcall(os.time,t) + assert(not ok and err:find("field '"..field.."' missing in date table",1,true)) + end + t[field]=nil + assert(not pcall(os.time,t)) + """ + ); + } + + [Test] + public async Task FieldsUseIndexMetamethodsInLua52Order() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local fields={sec=5,min=4,hour=3,day=2,month=1,year=2000,isdst=false} + local order={} + local t=setmetatable({}, {__index=function(_,key) + order[#order+1]=key + return fields[key] + end}) + assert(os.date('!%Y-%m-%d %H:%M:%S',os.time(t))=='2000-01-02 03:04:05') + assert(table.concat(order,',')=='sec,min,hour,day,month,year,isdst') + assert(next(t)==nil) + assert(os.time(setmetatable({}, {__index=fields}))==os.time(fields)) + local marker={} + local ok,err=pcall(os.time,setmetatable({}, {__index=function() error(marker) end})) + assert(not ok and err==marker) + local co=coroutine.create(function() + local ok=pcall(os.time,setmetatable({}, {__index=coroutine.yield})) + assert(not ok) + coroutine.yield('restored') + end) + local ok,value=coroutine.resume(co) + assert(ok and value=='restored') + """ + ); + } + + [Test] + public async Task HostIndexCallbacksCanAwait() + { + using var state = CreateState(); + state.Environment["read"] = new LuaFunction( + "read", + async (context, _) => + { + var key = context.GetArgument(1); + await Task.Yield(); + return context.Return( + key switch + { + "year" => 2000, + "month" => 1, + "day" => 2, + _ => LuaValue.Nil, + } + ); + } + ); + await state.DoStringAsync( + """ + local t=setmetatable({}, {__index=read}) + assert(os.time(t)==946814400) + """ + ); + } + + [Test] + public async Task UnrepresentableDatesReturnNil() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + assert(os.time{year=10000,month=1,day=1}==nil) + assert(os.time{year=2000,month=1,day=2147483647}==nil) + assert(os.time{year=1969,month=12,day=31,hour=23,min=59,sec=59}==nil) + """ + ); + } +} diff --git a/tests/Lua.Tests/TmpFileTests.cs b/tests/Lua.Tests/TmpFileTests.cs new file mode 100644 index 00000000..86f4f1c9 --- /dev/null +++ b/tests/Lua.Tests/TmpFileTests.cs @@ -0,0 +1,88 @@ +using Lua.IO; +using Lua.Platforms; +using Lua.Standard; +using Lua.Tests.Helpers; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class TmpFileTests(LuaStringMode mode) +{ + LuaState CreateState(ILuaFileSystem? fileSystem = null) + { + var platform = + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default; + if (fileSystem is not null) + platform = platform with { FileSystem = fileSystem }; + var state = LuaState.Create(platform); + state.OpenStandardLibraries(); + return state; + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task CreationFailuresReturnTheIoErrorTuple(bool denied, bool asynchronous) + { + Exception error = denied + ? new UnauthorizedAccessException("denied ế") + : new IOException("failed ế"); + using var state = CreateState(new FailingFileSystem(error, asynchronous)); + var result = await state.DoStringAsync("return pcall(io.tmpfile)"); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] + { + true, + LuaValue.Nil, + state.Platform.EncodeText(error.Message), + error.HResult, + } + ) + ); + } + + [TestCase(false)] + [TestCase(true)] + public void CreationCancellationStillEscapesPcall(bool asynchronous) + { + using var state = CreateState( + new FailingFileSystem(new OperationCanceledException(), asynchronous) + ); + Assert.ThrowsAsync(async () => + await state.DoStringAsync("return pcall(io.tmpfile)") + ); + } + + [Test] + public async Task SuccessfulCreationReturnsOneUsableFile() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local values=table.pack(io.tmpfile()) + assert(values.n==1 and io.type(values[1])=='file') + local file=values[1] + assert(file:write('temporary')==file) + assert(file:seek('set',0)==0 and file:read('*a')=='temporary') + assert(file:close() and io.type(file)=='closed file') + """ + ); + } + + sealed class FailingFileSystem(Exception error, bool asynchronous) + : NotImplementedExceptionFileSystemBase + { + public override async ValueTask OpenTempFileStream( + CancellationToken cancellationToken + ) + { + if (asynchronous) + await Task.Yield(); + throw error; + } + } +}