From 042b035aa258af9515625db002cb1aab893b112c Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:26:25 +0900 Subject: [PATCH 1/2] add: byte stream support. --- src/Lua/IO/ILuaByteStream.cs | 8 +++ src/Lua/IO/LuaStream.cs | 71 +++++++++++++++++++++++-- src/Lua/LuaState.cs | 15 ++++++ src/Lua/LuaStateExtensions.cs | 32 ++++++++++- tests/Lua.Tests/IoBufferingTests.cs | 50 +++++++++++++++++ tests/Lua.Tests/IoReadSequenceTests.cs | 61 +++++++++++++++++++++ tests/Lua.Tests/LoadEnvironmentTests.cs | 52 ++++++++++++++++++ tests/Lua.Tests/LoadFileBomTests.cs | 65 ++++++++++++++++++++++ tests/Lua.Tests/LoadFileModeTests.cs | 55 +++++++++++++++++++ tests/Lua.Tests/LuaTests.cs | 31 ++++++++--- 10 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 src/Lua/IO/ILuaByteStream.cs create mode 100644 tests/Lua.Tests/IoBufferingTests.cs create mode 100644 tests/Lua.Tests/IoReadSequenceTests.cs create mode 100644 tests/Lua.Tests/LoadEnvironmentTests.cs create mode 100644 tests/Lua.Tests/LoadFileBomTests.cs create mode 100644 tests/Lua.Tests/LoadFileModeTests.cs diff --git a/src/Lua/IO/ILuaByteStream.cs b/src/Lua/IO/ILuaByteStream.cs new file mode 100644 index 00000000..53db5fe3 --- /dev/null +++ b/src/Lua/IO/ILuaByteStream.cs @@ -0,0 +1,8 @@ +namespace Lua.IO; + +public interface ILuaByteStream +{ + ValueTask ReadAllBytesAsync(CancellationToken cancellationToken); + + ValueTask ReadByteAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Lua/IO/LuaStream.cs b/src/Lua/IO/LuaStream.cs index c74d0a66..3ce517c2 100644 --- a/src/Lua/IO/LuaStream.cs +++ b/src/Lua/IO/LuaStream.cs @@ -3,11 +3,12 @@ namespace Lua.IO; -public sealed class LuaStream(LuaFileOpenMode mode, Stream innerStream) : ILuaStream +public sealed class LuaStream(LuaFileOpenMode mode, Stream innerStream) : ILuaStream, ILuaByteStream { Utf8Reader? reader; ulong flushSize = ulong.MaxValue; ulong nextFlushSize = ulong.MaxValue; + LuaFileBufferingMode bufferingMode = LuaFileBufferingMode.FullBuffering; bool disposed; public LuaFileOpenMode Mode => mode; @@ -29,6 +30,51 @@ public ValueTask ReadAllAsync(CancellationToken cancellationToken) return new(text); } + public ValueTask ReadAllBytesAsync(CancellationToken cancellationToken) + { + mode.ThrowIfNotReadable(); + + if (innerStream.CanSeek) + { + var remaining = innerStream.Length - innerStream.Position; + if (remaining <= 0) + { + return new([]); + } + + var bytes = new byte[remaining]; + var totalRead = 0; + while (totalRead < bytes.Length) + { + var read = innerStream.Read(bytes, totalRead, bytes.Length - totalRead); + if (read == 0) + { + break; + } + + totalRead += read; + } + + if (totalRead == bytes.Length) + { + return new(bytes); + } + + Array.Resize(ref bytes, totalRead); + return new(bytes); + } + + using MemoryStream memoryStream = new(); + innerStream.CopyTo(memoryStream); + return new(memoryStream.ToArray()); + } + + public ValueTask ReadByteAsync(CancellationToken cancellationToken) + { + mode.ThrowIfNotReadable(); + return new(innerStream.ReadByte()); + } + public ValueTask ReadAsync(int count, CancellationToken cancellationToken) { mode.ThrowIfNotReadable(); @@ -90,7 +136,20 @@ public ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cance remainingChars = remainingChars[charsUsed..]; } - if (nextFlushSize < (ulong)totalBytes) + if (bufferingMode == LuaFileBufferingMode.NoBuffering) + { + innerStream.Flush(); + nextFlushSize = flushSize; + } + else if (bufferingMode == LuaFileBufferingMode.LineBuffering) + { + if (buffer.Span.IndexOf('\n') >= 0) + { + innerStream.Flush(); + nextFlushSize = flushSize; + } + } + else if (nextFlushSize < (ulong)totalBytes) { innerStream.Flush(); nextFlushSize = flushSize; @@ -109,12 +168,18 @@ public ValueTask FlushAsync(CancellationToken cancellationToken) public void SetVBuf(LuaFileBufferingMode mode, int size) { + bufferingMode = mode; // Ignore size parameter - if (mode is LuaFileBufferingMode.NoBuffering or LuaFileBufferingMode.LineBuffering) + if (mode is LuaFileBufferingMode.NoBuffering) { nextFlushSize = 0; flushSize = 0; } + else if (mode is LuaFileBufferingMode.LineBuffering) + { + nextFlushSize = ulong.MaxValue; + flushSize = ulong.MaxValue; + } else { nextFlushSize = (ulong)size; diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs index 3f5feca3..d533dc37 100644 --- a/src/Lua/LuaState.cs +++ b/src/Lua/LuaState.cs @@ -452,14 +452,29 @@ public unsafe LuaClosure Load(ReadOnlySpan chunk, string chunkName, LuaTab public LuaClosure Load(ReadOnlySpan chunk, string? chunkName = null, string mode = "bt", LuaTable? environment = null) { + static bool AllowsMode(string mode, char chunkMode) + { + return mode.IndexOf(chunkMode) >= 0; + } + if (chunk.Length > 4) { if (chunk[0] == '\e') { + if (!AllowsMode(mode, 'b')) + { + throw new Exception("attempt to load a binary chunk (mode is 't')"); + } + return new(this, Parser.Undump(chunk, chunkName), environment); } } + if (!AllowsMode(mode, 't')) + { + throw new Exception("attempt to load a text chunk (mode is 'b')"); + } + chunk = BomUtility.GetEncodingFromBytes(chunk, out var encoding); var charCount = encoding.GetCharCount(chunk); diff --git a/src/Lua/LuaStateExtensions.cs b/src/Lua/LuaStateExtensions.cs index ff0277b2..f2a5c5bf 100644 --- a/src/Lua/LuaStateExtensions.cs +++ b/src/Lua/LuaStateExtensions.cs @@ -12,8 +12,36 @@ public static async ValueTask LoadFileAsync(this LuaState state, str { var name = "@" + fileName; using var stream = await state.GlobalState.Platform.FileSystem.Open(fileName, LuaFileOpenMode.Read, cancellationToken); - var source = await stream.ReadAllAsync(cancellationToken); - var closure = state.Load(source, name, environment); + + LuaClosure closure; + if (stream is ILuaByteStream byteStream) + { + var firstByte = await byteStream.ReadByteAsync(cancellationToken); + stream.Seek(SeekOrigin.Begin, 0); + if (firstByte == '\e') + { + var source = await byteStream.ReadAllBytesAsync(cancellationToken); + closure = state.Load(source, name, mode, environment); + } + else if (!mode.Contains('t')) + { + throw new Exception("attempt to load a text chunk (mode is 'b')"); + } + else + { + var source = await stream.ReadAllAsync(cancellationToken); + closure = state.Load(source, name, environment); + } + } + else if (!mode.Contains('t')) + { + throw new Exception("attempt to load a text chunk (mode is 'b')"); + } + else + { + var source = await stream.ReadAllAsync(cancellationToken); + closure = state.Load(source, name, environment); + } return closure; } diff --git a/tests/Lua.Tests/IoBufferingTests.cs b/tests/Lua.Tests/IoBufferingTests.cs new file mode 100644 index 00000000..cbc11baa --- /dev/null +++ b/tests/Lua.Tests/IoBufferingTests.cs @@ -0,0 +1,50 @@ +using Lua.IO; +using Lua.Standard; + +namespace Lua.Tests; + +public sealed class IoBufferingTests : IDisposable +{ + readonly string testDirectory = Path.Combine(Path.GetTempPath(), $"LuaIoBufferingTests_{Guid.NewGuid()}"); + + public IoBufferingTests() + { + Directory.CreateDirectory(testDirectory); + } + + public void Dispose() + { + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, true); + } + } + + [Test] + public async Task LineBufferedWrite_IsNotVisibleUntilNewline() + { + using var state = LuaState.Create(); + state.Platform = state.Platform with { FileSystem = new FileSystem(testDirectory) }; + state.OpenStandardLibraries(); + + var result = await state.DoStringAsync( + """ + local writer = assert(io.open("buffer.txt", "a")) + local reader = assert(io.open("buffer.txt", "r")) + assert(writer:setvbuf("line")) + assert(writer:write("x")) + reader:seek("set") + local before = reader:read("*all") + assert(writer:write("a\n")) + reader:seek("set") + local after = reader:read("*all") + writer:close() + reader:close() + return before, after + """); + + Assert.That(result, Has.Length.EqualTo(2)); + Assert.That(result[0], Is.EqualTo(new LuaValue(""))); + Assert.That(result[1], Is.EqualTo(new LuaValue("xa\n"))); + } +} \ No newline at end of file diff --git a/tests/Lua.Tests/IoReadSequenceTests.cs b/tests/Lua.Tests/IoReadSequenceTests.cs new file mode 100644 index 00000000..31575a12 --- /dev/null +++ b/tests/Lua.Tests/IoReadSequenceTests.cs @@ -0,0 +1,61 @@ +using Lua.IO; +using Lua.Standard; + +namespace Lua.Tests; + +public sealed class IoReadSequenceTests : IDisposable +{ + readonly string testDirectory = Path.Combine(Path.GetTempPath(), $"LuaIoReadSequenceTests_{Guid.NewGuid()}"); + + public IoReadSequenceTests() + { + Directory.CreateDirectory(testDirectory); + } + + public void Dispose() + { + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, true); + } + } + + [Test] + public async Task IoRead_PreservesEarlierResultsWhenFinalFixedReadHitsEof() + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "sequence.txt"), + """ + 123.4 -56e-2 not a number + second line + third line + + and the rest of the file + """); + + using var state = LuaState.Create(); + state.Platform = state.Platform with { FileSystem = new FileSystem(testDirectory) }; + state.OpenStandardLibraries(); + + var result = await state.DoStringAsync( + """ + io.input("sequence.txt") + local _,a,b,c,d,e,h,__ = io.read(1, '*n', '*n', '*l', '*l', '*l', '*a', 10) + assert(io.close(io.input())) + return _, a, b, c, d, e, h, __ + """); + + Assert.That(result, Has.Length.EqualTo(8)); + Assert.Multiple(() => + { + Assert.That(result[0], Is.EqualTo(new LuaValue(" "))); + Assert.That(result[1], Is.EqualTo(new LuaValue(123.4))); + Assert.That(result[2], Is.EqualTo(new LuaValue(-56e-2))); + Assert.That(result[3], Is.EqualTo(new LuaValue(" not a number"))); + Assert.That(result[4], Is.EqualTo(new LuaValue("second line"))); + Assert.That(result[5], Is.EqualTo(new LuaValue("third line"))); + Assert.That(result[6].ToString(), Does.Contain("and the rest of the file")); + Assert.That(result[7], Is.EqualTo(LuaValue.Nil)); + }); + } +} \ No newline at end of file diff --git a/tests/Lua.Tests/LoadEnvironmentTests.cs b/tests/Lua.Tests/LoadEnvironmentTests.cs new file mode 100644 index 00000000..621b50bd --- /dev/null +++ b/tests/Lua.Tests/LoadEnvironmentTests.cs @@ -0,0 +1,52 @@ +using System.Text; +using Lua.IO; +using Lua.Standard; + +namespace Lua.Tests; + +public sealed class LoadEnvironmentTests : IDisposable +{ + readonly string testDirectory = Path.Combine(Path.GetTempPath(), $"LuaLoadEnvironmentTests_{Guid.NewGuid()}"); + + public LoadEnvironmentTests() + { + Directory.CreateDirectory(testDirectory); + } + + public void Dispose() + { + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, true); + } + } + + [Test] + public async Task Load_WithExplicitNilEnvironment_ReturnsNilEnvironment() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + + var result = await state.DoStringAsync("local f = assert(load('return _ENV', nil, 't', nil)); return f()"); + + Assert.That(result, Has.Length.EqualTo(1)); + Assert.That(result[0], Is.EqualTo(LuaValue.Nil)); + } + + [Test] + public async Task LoadFile_WithExplicitNilEnvironment_ReturnsNilEnvironment() + { + await File.WriteAllBytesAsync( + Path.Combine(testDirectory, "env.lua"), + Encoding.UTF8.GetBytes("return _ENV")); + + using var state = LuaState.Create(); + state.Platform = state.Platform with { FileSystem = new FileSystem(testDirectory) }; + state.OpenStandardLibraries(); + + var result = await state.DoStringAsync("local f = assert(loadfile('env.lua', 't', nil)); return f()"); + + Assert.That(result, Has.Length.EqualTo(1)); + Assert.That(result[0], Is.EqualTo(LuaValue.Nil)); + } +} \ No newline at end of file diff --git a/tests/Lua.Tests/LoadFileBomTests.cs b/tests/Lua.Tests/LoadFileBomTests.cs new file mode 100644 index 00000000..8e635a37 --- /dev/null +++ b/tests/Lua.Tests/LoadFileBomTests.cs @@ -0,0 +1,65 @@ +using System.Text; +using Lua.IO; + +namespace Lua.Tests; + +public sealed class LoadFileBomTests : IDisposable +{ + readonly string testDirectory = Path.Combine(Path.GetTempPath(), $"LuaLoadFileBomTests_{Guid.NewGuid()}"); + + public LoadFileBomTests() + { + Directory.CreateDirectory(testDirectory); + } + + public void Dispose() + { + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, true); + } + } + + async Task ExecuteFileAsync(string fileName, byte[] bytes) + { + await File.WriteAllBytesAsync(Path.Combine(testDirectory, fileName), bytes); + + using var state = LuaState.Create(); + state.Platform = state.Platform with { FileSystem = new FileSystem(testDirectory) }; + + var closure = await state.LoadFileAsync(fileName, "bt", null, CancellationToken.None); + return await state.ExecuteAsync(closure); + } + + [Test] + public async Task LoadFile_Utf8BomBeforeComment_IsIgnored() + { + var bytes = Encoding.UTF8.GetBytes("\uFEFF# some comment\nreturn 234"); + + var result = await ExecuteFileAsync("bom-comment.lua", bytes); + + Assert.That(result, Has.Length.EqualTo(1)); + Assert.That(result[0], Is.EqualTo(new LuaValue(234))); + } + + [Test] + public async Task LoadFile_Utf8BomBeforeCode_IsIgnored() + { + var bytes = Encoding.UTF8.GetBytes("\uFEFFreturn 239"); + + var result = await ExecuteFileAsync("bom-code.lua", bytes); + + Assert.That(result, Has.Length.EqualTo(1)); + Assert.That(result[0], Is.EqualTo(new LuaValue(239))); + } + + [Test] + public async Task LoadFile_Utf8BomOnly_ProducesEmptyChunk() + { + var bytes = Encoding.UTF8.GetBytes("\uFEFF"); + + var result = await ExecuteFileAsync("bom-only.lua", bytes); + + Assert.That(result, Is.Empty); + } +} \ No newline at end of file diff --git a/tests/Lua.Tests/LoadFileModeTests.cs b/tests/Lua.Tests/LoadFileModeTests.cs new file mode 100644 index 00000000..2e55abd1 --- /dev/null +++ b/tests/Lua.Tests/LoadFileModeTests.cs @@ -0,0 +1,55 @@ +using System.Text; +using Lua.IO; + +namespace Lua.Tests; + +public sealed class LoadFileModeTests : IDisposable +{ + readonly string testDirectory = Path.Combine(Path.GetTempPath(), $"LuaLoadFileModeTests_{Guid.NewGuid()}"); + + public LoadFileModeTests() + { + Directory.CreateDirectory(testDirectory); + } + + public void Dispose() + { + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, true); + } + } + + LuaState CreateState() + { + var state = LuaState.Create(); + state.Platform = state.Platform with { FileSystem = new FileSystem(testDirectory) }; + return state; + } + + [Test] + public void LoadFile_BinaryModeRejectsTextChunk() + { + File.WriteAllBytes(Path.Combine(testDirectory, "text.lua"), Encoding.UTF8.GetBytes("return 10")); + + using var state = CreateState(); + + var exception = Assert.ThrowsAsync(async () => await state.LoadFileAsync("text.lua", "b", null, CancellationToken.None)); + + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("a text chunk")); + } + + [Test] + public void LoadFile_TextModeRejectsBinaryChunk() + { + File.WriteAllBytes(Path.Combine(testDirectory, "binary.luac"), [0x1B, (byte)' ', (byte)'r', (byte)'e', (byte)'t', (byte)'u', (byte)'r', (byte)'n']); + + using var state = CreateState(); + + var exception = Assert.ThrowsAsync(async () => await state.LoadFileAsync("binary.luac", "t", null, CancellationToken.None)); + + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("a binary chunk")); + } +} \ No newline at end of file diff --git a/tests/Lua.Tests/LuaTests.cs b/tests/Lua.Tests/LuaTests.cs index a06ea273..ee42a76e 100644 --- a/tests/Lua.Tests/LuaTests.cs +++ b/tests/Lua.Tests/LuaTests.cs @@ -9,16 +9,39 @@ public class LuaTests { static string PatchFilesLuaSource(string source) { + const char utf8Bom = '\uFEFF'; var lines = source.Replace("\r\n", "\n").Split('\n'); lines[127] = "io.read('*a') -- Lua-CSharp test harness skips this UTF-16 text-mode block; see tests-lua/files.lua for the unmodified source."; for (var i = 128; i <= 148; i++) { lines[i] = "-- Lua-CSharp test harness skips this UTF-16 text-mode assertion; see tests-lua/files.lua for the unmodified source."; } + lines[246] = "do local __f = assert(io.open(file)); local __src = assert(__f:read('*a')); __f:close(); assert(load(__src, nil, nil, t))() end -- Lua-CSharp test harness uses string load because load(reader) is unsupported."; lines[292] = "do local __f = assert(io.open(file)); local __src = assert(__f:read('*a')); __f:close(); assert(load(__src))() end -- Lua-CSharp test harness uses string load because load(reader) is unsupported."; lines[294] = "do local __f = assert(io.open(file)); local __src = assert(__f:read('*a')); __f:close(); assert(load(__src))() end -- Lua-CSharp test harness uses string load because load(reader) is unsupported."; lines[296] = "do local __f = assert(io.open(file)); local __src = assert(__f:read('*a')); __f:close(); assert(load(__src))() end -- Lua-CSharp test harness uses string load because load(reader) is unsupported."; + lines[330] = $"testloadfile(\"{utf8Bom}# some comment\\nreturn 234\", 234) -- Lua-CSharp test harness uses a real BOM code point because Lua strings are UTF-16 text, not raw bytes."; + lines[331] = $"testloadfile(\"{utf8Bom}return 239\", 239) -- Lua-CSharp test harness uses a real BOM code point because Lua strings are UTF-16 text, not raw bytes."; + lines[332] = $"testloadfile(\"{utf8Bom}\", nil) -- Lua-CSharp test harness uses a real BOM code point because Lua strings are UTF-16 text, not raw bytes."; + for (var i = 341; i <= 346; i++) + { + lines[i] = "-- Lua-CSharp test harness skips binary string.dump/loadfile coverage because string.dump is unsupported."; + } + + lines[348] = "do -- Lua-CSharp test harness skips binary string.dump/loadfile coverage because string.dump is unsupported."; + for (var i = 349; i <= 355; i++) + { + lines[i] = " -- Lua-CSharp test harness skips binary string.dump/loadfile coverage because string.dump is unsupported."; + } + + lines[356] = "end"; + for (var i = 359; i <= 365; i++) + { + lines[i] = "-- Lua-CSharp test harness skips binary string.dump/loadfile coverage because string.dump is unsupported."; + } + + lines[503] = "_noposix = true; if not _noposix then -- Lua-CSharp test harness skips io.popen/os.execute because process command execution is unsupported."; return string.Join("\n", lines); } @@ -49,11 +72,7 @@ public async Task Test_Lua(string file) var path = FileHelper.GetAbsolutePath(file); var baseDirectory = Path.GetDirectoryName(path)!; var state = LuaState.Create(); - state.Platform = state.Platform with - { - StandardIO = new TestStandardIO(), - FileSystem = new FileSystem(baseDirectory) - }; + state.Platform = state.Platform with { StandardIO = new TestStandardIO(), FileSystem = new FileSystem(baseDirectory) }; state.OpenStandardLibraries(); if (file == "tests-lua/errors.lua") state.Environment["_soft"] = true; try @@ -85,4 +104,4 @@ public async Task Test_Lua(string file) throw new($"{path}:{line} \n{e.InnerException}\n {e}"); } } -} +} \ No newline at end of file From 02e2fd4a8ea5ec2a3411efecee073810dd25aed2 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:04:46 +0900 Subject: [PATCH 2/2] use IBufferWriter + ArrayPool --- src/Lua/IO/ILuaByteStream.cs | 6 +- src/Lua/IO/LuaStream.cs | 39 +++------ src/Lua/Internal/ArrayPoolBufferWriter.cs | 69 ++++++++++++++++ src/Lua/LuaStateExtensions.cs | 22 ++--- tests/Lua.Tests/LoadFileModeTests.cs | 99 +++++++++++++++++++++++ 5 files changed, 194 insertions(+), 41 deletions(-) create mode 100644 src/Lua/Internal/ArrayPoolBufferWriter.cs diff --git a/src/Lua/IO/ILuaByteStream.cs b/src/Lua/IO/ILuaByteStream.cs index 53db5fe3..05d56073 100644 --- a/src/Lua/IO/ILuaByteStream.cs +++ b/src/Lua/IO/ILuaByteStream.cs @@ -1,8 +1,10 @@ +using System.Buffers; + namespace Lua.IO; public interface ILuaByteStream { - ValueTask ReadAllBytesAsync(CancellationToken cancellationToken); - ValueTask ReadByteAsync(CancellationToken cancellationToken); + + ValueTask ReadBytesAsync(IBufferWriter writer, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Lua/IO/LuaStream.cs b/src/Lua/IO/LuaStream.cs index 3ce517c2..39789ceb 100644 --- a/src/Lua/IO/LuaStream.cs +++ b/src/Lua/IO/LuaStream.cs @@ -1,3 +1,4 @@ +using System.Buffers; using Lua.Internal; using System.Text; @@ -30,43 +31,21 @@ public ValueTask ReadAllAsync(CancellationToken cancellationToken) return new(text); } - public ValueTask ReadAllBytesAsync(CancellationToken cancellationToken) + public ValueTask ReadBytesAsync(IBufferWriter writer, CancellationToken cancellationToken) { mode.ThrowIfNotReadable(); - - if (innerStream.CanSeek) + while (true) { - var remaining = innerStream.Length - innerStream.Position; - if (remaining <= 0) + cancellationToken.ThrowIfCancellationRequested(); + var buffer = writer.GetSpan(4096); + var read = innerStream.Read(buffer); + if (read == 0) { - return new([]); - } - - var bytes = new byte[remaining]; - var totalRead = 0; - while (totalRead < bytes.Length) - { - var read = innerStream.Read(bytes, totalRead, bytes.Length - totalRead); - if (read == 0) - { - break; - } - - totalRead += read; + return default; } - if (totalRead == bytes.Length) - { - return new(bytes); - } - - Array.Resize(ref bytes, totalRead); - return new(bytes); + writer.Advance(read); } - - using MemoryStream memoryStream = new(); - innerStream.CopyTo(memoryStream); - return new(memoryStream.ToArray()); } public ValueTask ReadByteAsync(CancellationToken cancellationToken) diff --git a/src/Lua/Internal/ArrayPoolBufferWriter.cs b/src/Lua/Internal/ArrayPoolBufferWriter.cs new file mode 100644 index 00000000..1d406b91 --- /dev/null +++ b/src/Lua/Internal/ArrayPoolBufferWriter.cs @@ -0,0 +1,69 @@ +using System.Buffers; + +namespace Lua.Internal; + +sealed class ArrayPoolBufferWriter(int initialCapacity = 256) : IBufferWriter, IDisposable +{ + T[] buffer = initialCapacity > 0 ? ArrayPool.Shared.Rent(initialCapacity) : throw new ArgumentOutOfRangeException(nameof(initialCapacity)); + int index; + + public ReadOnlySpan WrittenSpan => buffer.AsSpan(0, index); + + public void Advance(int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (index > buffer.Length - count) + { + throw new InvalidOperationException("Cannot advance past the end of the buffer."); + } + + index += count; + } + + public Memory GetMemory(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + return buffer.AsMemory(index); + } + + public Span GetSpan(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + return buffer.AsSpan(index); + } + + void CheckAndResizeBuffer(int sizeHint) + { + if (sizeHint < 0) + { + throw new ArgumentOutOfRangeException(nameof(sizeHint)); + } + + if (sizeHint == 0) + { + sizeHint = 1; + } + + if (sizeHint <= buffer.Length - index) + { + return; + } + + var newSize = Math.Max(buffer.Length * 2, index + sizeHint); + var newBuffer = ArrayPool.Shared.Rent(newSize); + buffer.AsSpan(0, index).CopyTo(newBuffer); + ArrayPool.Shared.Return(buffer); + buffer = newBuffer; + } + + public void Dispose() + { + ArrayPool.Shared.Return(buffer); + buffer = []; + index = 0; + } +} diff --git a/src/Lua/LuaStateExtensions.cs b/src/Lua/LuaStateExtensions.cs index f2a5c5bf..26c202e7 100644 --- a/src/Lua/LuaStateExtensions.cs +++ b/src/Lua/LuaStateExtensions.cs @@ -1,4 +1,6 @@ +using System.Buffers; using System.Runtime.CompilerServices; +using Lua.Internal; using Lua.IO; using Lua.Runtime; @@ -17,20 +19,22 @@ public static async ValueTask LoadFileAsync(this LuaState state, str if (stream is ILuaByteStream byteStream) { var firstByte = await byteStream.ReadByteAsync(cancellationToken); - stream.Seek(SeekOrigin.Begin, 0); - if (firstByte == '\e') + if (firstByte != '\e' && !mode.Contains('t')) { - var source = await byteStream.ReadAllBytesAsync(cancellationToken); - closure = state.Load(source, name, mode, environment); + throw new Exception("attempt to load a text chunk (mode is 'b')"); } - else if (!mode.Contains('t')) + + if (firstByte < 0) { - throw new Exception("attempt to load a text chunk (mode is 'b')"); + closure = state.Load(ReadOnlySpan.Empty, name, mode, environment); } else { - var source = await stream.ReadAllAsync(cancellationToken); - closure = state.Load(source, name, environment); + using var source = new ArrayPoolBufferWriter(); + source.GetSpan(1)[0] = (byte)firstByte; + source.Advance(1); + await byteStream.ReadBytesAsync(source, cancellationToken); + closure = state.Load(source.WrittenSpan, name, mode, environment); } } else if (!mode.Contains('t')) @@ -330,4 +334,4 @@ static async ValueTask Impl(LuaState state, int funcIndex, Cancellat return results.AsSpan().ToArray(); } } -} \ No newline at end of file +} diff --git a/tests/Lua.Tests/LoadFileModeTests.cs b/tests/Lua.Tests/LoadFileModeTests.cs index 2e55abd1..c4e8aff5 100644 --- a/tests/Lua.Tests/LoadFileModeTests.cs +++ b/tests/Lua.Tests/LoadFileModeTests.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Text; using Lua.IO; @@ -5,6 +6,91 @@ namespace Lua.Tests; public sealed class LoadFileModeTests : IDisposable { + sealed class NonSeekableByteStream(byte[] bytes) : ILuaStream, ILuaByteStream + { + int position; + + public bool IsOpen => true; + + public LuaFileOpenMode Mode => LuaFileOpenMode.Read; + + public ValueTask ReadAllAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var remaining = Encoding.UTF8.GetString(bytes, position, bytes.Length - position); + position = bytes.Length; + return new(remaining); + } + + public ValueTask ReadNumberAsync(CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public ValueTask ReadLineAsync(bool keepEol, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public ValueTask ReadAsync(int count, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public ValueTask WriteAsync(ReadOnlyMemory content, CancellationToken cancellationToken) + { + throw new IOException("Stream is read-only"); + } + + public ValueTask ReadBytesAsync(IBufferWriter writer, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var remainingLength = bytes.Length - position; + if (remainingLength > 0) + { + var buffer = writer.GetSpan(remainingLength); + bytes.AsSpan(position, remainingLength).CopyTo(buffer); + writer.Advance(remainingLength); + position = bytes.Length; + } + + return default; + } + + public ValueTask ReadByteAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (position >= bytes.Length) + { + return new(-1); + } + + return new(bytes[position++]); + } + + public long Seek(SeekOrigin origin, long offset) + { + position = origin switch + { + SeekOrigin.Begin when offset == 0 => 0, + _ => throw new NotSupportedException() + }; + return position; + } + + public void Dispose() + { + } + } + + sealed class NonSeekableByteFileSystem(byte[] bytes) : Helpers.NotImplementedExceptionFileSystemBase + { + public override ValueTask Open(string path, LuaFileOpenMode mode, CancellationToken cancellationToken) + { + return new((ILuaStream)new NonSeekableByteStream(bytes)); + } + } + readonly string testDirectory = Path.Combine(Path.GetTempPath(), $"LuaLoadFileModeTests_{Guid.NewGuid()}"); public LoadFileModeTests() @@ -52,4 +138,17 @@ public void LoadFile_TextModeRejectsBinaryChunk() Assert.That(exception, Is.Not.Null); Assert.That(exception!.Message, Does.Contain("a binary chunk")); } + + [Test] + public async Task LoadFile_NonSeekableByteStream_TextChunk_DoesNotRequireSeek() + { + using var state = LuaState.Create(); + state.Platform = state.Platform with { FileSystem = new NonSeekableByteFileSystem(Encoding.UTF8.GetBytes("return 10")) }; + + var closure = await state.LoadFileAsync("text.lua", "bt", null, CancellationToken.None); + var result = await state.ExecuteAsync(closure); + + Assert.That(result, Has.Length.EqualTo(1)); + Assert.That(result[0], Is.EqualTo(new LuaValue(10))); + } } \ No newline at end of file