瀏覽代碼

Support file:read("*n") and fix apis.

Akeit0 6 月之前
父節點
當前提交
014b453cb2

+ 12 - 1
CLAUDE.md

@@ -112,4 +112,15 @@ dotnet pack -c Release
 - The project targets .NET Standard 2.1, .NET 6.0, and .NET 8.0
 - Uses C# 13 language features
 - Heavy use of unsafe code for performance
-- Strings are UTF-16 (differs from standard Lua)
+- Strings are UTF-16 (differs from standard Lua)
+
+## TODO
+
+- **ILuaStream Interface Changes**: The ILuaStream interface has been updated with new methods:
+  - Added `IsOpen` property to track stream state
+  - Added `ReadNumberAsync()` for reading numeric values (supports formats like "6.0", "-3.23", "15e12", hex numbers)
+  - Changed `ReadLineAsync()` to accept a `keepEol` parameter for controlling line ending behavior
+  - Renamed `ReadStringAsync()` to `ReadAsync()`
+  - Added `CloseAsync()` method for async stream closing
+  - ✅ Implemented `ReadNumberAsync()` in all implementations
+  - Need to properly implement the `keepEol` parameter in `ReadLineAsync()` for TextLuaStream

+ 35 - 0
src/Lua/IO/BufferedOutputStream.cs

@@ -0,0 +1,35 @@
+using Lua.Internal;
+
+namespace Lua.IO;
+
+public class BufferedOutputStream(Action<ReadOnlyMemory<char>> onFlush) : ILuaStream
+{
+    public void Dispose()
+    {
+        IsOpen = false;
+    }
+
+    public bool IsOpen { get; set; } = true;
+    public LuaFileOpenMode Mode  => LuaFileOpenMode.Write;
+
+    private FastListCore<char> buffer;
+    public ValueTask WriteAsync(ReadOnlyMemory<char> text, CancellationToken cancellationToken = default)
+    {
+        foreach (var c in text.Span)
+        {
+            buffer .Add(c);
+        }
+        return default;
+    }
+        
+    public ValueTask FlushAsync(CancellationToken cancellationToken = default)
+    {
+        if (buffer.Length > 0)
+        {
+            onFlush(buffer.AsArray().AsMemory(0, buffer.Length));
+            buffer.Clear();
+        }
+        return default;
+    }
+        
+}

+ 3 - 3
src/Lua/IO/ConsoleStandardIO.cs

@@ -10,7 +10,7 @@ public sealed class ConsoleStandardIO : ILuaStandardIO
     ILuaStream? standardInput;
 
     public ILuaStream Input => standardInput ??=
-        new StandardIOStream(ILuaStream.CreateStreamWrapper(
+        new StandardIOStream(ILuaStream.CreateFromStream(
             ConsoleHelper.OpenStandardInput(),
             LuaFileOpenMode.Read));
 
@@ -19,7 +19,7 @@ public sealed class ConsoleStandardIO : ILuaStandardIO
     public ILuaStream Output
 
         => standardOutput ??=
-            new StandardIOStream(ILuaStream.CreateStreamWrapper(
+            new StandardIOStream(ILuaStream.CreateFromStream(
                 ConsoleHelper.OpenStandardOutput(),
                 LuaFileOpenMode.Write));
 
@@ -27,7 +27,7 @@ public sealed class ConsoleStandardIO : ILuaStandardIO
     ILuaStream? standardError;
 
     public ILuaStream Error => standardError ??=
-        new StandardIOStream(ILuaStream.CreateStreamWrapper(
+        new StandardIOStream(ILuaStream.CreateFromStream(
             ConsoleHelper.OpenStandardError(),
             LuaFileOpenMode.Write));
 }

+ 3 - 3
src/Lua/IO/FileSystem.cs

@@ -37,11 +37,11 @@
             }
 
             ILuaStream wrapper =
-                new TextLuaStream(openMode, stream);
+                new LuaStream(openMode, stream);
 
             if (openMode == LuaFileOpenMode.AppendUpdate)
             {
-                wrapper.Seek(0, SeekOrigin.End);
+                wrapper.Seek(SeekOrigin.End,0);
             }
 
             return new(wrapper);
@@ -72,7 +72,7 @@
 
         public ValueTask<ILuaStream> OpenTempFileStream(CancellationToken cancellationToken)
         {
-            return new(new TextLuaStream(LuaFileOpenMode.WriteUpdate, File.Open(Path.GetTempFileName(), FileMode.Open, FileAccess.ReadWrite)));
+            return new(new LuaStream(LuaFileOpenMode.WriteUpdate, File.Open(Path.GetTempFileName(), FileMode.Open, FileAccess.ReadWrite)));
         }
     }
 }

+ 17 - 7
src/Lua/IO/ILuaStream.cs

@@ -2,6 +2,7 @@
 {
     public interface ILuaStream : IDisposable
     {
+        public bool IsOpen { get; }
         public LuaFileOpenMode Mode { get; }
 
         public ValueTask<string> ReadAllAsync(CancellationToken cancellationToken)
@@ -12,7 +13,15 @@
             throw new NotImplementedException($"ReadAllAsync must be implemented by {GetType().Name}");
         }
 
-        public ValueTask<string?> ReadLineAsync(CancellationToken cancellationToken)
+        public ValueTask<double?> ReadNumberAsync(CancellationToken cancellationToken)
+        {
+            Mode.ThrowIfNotReadable();
+
+            // Default implementation using ReadStringAsync
+            throw new NotImplementedException($"ReadNumberAsync must be implemented by {GetType().Name}");
+        }
+
+        public ValueTask<string?> ReadLineAsync(bool keepEol, CancellationToken cancellationToken)
         {
             Mode.ThrowIfNotReadable();
 
@@ -21,7 +30,7 @@
             throw new NotImplementedException($"ReadLineAsync must be implemented by {GetType().Name}");
         }
 
-        public ValueTask<string?> ReadStringAsync(int count, CancellationToken cancellationToken)
+        public ValueTask<string?> ReadAsync(int count, CancellationToken cancellationToken)
         {
             Mode.ThrowIfNotReadable();
 
@@ -49,17 +58,17 @@
             // Default implementation does nothing (no configurable buffering)
         }
 
-        public long Seek(long offset, SeekOrigin origin)
+        public long Seek(SeekOrigin origin, long offset)
         {
             throw new NotSupportedException($"Seek is not supported by {GetType().Name}");
         }
 
-        public static ILuaStream CreateStreamWrapper(Stream stream, LuaFileOpenMode openMode)
+        public static ILuaStream CreateFromStream(Stream stream, LuaFileOpenMode openMode)
         {
-            return new TextLuaStream(openMode, stream);
+            return new LuaStream(openMode, stream);
         }
 
-        public static ILuaStream CreateFromFileString(string content)
+        public static ILuaStream CreateFromString(string content)
         {
             return new StringStream(content);
         }
@@ -70,9 +79,10 @@
         }
 
 
-        public void Close()
+        public ValueTask CloseAsync()
         {
             Dispose();
+            return default;
         }
     }
 }

+ 25 - 5
src/Lua/IO/TextLuaStream.cs → src/Lua/IO/LuaStream.cs

@@ -3,19 +3,21 @@ using System.Text;
 
 namespace Lua.IO;
 
-internal sealed class TextLuaStream(LuaFileOpenMode mode, Stream innerStream) : ILuaStream
+internal sealed class LuaStream(LuaFileOpenMode mode, Stream innerStream) : ILuaStream
 {
     Utf8Reader? reader;
     ulong flushSize = ulong.MaxValue;
     ulong nextFlushSize = ulong.MaxValue;
+    bool disposed;
 
     public LuaFileOpenMode Mode => mode;
+    public bool IsOpen => !disposed;
 
-    public ValueTask<string?> ReadLineAsync(CancellationToken cancellationToken)
+    public ValueTask<string?> ReadLineAsync(bool keepEol, CancellationToken cancellationToken)
     {
         mode.ThrowIfNotReadable();
         reader ??= new();
-        return new(reader.ReadLine(innerStream));
+        return new(reader.ReadLine(innerStream, keepEol));
     }
 
     public ValueTask<string> ReadAllAsync(CancellationToken cancellationToken)
@@ -26,13 +28,28 @@ internal sealed class TextLuaStream(LuaFileOpenMode mode, Stream innerStream) :
         return new(text);
     }
 
-    public ValueTask<string?> ReadStringAsync(int count, CancellationToken cancellationToken)
+    public ValueTask<string?> ReadAsync(int count, CancellationToken cancellationToken)
     {
         mode.ThrowIfNotReadable();
         reader ??= new();
         return new(reader.Read(innerStream, count));
     }
 
+    public ValueTask<double?> ReadNumberAsync(CancellationToken cancellationToken)
+    {
+        mode.ThrowIfNotReadable();
+        reader ??= new();
+        
+        // Use the Utf8Reader's ReadNumber method which handles positioning correctly
+        var numberStr = reader.ReadNumber(innerStream);
+        if (numberStr == null)
+            return new((double?)null);
+            
+        // Parse using the shared utility
+        var result = NumberReaderHelper.ParseNumber(numberStr.AsSpan());
+        return new(result);
+    }
+
 
     public ValueTask WriteAsync(ReadOnlyMemory<char> buffer, CancellationToken cancellationToken)
     {
@@ -84,7 +101,7 @@ internal sealed class TextLuaStream(LuaFileOpenMode mode, Stream innerStream) :
         }
     }
 
-    public long Seek(long offset, SeekOrigin origin)
+    public long Seek(SeekOrigin origin,long offset)
     {
         if (reader != null && origin == SeekOrigin.Current)
         {
@@ -97,6 +114,9 @@ internal sealed class TextLuaStream(LuaFileOpenMode mode, Stream innerStream) :
 
     public void Dispose()
     {
+        if (disposed) return;
+        disposed = true;
+        
         try
         {
             if (innerStream.CanWrite)

+ 68 - 12
src/Lua/IO/MemoryStreams.cs

@@ -6,6 +6,7 @@ public class CharMemoryStream(ReadOnlyMemory<char> contents) : ILuaStream
     private bool disposed;
 
     public LuaFileOpenMode Mode => LuaFileOpenMode.Read;
+    public bool IsOpen => !disposed;
 
     public void Dispose()
     {
@@ -25,7 +26,7 @@ public class CharMemoryStream(ReadOnlyMemory<char> contents) : ILuaStream
         return new(remaining.ToString());
     }
 
-    public ValueTask<string?> ReadLineAsync(CancellationToken cancellationToken)
+    public ValueTask<string?> ReadLineAsync(bool keepEol, CancellationToken cancellationToken)
     {
         ThrowIfDisposed();
         cancellationToken.ThrowIfCancellationRequested();
@@ -33,8 +34,8 @@ public class CharMemoryStream(ReadOnlyMemory<char> contents) : ILuaStream
         if (Position >= contents.Length)
             return new((string?)null);
 
-        var remainingSpan = contents.Slice(Position).Span;
-        var newlineIndex = remainingSpan.IndexOf('\n');
+        var remainingSpan = contents[Position..].Span;
+        var newlineIndex = remainingSpan.IndexOfAny('\r', '\n');
 
         string result;
         if (newlineIndex == -1)
@@ -45,20 +46,34 @@ public class CharMemoryStream(ReadOnlyMemory<char> contents) : ILuaStream
         }
         else
         {
-            // Read up to newline
             var lineSpan = remainingSpan[..newlineIndex];
-            // Remove CR if present
-            if (lineSpan.Length > 0 && lineSpan[^1] == '\r')
-                lineSpan = lineSpan[..^1];
-
-            result = lineSpan.ToString();
-            Position += newlineIndex + 1;
+            var nlChar = remainingSpan[newlineIndex];
+            var endOfLineLength = 1;
+            
+            // Check for CRLF
+            if (nlChar == '\r' && newlineIndex + 1 < remainingSpan.Length && remainingSpan[newlineIndex + 1] == '\n')
+            {
+                endOfLineLength = 2; // \r\n
+            }
+            
+            if (keepEol)
+            {
+                // Include the newline character(s)
+                result = remainingSpan[..(newlineIndex + endOfLineLength)].ToString();
+            }
+            else
+            {
+                // Just the line content without newlines
+                result = lineSpan.ToString();
+            }
+            
+            Position += newlineIndex + endOfLineLength;
         }
 
         return new(result);
     }
 
-    public ValueTask<string?> ReadStringAsync(int count, CancellationToken cancellationToken)
+    public ValueTask<string?> ReadAsync(int count, CancellationToken cancellationToken)
     {
         ThrowIfDisposed();
         cancellationToken.ThrowIfCancellationRequested();
@@ -75,6 +90,41 @@ public class CharMemoryStream(ReadOnlyMemory<char> contents) : ILuaStream
         return new(result);
     }
 
+    public ValueTask<double?> ReadNumberAsync(CancellationToken cancellationToken)
+    {
+        ThrowIfDisposed();
+        cancellationToken.ThrowIfCancellationRequested();
+        
+        if (Position >= contents.Length)
+            return new((double?)null);
+        
+        var remaining = contents[Position..].Span;
+        var startPos = Position;
+        
+        // Use the shared utility to scan for a number
+        var numberLength = NumberReaderHelper.ScanNumberLength(remaining, skipWhitespace: true);
+        
+        if (numberLength == 0)
+        {
+            Position = contents.Length;
+            return new((double?)null);
+        }
+        
+        // Find where the actual number starts (after whitespace)
+        var whitespaceLength = 0;
+        while (whitespaceLength < remaining.Length && char.IsWhiteSpace(remaining[whitespaceLength]))
+        {
+            whitespaceLength++;
+        }
+        
+        var numberSpan = remaining.Slice(whitespaceLength, numberLength);
+        Position = startPos + whitespaceLength + numberLength;
+        
+        // Parse using shared utility
+        var result = NumberReaderHelper.ParseNumber(numberSpan);
+        return new(result);
+    }
+
     public ValueTask WriteAsync(ReadOnlyMemory<char> content, CancellationToken cancellationToken)
     {
         throw new IOException("Stream is read-only");
@@ -91,7 +141,13 @@ public class CharMemoryStream(ReadOnlyMemory<char> contents) : ILuaStream
         // No-op for memory streams
     }
 
-    public long Seek(long offset, SeekOrigin origin)
+    public ValueTask CloseAsync()
+    {
+        Dispose();
+        return default;
+    }
+
+    public long Seek(SeekOrigin origin,long offset)
     {
         ThrowIfDisposed();
 

+ 181 - 0
src/Lua/IO/NumberReaderHelper.cs

@@ -0,0 +1,181 @@
+using Lua.Internal;
+using System.Globalization;
+
+namespace Lua.IO;
+
+public static class NumberReaderHelper
+{
+    /// <summary>
+    /// Scans a span of characters to find the extent of a valid number.
+    /// Returns the length of the number portion, or 0 if no valid number is found.
+    /// </summary>
+    /// <param name="span">The span to scan</param>
+    /// <param name="skipWhitespace">Whether to skip leading whitespace</param>
+    /// <returns>The length of the valid number portion</returns>
+    public static int ScanNumberLength(ReadOnlySpan<char> span, bool skipWhitespace = true)
+    {
+        var position = 0;
+        
+        // Skip leading whitespace
+        if (skipWhitespace)
+        {
+            while (position < span.Length && char.IsWhiteSpace(span[position]))
+            {
+                position++;
+            }
+        }
+        
+        if (position >= span.Length)
+            return 0;
+        
+        var numberStart = position;
+        var hasStarted = false;
+        var isHex = false;
+        var hasDecimal = false;
+        var lastWasE = false;
+        
+        // Check for sign
+        if (position < span.Length && (span[position] == '+' || span[position] == '-'))
+        {
+            position++;
+            hasStarted = true;
+        }
+        
+        // Check for hex prefix right at the start (after optional sign)
+        if (position < span.Length - 1 && span[position] == '0' && (span[position + 1] == 'x' || span[position + 1] == 'X'))
+        {
+            isHex = true;
+            position += 2; // Skip '0x' or '0X'
+            hasStarted = true;
+        }
+        
+        // Scan for valid number characters
+        while (position < span.Length)
+        {
+            var c = span[position];
+            
+            // Hex prefix is handled above before the loop
+            
+            if (isHex)
+            {
+                // Hex digits
+                if (c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F')
+                {
+                    position++;
+                    hasStarted = true;
+                }
+                // Hex decimal point
+                else if (c == '.' && !hasDecimal)
+                {
+                    position++;
+                    hasDecimal = true;
+                }
+                // Hex exponent (p or P)
+                else if (c is 'p' or 'P' && hasStarted)
+                {
+                    position++;
+                    lastWasE = true;
+                }
+                // Sign after exponent
+                else if (lastWasE && c is '+' or '-')
+                {
+                    position++;
+                    lastWasE = false;
+                }
+                else
+                {
+                    break;
+                }
+            }
+            else
+            {
+                // Decimal digits
+                if (c is >= '0' and <= '9')
+                {
+                    position++;
+                    hasStarted = true;
+                    lastWasE = false;
+                }
+                // Decimal point
+                else if (c == '.' && !hasDecimal)
+                {
+                    position++;
+                    hasDecimal = true;
+                    lastWasE = false;
+                }
+                // Exponent (e or E)
+                else if (c is 'e' or 'E' && hasStarted)
+                {
+                    position++;
+                    lastWasE = true;
+                }
+                // Sign after exponent
+                else if (lastWasE && c is '+' or '-')
+                {
+                    position++;
+                    lastWasE = false;
+                }
+                else
+                {
+                    break;
+                }
+            }
+        }
+        
+        // Return the length of the number portion
+        return position - numberStart;
+    }
+    
+    public static bool TryParseToDouble(ReadOnlySpan<char> span, out double result)
+    {
+        span = span.Trim();
+        if (span.Length == 0)
+        {
+            result = default!;
+            return false;
+        }
+
+        var sign = 1;
+        var first = span[0];
+        if (first is '+')
+        {
+            sign = 1;
+            span = span[1..];
+        }
+        else if (first is '-')
+        {
+            sign = -1;
+            span = span[1..];
+        }
+
+        if (span.Length > 2 && span[0] is '0' && span[1] is 'x' or 'X')
+        {
+            // TODO: optimize
+            try
+            {
+                var d = HexConverter.ToDouble(span) * sign;
+                result = d;
+                return true;
+            }
+            catch (FormatException)
+            {
+                result = default!;
+                return false;
+            }
+        }
+        else
+        {
+            return double.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out result);
+        }
+    }
+    
+    /// <summary>
+    /// Parses a number from a span and returns the result or null if parsing fails.
+    /// </summary>
+    /// <param name="span">The span containing the number</param>
+    /// <returns>The parsed number or null if parsing failed</returns>
+    public static double? ParseNumber(ReadOnlySpan<char> span)
+    {
+        return TryParseToDouble(span, out var result) ? result : null;
+    }
+}

+ 15 - 6
src/Lua/IO/StandardIOStream.cs

@@ -6,15 +6,19 @@
     internal sealed class StandardIOStream(ILuaStream innerStream) : ILuaStream
     {
         public LuaFileOpenMode Mode => innerStream.Mode;
+        public bool IsOpen => innerStream.IsOpen;
 
         public ValueTask<string> ReadAllAsync(CancellationToken cancellationToken)
             => innerStream.ReadAllAsync(cancellationToken);
 
-        public ValueTask<string?> ReadLineAsync(CancellationToken cancellationToken)
-            => innerStream.ReadLineAsync(cancellationToken);
+        public ValueTask<string?> ReadLineAsync(bool keepEol, CancellationToken cancellationToken)
+            => innerStream.ReadLineAsync(keepEol, cancellationToken);
 
-        public ValueTask<string?> ReadStringAsync(int count, CancellationToken cancellationToken)
-            => innerStream.ReadStringAsync(count, cancellationToken);
+        public ValueTask<string?> ReadAsync(int count, CancellationToken cancellationToken)
+            => innerStream.ReadAsync(count, cancellationToken);
+
+        public ValueTask<double?> ReadNumberAsync(CancellationToken cancellationToken)
+            => innerStream.ReadNumberAsync(cancellationToken);
 
         public ValueTask WriteAsync(ReadOnlyMemory<char> content, CancellationToken cancellationToken)
             => innerStream.WriteAsync(content, cancellationToken);
@@ -25,14 +29,19 @@
         public void SetVBuf(LuaFileBufferingMode mode, int size)
             => innerStream.SetVBuf(mode, size);
 
-        public long Seek(long offset, SeekOrigin origin)
-            => innerStream.Seek(offset, origin);
+        public long Seek(SeekOrigin origin, long offset)
+            => innerStream.Seek(origin, offset);
 
         public void Close()
         {
             throw new IOException("cannot close standard file");
         }
 
+        public ValueTask CloseAsync()
+        {
+            throw new IOException("cannot close standard file");
+        }
+
         public void Dispose()
         {
             // Do not dispose inner stream to prevent closing standard IO streams

+ 207 - 5
src/Lua/Internal/Utf8Reader.cs

@@ -33,7 +33,7 @@ internal sealed class Utf8Reader
 
     public long Remain => bufLen - bufPos;
 
-    public string? ReadLine(Stream stream)
+    public string? ReadLine(Stream stream, bool keepEol = false)
     {
         var resultBuffer = ArrayPool<byte>.Shared.Rent(1024);
         var lineLen = 0;
@@ -54,21 +54,41 @@ internal sealed class Utf8Reader
 
                 if (idx >= 0)
                 {
+                    // Add the line content (before the newline)
                     AppendToBuffer(ref resultBuffer, span[..idx], ref lineLen);
 
                     byte nl = span[idx];
+                    var eolStart = bufPos + idx;
                     bufPos += idx + 1;
 
-                    // CRLF
+                    // Handle CRLF - check if we have \r\n
+                    bool isCRLF = false;
                     if (nl == (byte)'\r' && bufPos < bufLen && buffer[bufPos] == (byte)'\n')
-                        bufPos++;
+                    {
+                        isCRLF = true;
+                        bufPos++; // Skip the \n as well
+                    }
+
+                    // Add end-of-line characters if keepEol is true
+                    if (keepEol)
+                    {
+                        if (isCRLF)
+                        {
+                            // Add \r\n
+                            AppendToBuffer(ref resultBuffer, new ReadOnlySpan<byte>(new byte[] { (byte)'\r', (byte)'\n' }), ref lineLen);
+                        }
+                        else
+                        {
+                            // Add just the single newline character (\r or \n)
+                            AppendToBuffer(ref resultBuffer, new ReadOnlySpan<byte>(new byte[] { nl }), ref lineLen);
+                        }
+                    }
 
-                    // 行を返す
                     return Encoding.UTF8.GetString(resultBuffer, 0, lineLen);
                 }
                 else
                 {
-                    // 改行なし → 全部行バッファへ
+                    // No newline found → add all to line buffer
                     AppendToBuffer(ref resultBuffer, span, ref lineLen);
                     bufPos = bufLen;
                 }
@@ -116,6 +136,30 @@ internal sealed class Utf8Reader
         }
     }
 
+    public byte ReadByte(Stream stream)
+    {
+        if (buffer.Length == 0) return 0;
+
+        var len = 0;
+        while (len < 1)
+        {
+            if (bufPos >= bufLen)
+            {
+                
+                bufLen = stream.Read(this.buffer, 0, this.buffer.Length);
+                bufPos = 0;
+                if (bufLen == 0) break; // EOF
+            }
+            var bytesToRead = Math.Min(1, bufLen - bufPos);
+            if (bytesToRead == 0) break;
+            if (bytesToRead > 0)
+            {
+                len += bytesToRead;
+            }
+        }
+
+        return buffer[bufPos++];
+    }
     public string? Read(Stream stream, int charCount)
     {
         if (charCount < 0) throw new ArgumentOutOfRangeException(nameof(charCount));
@@ -175,6 +219,7 @@ internal sealed class Utf8Reader
             var newBuffer = ArrayPool<byte>.Shared.Rent(newSize);
             Array.Copy(buffer, newBuffer, length);
             ArrayPool<byte>.Shared.Return(buffer);
+            buffer = newBuffer;
         }
 
         segment.CopyTo(buffer.AsSpan(length));
@@ -187,6 +232,163 @@ internal sealed class Utf8Reader
         bufLen = 0;
     }
 
+    public string? ReadNumber(Stream stream)
+    {
+        var resultBuffer = ArrayPool<char>.Shared.Rent(64); // Numbers shouldn't be too long
+        var len = 0;
+        var hasStarted = false;
+        var isHex = false;
+        var hasDecimal = false;
+        var lastWasE = false;
+        
+        try
+        {
+            // Skip leading whitespace
+            while (true)
+            {
+                var b = PeekByte(stream);
+                if (b == -1) return null; // EOF
+                
+                var c = (char)b;
+                if (!char.IsWhiteSpace(c)) break;
+                
+                ReadByte(stream); // Consume whitespace
+            }
+            
+            // Check for hex prefix at the start
+            if (PeekByte(stream) == '0')
+            {
+                var nextByte = PeekByte(stream, 1);
+                if (nextByte == 'x' || nextByte == 'X')
+                {
+                    isHex = true;
+                    resultBuffer[len++] = '0';
+                    ReadByte(stream);
+                    resultBuffer[len++] = (char)ReadByte(stream);
+                    hasStarted = true;
+                }
+            }
+            
+            // Read number characters
+            while (true)
+            {
+                var b = PeekByte(stream);
+                if (b == -1) break; // EOF
+                
+                var c = (char)b;
+                var shouldConsume = false;
+                
+                if (!hasStarted && (c == '+' || c == '-'))
+                {
+                    shouldConsume = true;
+                    hasStarted = true;
+                }
+                else if (isHex)
+                {
+                    // Hex digits
+                    if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))
+                    {
+                        shouldConsume = true;
+                        hasStarted = true;
+                    }
+                    // Hex decimal point
+                    else if (c == '.' && !hasDecimal)
+                    {
+                        shouldConsume = true;
+                        hasDecimal = true;
+                    }
+                    // Hex exponent (p or P)
+                    else if ((c == 'p' || c == 'P') && hasStarted)
+                    {
+                        shouldConsume = true;
+                        lastWasE = true;
+                    }
+                    // Sign after exponent
+                    else if (lastWasE && (c == '+' || c == '-'))
+                    {
+                        shouldConsume = true;
+                        lastWasE = false;
+                    }
+                }
+                else
+                {
+                    // Decimal digits
+                    if (c >= '0' && c <= '9')
+                    {
+                        shouldConsume = true;
+                        hasStarted = true;
+                        lastWasE = false;
+                    }
+                    // Decimal point
+                    else if (c == '.' && !hasDecimal)
+                    {
+                        shouldConsume = true;
+                        hasDecimal = true;
+                        lastWasE = false;
+                    }
+                    // Exponent (e or E)
+                    else if ((c == 'e' || c == 'E') && hasStarted)
+                    {
+                        shouldConsume = true;
+                        lastWasE = true;
+                    }
+                    // Sign after exponent
+                    else if (lastWasE && (c == '+' || c == '-'))
+                    {
+                        shouldConsume = true;
+                        lastWasE = false;
+                    }
+                }
+                
+                if (shouldConsume)
+                {
+                    if (len >= resultBuffer.Length)
+                    {
+                        // Number too long, expand buffer
+                        var newBuffer = ArrayPool<char>.Shared.Rent(resultBuffer.Length * 2);
+                        resultBuffer.AsSpan(0, len).CopyTo(newBuffer);
+                        ArrayPool<char>.Shared.Return(resultBuffer);
+                        resultBuffer = newBuffer;
+                    }
+                    
+                    resultBuffer[len++] = c;
+                    ReadByte(stream); // Consume the byte
+                }
+                else
+                {
+                    break; // Not part of the number
+                }
+            }
+            
+            return len == 0 ? null : resultBuffer.AsSpan(0, len).ToString();
+        }
+        finally
+        {
+            ArrayPool<char>.Shared.Return(resultBuffer);
+        }
+    }
+    
+    private int PeekByte(Stream stream, int offset = 0)
+    {
+        // Ensure we have enough data in buffer
+        while (bufPos + offset >= bufLen)
+        {
+            if (bufLen == 0 || bufPos == bufLen)
+            {
+                bufLen = stream.Read(buffer, 0, buffer.Length);
+                bufPos = 0;
+                if (bufLen == 0) return -1; // EOF
+            }
+            else
+            {
+                // We need more data but buffer has some - this shouldn't happen with small offsets
+                return -1;
+            }
+        }
+        
+        return buffer[bufPos + offset];
+    }
+
     public void Dispose()
     {
         scratchBufferUsed = false;

+ 12 - 8
src/Lua/Standard/FileHandle.cs

@@ -46,16 +46,20 @@ public class FileHandle : ILuaUserData
         fileHandleMetatable[Metamethods.Index] = IndexMetamethod;
     }
 
-    public FileHandle(Stream stream, LuaFileOpenMode mode) : this(ILuaStream.CreateStreamWrapper(stream, mode)) { }
+    public FileHandle(Stream stream, LuaFileOpenMode mode) : this(ILuaStream.CreateFromStream(stream, mode)) { }
 
     public FileHandle(ILuaStream stream)
     {
         this.stream = stream;
     }
+    public ValueTask<double?> ReadNumberAsync(CancellationToken cancellationToken)
+    {
+        return stream.ReadNumberAsync( cancellationToken);
+    }
 
-    public ValueTask<string?> ReadLineAsync(CancellationToken cancellationToken)
+    public ValueTask<string?> ReadLineAsync(bool keepEol,CancellationToken cancellationToken)
     {
-        return stream.ReadLineAsync(cancellationToken);
+        return stream.ReadLineAsync(keepEol, cancellationToken);
     }
 
     public ValueTask<string> ReadToEndAsync(CancellationToken cancellationToken)
@@ -65,7 +69,7 @@ public class FileHandle : ILuaUserData
 
     public ValueTask<string?> ReadStringAsync(int count, CancellationToken cancellationToken)
     {
-        return stream.ReadStringAsync(count, cancellationToken);
+        return stream.ReadAsync(count, cancellationToken);
     }
 
 
@@ -83,9 +87,9 @@ public class FileHandle : ILuaUserData
     public long Seek(string whence, long offset) =>
         whence switch
         {
-            "set" => stream.Seek(offset, SeekOrigin.Begin),
-            "cur" => stream.Seek(offset, SeekOrigin.Current),
-            "end" => stream.Seek(offset, SeekOrigin.End),
+            "set" => stream.Seek(SeekOrigin.Begin,offset),
+            "cur" => stream.Seek(SeekOrigin.Current,offset),
+            "end" => stream.Seek( SeekOrigin.End,offset),
             _ => throw new ArgumentException($"Invalid option '{whence}'")
         };
 
@@ -109,7 +113,7 @@ public class FileHandle : ILuaUserData
     public void Close()
     {
         if (isClosed) throw new ObjectDisposedException(nameof(FileHandle));
-        stream.Close();
+        stream.CloseAsync().AsTask().Wait();
         Volatile.Write(ref isClosed, true);
         stream = null!;
     }

+ 1 - 1
src/Lua/Standard/IOLibrary.cs

@@ -77,7 +77,7 @@ public sealed class IOLibrary
         }
         else
         {
-            var stream = await context.State.FileSystem.Open(arg.ToString(), LuaFileOpenMode.AppendUpdate, cancellationToken);
+            var stream = await context.State.FileSystem.Open(arg.ToString(), LuaFileOpenMode.Read, cancellationToken);
             var handle = new FileHandle(stream);
             registry["_IO_input"] = new(handle);
             return context.Return(new LuaValue(handle));

+ 4 - 4
src/Lua/Standard/Internal/IOHelper.cs

@@ -95,19 +95,19 @@ internal static class IOHelper
                     {
                         case "*n":
                         case "*number":
-                            // TODO: support number format
-                            throw new NotImplementedException();
+                            stack.Push(await file.ReadNumberAsync(cancellationToken)?? LuaValue.Nil);
+                            break;
                         case "*a":
                         case "*all":
                             stack.Push((await file.ReadToEndAsync(cancellationToken)));
                             break;
                         case "*l":
                         case "*line":
-                            stack.Push(await file.ReadLineAsync(cancellationToken) ?? LuaValue.Nil);
+                            stack.Push(await file.ReadLineAsync(false,cancellationToken) ?? LuaValue.Nil);
                             break;
                         case "L":
                         case "*L":
-                            var text = await file.ReadLineAsync(cancellationToken);
+                            var text = await file.ReadLineAsync(true,cancellationToken);
                             stack.Push(text == null ? LuaValue.Nil : text + Environment.NewLine);
                             break;
                     }

+ 1 - 1
tests/Lua.Tests/AbstractFileTests.cs

@@ -18,7 +18,7 @@ public class AbstractFileTests
 
             if (mode != LuaFileOpenMode.Read)
                 throw new IOException($"File {path} not opened in read mode");
-            return new (new ReadOnlyCharMemoryLuaIOStream(value.AsMemory()));
+            return new (ILuaStream.CreateFromMemory(value.AsMemory()));
         }
     }
 

+ 0 - 97
tests/Lua.Tests/Helpers/CharMemoryStream.cs

@@ -1,97 +0,0 @@
-using Lua.IO;
-namespace Lua.Tests.Helpers;
-
-internal sealed class ReadOnlyCharMemoryLuaIOStream(ReadOnlyMemory<char> buffer, Action<ReadOnlyCharMemoryLuaIOStream>? onDispose  =null,object? state =null) : NotSupportedStreamBase
-{
-    public readonly ReadOnlyMemory<char> Buffer = buffer;
-    int position;
-    public readonly object? State = state;
-    Action<ReadOnlyCharMemoryLuaIOStream>? onDispose = onDispose;
-
-    public static (string Result, int AdvanceCount) ReadLine(ReadOnlySpan<char> remaining)
-    {
-        int advanceCount;
-        var line = remaining.IndexOfAny('\n', '\r');
-        if (line == -1)
-        {
-            line = remaining.Length;
-            advanceCount = line;
-        }
-        else
-        {
-            if (remaining[line] == '\r' && line + 1 < remaining.Length && remaining[line + 1] == '\n')
-            {
-                advanceCount = line + 2;
-            }
-            else
-            {
-                advanceCount = line + 1;
-            }
-        }
-
-
-        return new(remaining[..line].ToString(), advanceCount);
-    }
-    public override ValueTask<string?> ReadLineAsync(CancellationToken cancellationToken)
-    {
-        if (position >= Buffer.Length)
-        {
-            return new(default(string));
-        }
-
-        var remaining = Buffer[position..];
-        var (line, advanceCount) = ReadLine(remaining.Span);
-        position += advanceCount;
-        return new(line);
-    }
-
-    public override ValueTask<string> ReadAllAsync(CancellationToken cancellationToken)
-    {
-        var remaining = Buffer[position..];
-        position = Buffer.Length;
-        return new( remaining.ToString());
-    }
-
-    public override ValueTask<string?> ReadStringAsync(int count, CancellationToken cancellationToken)
-    {
-        cancellationToken .ThrowIfCancellationRequested();
-        if (position >= Buffer.Length)
-        {
-            return new("");
-        }
-
-        var remaining = Buffer[position..];
-        if (count > remaining.Length)
-        {
-            count = remaining.Length;
-        }
-
-        var result = remaining.Slice(0, count).ToString();
-        position += count;
-        return new(result);
-    }
-
-    public override void Dispose()
-    {
-        onDispose?.Invoke(this);
-        onDispose = null;
-    }
-
-    public override long Seek(long offset, SeekOrigin origin)
-    {
-        unchecked
-        {
-            position = origin switch
-            {
-                SeekOrigin.Begin => (int)offset,
-                SeekOrigin.Current => position + (int)offset,
-                SeekOrigin.End => (int)(Buffer.Length + offset),
-                _ => (int)IOThrowHelpers.ThrowArgumentExceptionForSeekOrigin()
-            };
-        }
-
-        IOThrowHelpers.ValidatePosition(position, Buffer.Length);
-
-        return position;
-    }
-}

+ 0 - 48
tests/Lua.Tests/Helpers/NotSupportedStreamBase.cs

@@ -1,48 +0,0 @@
-using Lua.IO;
-
-namespace Lua.Tests.Helpers
-{
-    public class NotSupportedStreamBase : ILuaStream
-    {
-        public virtual void Dispose()
-        {
-        }
-
-        public virtual LuaFileOpenMode Mode => throw IOThrowHelpers.GetNotSupportedException();
-
-        public virtual ValueTask<string?> ReadLineAsync(CancellationToken cancellationToken)
-        {
-            throw IOThrowHelpers.GetNotSupportedException();
-        }
-
-        public virtual ValueTask<string> ReadAllAsync(CancellationToken cancellationToken)
-        {
-            throw IOThrowHelpers.GetNotSupportedException();
-        }
-
-        public virtual ValueTask<string?> ReadStringAsync(int count, CancellationToken cancellationToken)
-        {
-            throw IOThrowHelpers.GetNotSupportedException();
-        }
-
-        public virtual ValueTask WriteAsync(ReadOnlyMemory<char> content, CancellationToken cancellationToken)
-        {
-            throw IOThrowHelpers.GetNotSupportedException();
-        }
-
-        public virtual ValueTask FlushAsync(CancellationToken cancellationToken)
-        {
-            throw IOThrowHelpers.GetNotSupportedException();
-        }
-
-        public virtual void SetVBuf(LuaFileBufferingMode mode, int size)
-        {
-            throw IOThrowHelpers.GetNotSupportedException();
-        }
-
-        public virtual long Seek(long offset, SeekOrigin origin)
-        {
-            throw IOThrowHelpers.GetNotSupportedException();
-        }
-    }
-}

+ 14 - 0
tests/Lua.Tests/Helpers/TestStandardIO.cs

@@ -0,0 +1,14 @@
+using Lua.IO;
+
+namespace Lua.Tests.Helpers
+{
+    public class TestStandardIO :ILuaStandardIO
+    {
+        private readonly ConsoleStandardIO consoleStandardIO = new ConsoleStandardIO();
+        public ILuaStream Input => consoleStandardIO.Input;
+        // This is a test implementation of Output that writes to the console. Because NUnit does not support Console output streams.
+        
+        public ILuaStream Output { get; set; } = new StandardIOStream(new BufferedOutputStream((memory) => { Console.WriteLine(memory.ToString()); }));
+        public ILuaStream Error  => consoleStandardIO.Error;
+    }
+}

+ 32 - 34
tests/Lua.Tests/IOTests.cs

@@ -28,7 +28,7 @@ public class IOTests : IDisposable
     {
         return Path.Combine(testDirectory, filename);
     }
-    
+
     [Test]
     public async Task TextStream_Write_And_Read_Text()
     {
@@ -36,13 +36,13 @@ public class IOTests : IDisposable
         var testContent = "Hello, World!\nThis is a test.";
 
         // Write text
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write, CancellationToken.None))
         {
             await stream.WriteAsync(testContent, CancellationToken.None);
         }
 
         // Read text
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read, CancellationToken.None))
         {
             var content = await stream.ReadAllAsync(CancellationToken.None);
             Assert.That(content, Is.EqualTo(testContent));
@@ -50,8 +50,6 @@ public class IOTests : IDisposable
     }
 
 
-
-
     [Test]
     public async Task TextStream_ReadLine_Works()
     {
@@ -59,22 +57,22 @@ public class IOTests : IDisposable
         var lines = new[] { "Line 1", "Line 2", "Line 3" };
 
         // Write multiple lines
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write, CancellationToken.None))
         {
             await stream.WriteAsync((string.Join("\n", lines)), CancellationToken.None);
         }
 
         // Read lines one by one
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read, CancellationToken.None))
         {
             for (int i = 0; i < lines.Length; i++)
             {
-                var line = await stream.ReadLineAsync(CancellationToken.None);
+                var line = await stream.ReadLineAsync(false, CancellationToken.None);
                 Assert.That(line, Is.EqualTo(lines[i]));
             }
 
             // EOF should return null
-            var eofLine = await stream.ReadLineAsync(CancellationToken.None);
+            var eofLine = await stream.ReadLineAsync(false, CancellationToken.None);
             Assert.That(eofLine, Is.Null);
         }
     }
@@ -86,28 +84,28 @@ public class IOTests : IDisposable
         var testContent = "Hello, World!";
 
         // Write content
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write, CancellationToken.None))
         {
             await stream.WriteAsync(testContent, CancellationToken.None);
         }
 
         // Read partial strings
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read, CancellationToken.None))
         {
-            var part1 = await stream.ReadStringAsync(5, CancellationToken.None);
+            var part1 = await stream.ReadAsync(5, CancellationToken.None);
             Assert.That(part1, Is.EqualTo("Hello"));
 
-            var part2 = await stream.ReadStringAsync(7, CancellationToken.None);
+            var part2 = await stream.ReadAsync(7, CancellationToken.None);
             Assert.That(part2, Is.EqualTo(", World"));
 
-            var part3 = await stream.ReadStringAsync(1, CancellationToken.None);
+            var part3 = await stream.ReadAsync(1, CancellationToken.None);
             Assert.That(part3, Is.EqualTo("!")); // Only 1 char left
 
-            var eof = await stream.ReadStringAsync(10, CancellationToken.None);
+            var eof = await stream.ReadAsync(10, CancellationToken.None);
             Assert.That(eof, Is.Null);
         }
     }
-    
+
 
     [Test]
     public async Task Append_Mode_Appends_Content()
@@ -115,19 +113,19 @@ public class IOTests : IDisposable
         var testFile = GetTestFilePath("append_test.txt");
 
         // Write initial content
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write, CancellationToken.None))
         {
             await stream.WriteAsync(("Hello"), CancellationToken.None);
         }
 
         // Append content
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Append,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Append, CancellationToken.None))
         {
             await stream.WriteAsync((" World"), CancellationToken.None);
         }
 
         // Read and verify
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read, CancellationToken.None))
         {
             var content = await stream.ReadAllAsync(CancellationToken.None);
             Assert.That(content, Is.EqualTo("Hello World"));
@@ -141,27 +139,27 @@ public class IOTests : IDisposable
         var testContent = "0123456789";
 
         // Write content
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write, CancellationToken.None))
         {
             await stream.WriteAsync((testContent), CancellationToken.None);
         }
 
         // Test seeking
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read, CancellationToken.None))
         {
             // Seek from beginning
-            stream.Seek(5, SeekOrigin.Begin);
-            var afterBegin = await stream.ReadStringAsync(3, CancellationToken.None);
+            stream.Seek(SeekOrigin.Begin, 5);
+            var afterBegin = await stream.ReadAsync(3, CancellationToken.None);
             Assert.That(afterBegin, Is.EqualTo("567"));
 
             // Seek from current
-            stream.Seek(-2, SeekOrigin.Current);
-            var afterCurrent = await stream.ReadStringAsync(2, CancellationToken.None);
+            stream.Seek(SeekOrigin.Current, -2);
+            var afterCurrent = await stream.ReadAsync(2, CancellationToken.None);
             Assert.That(afterCurrent, Is.EqualTo("67"));
 
             // Seek from end
-            stream.Seek(-3, SeekOrigin.End);
-            var afterEnd = await stream.ReadStringAsync(3, CancellationToken.None);
+            stream.Seek(SeekOrigin.End, -3);
+            var afterEnd = await stream.ReadAsync(3, CancellationToken.None);
             Assert.That(afterEnd, Is.EqualTo("789"));
         }
     }
@@ -174,7 +172,7 @@ public class IOTests : IDisposable
 
         File.WriteAllText(oldPath, "test content");
 
-       await fileSystem.Rename(oldPath, newPath,CancellationToken.None);
+        await fileSystem.Rename(oldPath, newPath, CancellationToken.None);
 
         Assert.That(File.Exists(oldPath), Is.False);
         Assert.That(File.Exists(newPath), Is.True);
@@ -189,7 +187,7 @@ public class IOTests : IDisposable
         File.WriteAllText(testFile, "test content");
         Assert.That(File.Exists(testFile), Is.True);
 
-        await fileSystem.Remove(testFile,CancellationToken.None);
+        await fileSystem.Remove(testFile, CancellationToken.None);
 
         Assert.That(File.Exists(testFile), Is.False);
     }
@@ -218,7 +216,7 @@ public class IOTests : IDisposable
                 await tempStream.WriteAsync("temp content".AsMemory(), CancellationToken.None);
 
                 // Seek and read
-                tempStream.Seek(0, SeekOrigin.Begin);
+                tempStream.Seek(SeekOrigin.Begin, 0);
                 var content = await tempStream.ReadAllAsync(CancellationToken.None);
                 Assert.That(content, Is.EqualTo("temp content"));
             }
@@ -246,7 +244,7 @@ public class IOTests : IDisposable
     {
         var testFile = GetTestFilePath("buffer_test.txt");
 
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write, CancellationToken.None))
         {
             // Set no buffering
             stream.SetVBuf(LuaFileBufferingMode.NoBuffering, 0);
@@ -283,7 +281,7 @@ public class IOTests : IDisposable
             await stream.WriteAsync(charArray, CancellationToken.None);
         }
 
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read, CancellationToken.None))
         {
             var content = await stream.ReadAllAsync(CancellationToken.None);
             Assert.That(content, Is.EqualTo("Hello from char array"));
@@ -291,12 +289,12 @@ public class IOTests : IDisposable
 
         // Test with partial char array
         var longCharArray = "Hello World!!!".ToCharArray();
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Write, CancellationToken.None))
         {
             await stream.WriteAsync((longCharArray.AsMemory(0, 11)), CancellationToken.None); // Only "Hello World"
         }
 
-        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read,CancellationToken.None))
+        using (var stream = await fileSystem.Open(testFile, LuaFileOpenMode.Read, CancellationToken.None))
         {
             var content = await stream.ReadAllAsync(CancellationToken.None);
             Assert.That(content, Is.EqualTo("Hello World"));

+ 2 - 1
tests/Lua.Tests/LuaTests.cs

@@ -1,5 +1,5 @@
 using Lua.Standard;
-using System.Globalization;
+using Lua.Tests.Helpers;
 
 namespace Lua.Tests;
 
@@ -30,6 +30,7 @@ public class LuaTests
     public async Task Test_Lua(string file)
     {
         var state = LuaState.Create();
+        state.Platform.StandardIO = new TestStandardIO();
         state.OpenStandardLibraries();
         var path = FileHelper.GetAbsolutePath(file);
         Directory.SetCurrentDirectory(Path.GetDirectoryName(path)!);