diff --git a/.gitignore b/.gitignore index 0808c4a..b5112b9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ *.suo *.user *.userosscache +*.csproj.lscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfe7ad4..311c099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ The format is based on Keep a Changelog. ## [Unreleased] +## [1.1.0] - 2026-05-08 + +### Added + +- `Esolang.Processor.Abstractions` (`Esolang.Processor` namespace): shared execution abstractions package (`IProcessor`, `ITextProcessor`, `IPipeProcessor`). +- `Esolang.Funge.Processor.Tests`: coverage for `RunToEnd(...)` and `RunToEndAsync(...)` on `FungeProcessor`. +- `Esolang.Funge.Generator.Tests`: 3D runtime coverage for `h` / `l` / `m` and 3D `g` / `p` behavior. +- `samples/Generator.UseConsole/Programs/hello3d.b98`: minimal Trefunge sample using form-feed (`\f`) layer separation. +- `Esolang.Funge.Generator.Tests`: runtime coverage for storage offset-aware `g` / `p`, stack stack transfer via `u`, and `y` capability flags. + +### Changed + +- `Esolang.Funge.Processor`: `FungeProcessor` now implements `ITextProcessor` and exposes `RunToEnd(...)` / `RunToEndAsync(...)` while preserving existing `Run(...)` behavior. +- `Esolang.Funge.Processor`: switched abstraction source from local `Processor/IProcessor.cs` to `Esolang.Processor.Abstractions` package. +- `dotnet-funge` (`Esolang.Funge.Interpreter`): command execution path now calls `RunToEnd(...)`. +- `Esolang.Funge.Generator`: added return-type support for `int`, `Task`, and `ValueTask`, and aligned `q` handling to return the popped exit code (`@` returns `0`). +- `Esolang.Funge.Parser`: parser and space model now support 3D coordinates (`X`, `Y`, `Z`), with form-feed (`\f`) treated as a Z-layer separator. +- `Esolang.Funge.Processor`: enabled Trefunge 3D navigation instructions `h` / `l` / `m`, extended `?` to 6 directions, and upgraded `g` / `p` / `x` to 3D operands. +- `Esolang.Funge.Processor`: implemented filesystem instructions `i` / `o` (file input/output). +- `Esolang.Funge.Processor`: implemented system execution instruction `=` (returns process exit code on stack). +- `Esolang.Funge.Generator`: generated runtime now uses 3D execution space (XYZ bounds/cells), supports `h` / `l` / `m`, and handles 3D `g` / `p` / `x` semantics. +- `Esolang.Funge.Generator`: generated runtime now supports filesystem instructions `i` / `o`. +- `Esolang.Funge.Generator`: generated runtime now supports system execution instruction `=`. +- `Esolang.Funge.Processor`: `y` now includes command-line arguments and environment variables, uses Funge-98 date/time encoding (base-256), reports least-stack-area bounds as relative extents, and aligns positive-`c` pick behavior with full-stack semantics. +- `dotnet-funge` (`Esolang.Funge.Interpreter`): processor construction now passes command-line arguments and environment variables for `y` system-info reporting. +- `Esolang.Funge.Generator`: generated runtime now supports concurrency (`t`), stack stack operations (`{` / `}` / `u`), `y` system-info, and applies storage offset semantics to `g` / `p` and file I/O least-point handling. +- `Esolang.Funge.Generator`: method return handling now dispatches via runtime facade APIs (`RunSync` / `RunTask*` / `RunValueTask*` / `RunEnumerable` / `RunAsyncEnumerable`), and synchronous signatures can cooperatively cancel infinite execution via `CancellationToken`. +- `Esolang.Funge.Generator`: runtime source emission now includes only the facade methods required by generated signatures (Piet-style minimal runtime emission), and facade source blocks are generated with raw string literals for cleaner output layout. +- `Esolang.Funge.Generator`: generated runtime class and internal runtime entry methods are now annotated with `EditorBrowsable(EditorBrowsableState.Never)` to reduce IntelliSense surface for internal-use APIs. +- Docs: updated package README compliance tables and added 3D notes/examples (including `\f` layer separator guidance). +- Repository hygiene: ignore `*.csproj.lscache` (C# Dev Kit cache) to avoid generated noise in working trees. +- Build/package baseline: incremented `AssemblyVersion` / `FileVersion` to `1.1.0.3` and `Version` to `1.1.0`. + ## [1.0.1] - 2026-05-07 ### Changed diff --git a/Directory.Build.props b/Directory.Build.props index 8743322..ebb6abd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,9 +3,9 @@ enable enable 14 - 1.0.1.2 - 1.0.1.2 - 1.0.1 + 1.1.0.3 + 1.1.0.3 + 1.1.0 https://github.com/Esolang-NET/Funge/ https://github.com/Esolang-NET/Funge.git true diff --git a/Directory.Build.targets b/Directory.Build.targets index 1a96e0f..aaba90e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -7,6 +7,7 @@ + diff --git a/Generator.Tests/FungeMethodGeneratorTests.cs b/Generator.Tests/FungeMethodGeneratorTests.cs index da98929..397c158 100644 --- a/Generator.Tests/FungeMethodGeneratorTests.cs +++ b/Generator.Tests/FungeMethodGeneratorTests.cs @@ -1,542 +1,1332 @@ -using Basic.Reference.Assemblies; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; -using System.Collections.Immutable; -using System.Reflection; -using System.Text; - -namespace Esolang.Funge.Generator.Tests; - -[TestClass] -public class FungeMethodGeneratorTests -{ - public TestContext TestContext { get; set; } = default!; - - Compilation baseCompilation = default!; - - [TestInitialize] - public void InitializeCompilation() - { - IEnumerable references = -#if NET10_0_OR_GREATER - Net100.References.All; -#elif NET9_0_OR_GREATER - Net90.References.All; -#elif NET8_0_OR_GREATER - Net80.References.All; -#elif NET472_OR_GREATER - Net472.References.All; -#else - throw new InvalidOperationException("Unsupported target framework for generator tests."); -#endif - - var referenceList = references.ToList(); - { - var hasPipelinesReference = referenceList.Any(static r => - string.Equals(Path.GetFileNameWithoutExtension(r.FilePath), "System.IO.Pipelines", StringComparison.OrdinalIgnoreCase)); - if (!hasPipelinesReference) - { - var pipelinesAssemblyLocation = typeof(System.IO.Pipelines.PipeReader).Assembly.Location; - if (!string.IsNullOrWhiteSpace(pipelinesAssemblyLocation)) - { - referenceList.Add(MetadataReference.CreateFromFile(pipelinesAssemblyLocation)); - } - } - } -#if !NET - { - var memoryAssemblyLocation = typeof(Memory<>).Assembly.Location; - if (!string.IsNullOrWhiteSpace(memoryAssemblyLocation)) - { - referenceList.Add(MetadataReference.CreateFromFile(memoryAssemblyLocation)); - } - } - { - var asm = typeof(ValueTask).Assembly.Location; - referenceList.Add(MetadataReference.CreateFromFile(asm)); - } - { - var asm = typeof(IAsyncEnumerable<>).Assembly.Location; - referenceList.Add(MetadataReference.CreateFromFile(asm)); - } -#endif - - baseCompilation = CSharpCompilation.Create("generatortest", - references: referenceList, - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - } - - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - GeneratorDriver RunGenerators( - string source, - out Compilation outputCompilation, - out ImmutableArray diagnostics, - IEnumerable<(string path, string content)>? additionalFiles = null, - LanguageVersion languageVersion = LanguageVersion.CSharp12, - CancellationToken cancellationToken = default) - { - var parseOptions = new CSharpParseOptions(languageVersion); - var generator = new MethodGenerator(); - var driver = CSharpGeneratorDriver.Create( - generators: [generator.AsSourceGenerator()], - additionalTexts: additionalFiles?.Select(f => - (AdditionalText)new TestAdditionalText(f.path, f.content)) ?? [], - driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true) - ).WithUpdatedParseOptions(parseOptions); - - var compilation = baseCompilation.AddSyntaxTrees( - CSharpSyntaxTree.ParseText(source, parseOptions, path: "input.cs", - encoding: Encoding.UTF8, cancellationToken: cancellationToken)); - - return driver.RunGeneratorsAndUpdateCompilation(compilation, out outputCompilation, out diagnostics, cancellationToken); - } - - Assembly Emit(Compilation compilation, CancellationToken cancellationToken = default) - { - using var ms = new MemoryStream(); - var result = compilation.Emit(ms, cancellationToken: cancellationToken); - if (!result.Success) - { - foreach (var d in result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)) - TestContext.WriteLine(d.ToString()); - foreach (var t in compilation.SyntaxTrees) - TestContext.WriteLine($"// {t.FilePath}\n{t}"); - Assert.Fail("Compilation emit failed"); - } - ms.Seek(0, SeekOrigin.Begin); - -#if NET48 - return Assembly.Load(ms.ToArray()); -#else - var ctx = new System.Runtime.Loader.AssemblyLoadContext(nameof(FungeMethodGeneratorTests), isCollectible: true); - return ctx.LoadFromStream(ms); -#endif - } - - void AssertNoErrors(ImmutableArray diagnostics, Compilation compilation) - { - var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToArray(); - if (errors.Length > 0) - { - foreach (var d in errors) TestContext.WriteLine(d.ToString()); - foreach (var t in compilation.SyntaxTrees) TestContext.WriteLine($"// {t.FilePath}\n{t}"); - Assert.Fail($"{errors.Length} error(s) in generator output"); - } - } - - // ----------------------------------------------------------------------- - // Basic tests - // ----------------------------------------------------------------------- - - [TestMethod] - public void EmptyProgram_Void_NoErrors() - { - // "@" is the Funge "stop" instruction — program terminates immediately - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial void Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - Assert.AreEqual(4, comp.SyntaxTrees.Count()); // input.cs + attributes + helper + method - } - - [TestMethod] - public async Task HelloWorld_StringReturn() - { - // Classic Hello World in Funge-98 - const string helloWorld = - "64+\"!dlroW ,olleH\",,,,,,,,,,,,,@"; - - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("hello.b98")] - public static partial string Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("hello.b98", helloWorld)]); - AssertNoErrors(diag, comp); - - var asm = Emit(comp); - await Task.Factory.StartNew(() => - { - var t = asm.GetType("TestProject.TestClass")!; - var m = t.GetMethod("Run")!; - var result = (string?)m.Invoke(null, []); - Assert.AreEqual("Hello, World!", result); - }, TestContext.CancellationTokenSource.Token, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); - } - - [TestMethod] - public async Task StringMode_SgmlStyleSpaces_StringReturn() - { - const string program = "\" \"..@"; - - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("sgml.b98")] - public static partial string Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("sgml.b98", program)]); - AssertNoErrors(diag, comp); - - var asm = Emit(comp); - await Task.Factory.StartNew(() => - { - var t = asm.GetType("TestProject.TestClass")!; - var m = t.GetMethod("Run")!; - var result = (string?)m.Invoke(null, []); - Assert.AreEqual("32 0 ", result); - }, TestContext.CancellationTokenSource.Token, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); - } - - [TestMethod] - public async Task Iterate_K_ExecutesOperandCorrectly_StringReturn() - { - const string program = "2k6...@"; - - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("k.b98")] - public static partial string Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("k.b98", program)]); - AssertNoErrors(diag, comp); - - var asm = Emit(comp); - await Task.Factory.StartNew(() => - { - var t = asm.GetType("TestProject.TestClass")!; - var m = t.GetMethod("Run")!; - var result = (string?)m.Invoke(null, []); - Assert.AreEqual("6 6 6 ", result); - }, TestContext.CancellationTokenSource.Token, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); - } - - [TestMethod] - public void ReturnType_Void_TextWriter() - { - var source = """ - using Esolang.Funge; - using System.IO; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial void Run(TextWriter output); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - [TestMethod] - public void ReturnType_Task_NoErrors() - { - var source = """ - using Esolang.Funge; - using System.Threading.Tasks; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial Task Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - [TestMethod] - public void ReturnType_TaskString_NoErrors() - { - var source = """ - using Esolang.Funge; - using System.Threading.Tasks; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial Task Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - [TestMethod] - public void ReturnType_ValueTask_NoErrors() - { - var source = """ - using Esolang.Funge; - using System.Threading.Tasks; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial ValueTask Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - [TestMethod] - public void ReturnType_ValueTaskString_NoErrors() - { - var source = """ - using Esolang.Funge; - using System.Threading.Tasks; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial ValueTask Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - [TestMethod] - public void ReturnType_IEnumerableByte_NoErrors() - { - var source = """ - using Esolang.Funge; - using System.Collections.Generic; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial IEnumerable Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - [TestMethod] - public void ReturnType_IAsyncEnumerableByte_NoErrors() - { - var source = """ - using Esolang.Funge; - using System.Collections.Generic; - using System.Threading; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial IAsyncEnumerable Run(CancellationToken cancellationToken = default); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - [TestMethod] - public void Input_TextReader_NoErrors() - { - var source = """ - using Esolang.Funge; - using System.IO; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial void Run(TextReader input); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - [TestMethod] - public void Input_String_NoErrors() - { - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial void Run(string input); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "@")]); - AssertNoErrors(diag, comp); - } - - // ----------------------------------------------------------------------- - // Diagnostic tests - // ----------------------------------------------------------------------- - - [TestMethod] - public void Diagnostic_InvalidReturnType_FG0002() - { - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial int Run(); - } - """; - RunGenerators(source, out _, out var diag, - additionalFiles: [("test.b98", "@")]); - Assert.IsTrue(diag.Any(d => d.Id == "FG0002"), "Expected FG0002"); - } - - [TestMethod] - public void Diagnostic_SourceFileNotFound_FG0004() - { - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("nonexistent.b98")] - public static partial void Run(); - } - """; - RunGenerators(source, out _, out var diag); - Assert.IsTrue(diag.Any(d => d.Id == "FG0004"), "Expected FG0004"); - } - - [TestMethod] - public void Diagnostic_DuplicateInputParameter_FG0006() - { - var source = """ - using Esolang.Funge; - using System.IO; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial void Run(TextReader a, TextReader b); - } - """; - RunGenerators(source, out _, out var diag, - additionalFiles: [("test.b98", "@")]); - Assert.IsTrue(diag.Any(d => d.Id == "FG0006"), "Expected FG0006"); - } - - [TestMethod] - public void Diagnostic_ReturnOutputConflict_FG0007() - { - var source = """ - using Esolang.Funge; - using System.IO; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial string Run(TextWriter output); - } - """; - RunGenerators(source, out _, out var diag, - additionalFiles: [("test.b98", "@")]); - Assert.IsTrue(diag.Any(d => d.Id == "FG0007"), "Expected FG0007"); - } - - [TestMethod] - public void Runtime_SelfModifiedOutputWithoutOutputInterface_Throws() - { - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial void Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "68*2-s<<@")]); - AssertNoErrors(diag, comp); - - var asm = Emit(comp); - var t = asm.GetType("TestProject.TestClass") - ?? asm.GetType("TestClass"); - Assert.IsNotNull(t, "Failed to find generated type TestProject.TestClass."); - var m = t.GetMethod("Run", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance); - Assert.IsNotNull(m, "Failed to find generated method Run."); - - var ex = Assert.Throws(() => m!.Invoke(null, [])); - Assert.IsNotNull(ex); - Assert.IsNotNull(ex.InnerException); - Assert.IsInstanceOfType(ex.InnerException, typeof(InvalidOperationException)); - StringAssert.Contains(ex.InnerException!.Message, "without an output interface"); - } - - [TestMethod] - public void Runtime_SelfModifiedInputWithoutInputInterface_Throws() - { - var source = """ - using Esolang.Funge; - namespace TestProject; - partial class TestClass - { - [GenerateFungeMethod("test.b98")] - public static partial void Run(); - } - """; - RunGenerators(source, out var comp, out var diag, - additionalFiles: [("test.b98", "66*2+s<<@")]); - AssertNoErrors(diag, comp); - - var asm = Emit(comp); - var t = asm.GetType("TestProject.TestClass") - ?? asm.GetType("TestClass"); - Assert.IsNotNull(t, "Failed to find generated type TestProject.TestClass."); - var m = t.GetMethod("Run", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance); - Assert.IsNotNull(m, "Failed to find generated method Run."); - - var ex = Assert.Throws(() => m!.Invoke(null, [])); - Assert.IsNotNull(ex); - Assert.IsNotNull(ex.InnerException); - Assert.IsInstanceOfType(ex.InnerException, typeof(InvalidOperationException)); - StringAssert.Contains(ex.InnerException!.Message, "without an input interface"); - } -} - -/// Fake AdditionalText for testing. -file sealed class TestAdditionalText(string path, string content) : AdditionalText -{ - public override string Path { get; } = path; - public override SourceText? GetText(CancellationToken cancellationToken = default) - => SourceText.From(content, Encoding.UTF8); -} +using Basic.Reference.Assemblies; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; +using System.Reflection; +using System.Text; + +namespace Esolang.Funge.Generator.Tests; + +[TestClass] +public class FungeMethodGeneratorTests +{ + public TestContext TestContext { get; set; } = default!; + + Compilation baseCompilation = default!; + + [TestInitialize] + public void InitializeCompilation() + { + IEnumerable references = +#if NET10_0_OR_GREATER + Net100.References.All; +#elif NET9_0_OR_GREATER + Net90.References.All; +#elif NET8_0_OR_GREATER + Net80.References.All; +#elif NET472_OR_GREATER + Net472.References.All; +#else + throw new InvalidOperationException("Unsupported target framework for generator tests."); +#endif + + var referenceList = references.ToList(); + { + var hasPipelinesReference = referenceList.Any(static r => + string.Equals(Path.GetFileNameWithoutExtension(r.FilePath), "System.IO.Pipelines", StringComparison.OrdinalIgnoreCase)); + if (!hasPipelinesReference) + { + var pipelinesAssemblyLocation = typeof(System.IO.Pipelines.PipeReader).Assembly.Location; + if (!string.IsNullOrWhiteSpace(pipelinesAssemblyLocation)) + { + referenceList.Add(MetadataReference.CreateFromFile(pipelinesAssemblyLocation)); + } + } + } +#if !NET + { + var memoryAssemblyLocation = typeof(Memory<>).Assembly.Location; + if (!string.IsNullOrWhiteSpace(memoryAssemblyLocation)) + { + referenceList.Add(MetadataReference.CreateFromFile(memoryAssemblyLocation)); + } + } + { + var asm = typeof(ValueTask).Assembly.Location; + referenceList.Add(MetadataReference.CreateFromFile(asm)); + } + { + var asm = typeof(IAsyncEnumerable<>).Assembly.Location; + referenceList.Add(MetadataReference.CreateFromFile(asm)); + } +#endif + + baseCompilation = CSharpCompilation.Create("generatortest", + references: referenceList, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + CancellationToken TestCancellationToken => TestContext.CancellationTokenSource.Token; + + GeneratorDriver RunGenerators( + string source, + out Compilation outputCompilation, + out ImmutableArray diagnostics, + IEnumerable<(string path, string content)>? additionalFiles = null, + LanguageVersion languageVersion = LanguageVersion.CSharp12) + => RunGenerators(source, out outputCompilation, out diagnostics, TestCancellationToken, additionalFiles, languageVersion); + + GeneratorDriver RunGenerators( + string source, + out Compilation outputCompilation, + out ImmutableArray diagnostics, + CancellationToken cancellationToken, + IEnumerable<(string path, string content)>? additionalFiles = null, + LanguageVersion languageVersion = LanguageVersion.CSharp12) + { + var parseOptions = new CSharpParseOptions(languageVersion); + var generator = new MethodGenerator(); + var driver = CSharpGeneratorDriver.Create( + generators: [generator.AsSourceGenerator()], + additionalTexts: additionalFiles?.Select(f => + (AdditionalText)new TestAdditionalText(f.path, f.content)) ?? [], + driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true) + ).WithUpdatedParseOptions(parseOptions); + + var compilation = baseCompilation.AddSyntaxTrees( + CSharpSyntaxTree.ParseText(source, parseOptions, path: "input.cs", + encoding: Encoding.UTF8, cancellationToken: cancellationToken)); + + return driver.RunGeneratorsAndUpdateCompilation(compilation, out outputCompilation, out diagnostics, cancellationToken); + } + + Assembly Emit(Compilation compilation) + => Emit(compilation, TestCancellationToken); + + Assembly Emit(Compilation compilation, CancellationToken cancellationToken) + { + using var ms = new MemoryStream(); + var result = compilation.Emit(ms, cancellationToken: cancellationToken); + if (!result.Success) + { + foreach (var d in result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)) + TestContext.WriteLine(d.ToString()); + foreach (var t in compilation.SyntaxTrees) + TestContext.WriteLine($"// {t.FilePath}\n{t}"); + Assert.Fail("Compilation emit failed"); + } + ms.Seek(0, SeekOrigin.Begin); + +#if NET48 + return Assembly.Load(ms.ToArray()); +#else + var ctx = new System.Runtime.Loader.AssemblyLoadContext(nameof(FungeMethodGeneratorTests), isCollectible: true); + return ctx.LoadFromStream(ms); +#endif + } + + void AssertNoErrors(ImmutableArray diagnostics, Compilation compilation) + { + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToArray(); + if (errors.Length > 0) + { + foreach (var d in errors) TestContext.WriteLine(d.ToString()); + foreach (var t in compilation.SyntaxTrees) TestContext.WriteLine($"// {t.FilePath}\n{t}"); + Assert.Fail($"{errors.Length} error(s) in generator output"); + } + } + + // ----------------------------------------------------------------------- + // Basic tests + // ----------------------------------------------------------------------- + + [TestMethod] + public void EmptyProgram_Void_NoErrors() + { + // "@" is the Funge "stop" instruction — program terminates immediately + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial void Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + Assert.AreEqual(4, comp.SyntaxTrees.Count()); // input.cs + attributes + helper + method + } + + [TestMethod] + public async Task HelloWorld_StringReturn() + { + // Classic Hello World in Funge-98 + const string helloWorld = + "64+\"!dlroW ,olleH\",,,,,,,,,,,,,@"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("hello.b98")] + public static partial string Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("hello.b98", helloWorld)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (string?)m.Invoke(null, []); + Assert.AreEqual("Hello, World!", result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task StringMode_SgmlStyleSpaces_StringReturn() + { + const string program = "\" \"..@"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("sgml.b98")] + public static partial string Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("sgml.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (string?)m.Invoke(null, []); + Assert.AreEqual("32 0 ", result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Iterate_K_ExecutesOperandCorrectly_StringReturn() + { + const string program = "2k6...@"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("k.b98")] + public static partial string Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("k.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (string?)m.Invoke(null, []); + Assert.AreEqual("6 6 6 ", result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public void ReturnType_Void_TextWriter() + { + var source = """ + using Esolang.Funge; + using System.IO; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial void Run(TextWriter output); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_Task_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial Task Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_Int_NoErrors() + { + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_TaskInt_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial Task Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_ValueTaskInt_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial ValueTask Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_TaskString_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial Task Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_ValueTask_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial ValueTask Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_ValueTaskString_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial ValueTask Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_IEnumerableByte_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.Collections.Generic; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial IEnumerable Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void ReturnType_IAsyncEnumerableByte_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.Collections.Generic; + using System.Threading; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial IAsyncEnumerable Run(CancellationToken cancellationToken = default); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void Input_TextReader_NoErrors() + { + var source = """ + using Esolang.Funge; + using System.IO; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial void Run(TextReader input); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void Input_String_NoErrors() + { + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial void Run(string input); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + } + + [TestMethod] + public void RuntimeTemplate_NullabilityWarnings_NotEmitted() + { + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial void Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "@")]); + AssertNoErrors(diag, comp); + + var options = (CSharpCompilationOptions)comp.Options; + var strict = comp.WithOptions(options.WithSpecificDiagnosticOptions( + options.SpecificDiagnosticOptions + .SetItem("CS8602", ReportDiagnostic.Error) + .SetItem("CS8603", ReportDiagnostic.Error))); + + var runtimeNullabilityErrors = strict + .GetDiagnostics(TestCancellationToken) + .Where(static d => d.Severity == DiagnosticSeverity.Error) + .Where(static d => d.Id is "CS8602" or "CS8603") + .Where(static d => d.Location.SourceTree?.FilePath.Contains("FungeRuntime.g.cs", StringComparison.OrdinalIgnoreCase) == true) + .ToArray(); + + if (runtimeNullabilityErrors.Length > 0) + { + foreach (var d in runtimeNullabilityErrors) + TestContext.WriteLine(d.ToString()); + } + + Assert.AreEqual(0, runtimeNullabilityErrors.Length, "FungeRuntime.g.cs must not produce CS8602/CS8603."); + } + + // ----------------------------------------------------------------------- + // Diagnostic tests + // ----------------------------------------------------------------------- + + [TestMethod] + public void Diagnostic_InvalidReturnType_FG0002() + { + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial double Run(); + } + """; + RunGenerators(source, out _, out var diag, + additionalFiles: [("test.b98", "@")]); + Assert.IsTrue(diag.Any(d => d.Id == "FG0002"), "Expected FG0002"); + } + + [TestMethod] + public async Task Runtime_ExitCode_IntReturn_QReturnsStackTop() + { + const string program = "5q@"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("exit-code.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("exit-code.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(5, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_ExitCode_IntReturn_AtReturnsZero() + { + const string program = "@"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("exit-code-zero.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("exit-code-zero.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(0, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_3D_GoLow_ExitCode() + { + const string program = "l\f>7q"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("go-low.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("go-low.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(7, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_3D_GoHigh_ExitCode() + { + const string program = "h\f\f>7q"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("go-high.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("go-high.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(7, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_3D_HighLowIf_SelectsDirection() + { + const string programLow = "0m\f >1q\f >2q"; + const string programHigh = "1m\f >1q\f >2q"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("select-low.b98")] + public static partial int RunLow(); + + [GenerateFungeMethod("select-high.b98")] + public static partial int RunHigh(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("select-low.b98", programLow), ("select-high.b98", programHigh)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var mLow = t.GetMethod("RunLow")!; + var mHigh = t.GetMethod("RunHigh")!; + var low = (int?)mLow.Invoke(null, []); + var high = (int?)mHigh.Invoke(null, []); + Assert.AreEqual(1, low); + Assert.AreEqual(2, high); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_3D_GetPut_UsesXYZ() + { + const string program = "88*1+500p500gq"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("getput-3d.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("getput-3d.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(65, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_StorageOffset_AppliesToGetPut() + { + const string program = "0{88*1+000p000gq"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("offset-getput.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("offset-getput.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(65, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_StackStack_U_TransfersFromSoss() + { + const string program = "120{4u.@"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("stack-u.b98")] + public static partial string Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("stack-u.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (string?)m.Invoke(null, []); + Assert.AreEqual("2 ", result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_SystemInfo_FlagsIncludesConcurrentFileExec() + { + const string program = "1yq"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("sysinfo-flags.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("sysinfo-flags.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(15, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_FileInput_LoadsIntoSpace() + { + var originalDir = Directory.GetCurrentDirectory(); + var tempDir = Path.Combine(Path.GetTempPath(), $"funge-gen-io-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + Directory.SetCurrentDirectory(tempDir); + File.WriteAllText("input.txt", "A"); + + var reversed = new string("input.txt".Reverse().ToArray()); + var program = $"00000\"{reversed}\"in000gq"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("file-in.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("file-in.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(65, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + Directory.Delete(tempDir, recursive: true); + } + } + + [TestMethod] + public async Task Runtime_FileOutput_WritesRegion() + { + var originalDir = Directory.GetCurrentDirectory(); + var tempDir = Path.Combine(Path.GetTempPath(), $"funge-gen-io-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + Directory.SetCurrentDirectory(tempDir); + + var reversed = new string("output.txt".Reverse().ToArray()); + var program = $"88*1+000p00000000\"{reversed}\"o@"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("file-out.b98")] + public static partial void Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("file-out.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + _ = m.Invoke(null, []); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + + var bytes = File.ReadAllBytes(Path.Combine(tempDir, "output.txt")); + CollectionAssert.AreEqual(new byte[] { 65 }, bytes); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + Directory.Delete(tempDir, recursive: true); + } + } + + [TestMethod] + public async Task Runtime_SystemExec_ReturnsExitCode() + { + const string command = "exit 7"; + var reversed = new string(command.Reverse().ToArray()); + var program = $"0\"{reversed}\"=q"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("system-exec.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("system-exec.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreEqual(7, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public async Task Runtime_SystemExec_FailureIsNonZero() + { + const string command = "this_command_should_not_exist_12345"; + var reversed = new string(command.Reverse().ToArray()); + var program = $"0\"{reversed}\"=q"; + + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("system-exec-fail.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("system-exec-fail.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + await Task.Factory.StartNew(() => + { + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var result = (int?)m.Invoke(null, []); + Assert.AreNotEqual(0, result); + }, TestCancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + [TestMethod] + public void Generated_TaskReturn_UsesTaskRuntimeFacade() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("task-facade.b98")] + public static partial Task Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("task-facade.b98", "@")]); + AssertNoErrors(diag, comp); + + var generated = comp.SyntaxTrees + .Select(static t => t.ToString()) + .Single(static text => text.Contains("Generated from: task-facade.b98", StringComparison.Ordinal)); + Assert.Contains("FungeRuntime.RunTask(", generated); + } + + [TestMethod] + public void Generated_ValueTaskStringReturn_UsesValueTaskRuntimeFacade() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("valuetask-facade.b98")] + public static partial ValueTask Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("valuetask-facade.b98", "@")]); + AssertNoErrors(diag, comp); + + var generated = comp.SyntaxTrees + .Select(static t => t.ToString()) + .Single(static text => text.Contains("Generated from: valuetask-facade.b98", StringComparison.Ordinal)); + Assert.Contains("FungeRuntime.RunValueTaskString(", generated); + } + + [TestMethod] + public async Task Runtime_AsyncEnumerableByte_ReturnsOutputBytes() + { + const string program = "\"A\",@"; + + var source = """ + using Esolang.Funge; + using System.Collections.Generic; + using System.Threading; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("async-enumerable.b98")] + public static partial IAsyncEnumerable Run(CancellationToken cancellationToken = default); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("async-enumerable.b98", program)]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + var stream = (IAsyncEnumerable?)m.Invoke(null, [TestCancellationToken]); + Assert.IsNotNull(stream); + + var bytes = new List(); + var enumerator = stream!.GetAsyncEnumerator(TestCancellationToken); + try + { + while (await enumerator.MoveNextAsync()) + bytes.Add(enumerator.Current); + } + finally + { + await enumerator.DisposeAsync(); + } + + CollectionAssert.AreEqual(new byte[] { (byte)'A' }, bytes); + } + + [TestMethod] + public void Generated_SyncWithCancellationToken_UsesRunSyncWithToken() + { + var source = """ + using Esolang.Funge; + using System.Threading; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("sync-token.b98")] + public static partial int Run(CancellationToken cancellationToken = default); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("sync-token.b98", "@")]); + AssertNoErrors(diag, comp); + + var generated = comp.SyntaxTrees + .Select(static t => t.ToString()) + .Single(static text => text.Contains("Generated from: sync-token.b98", StringComparison.Ordinal)); + Assert.Contains("FungeRuntime.RunSync(", generated); + Assert.Contains("cancellationToken", generated); + } + + [TestMethod] + public void Generated_Runtime_EmitsOnlyRequiredFacades_ForIntReturn() + { + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("minimal-int.b98")] + public static partial int Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("minimal-int.b98", "@")]); + AssertNoErrors(diag, comp); + + var runtime = comp.SyntaxTrees + .Select(static t => t.ToString()) + .Single(static text => text.Contains("internal static class FungeRuntime", StringComparison.Ordinal)); + + Assert.Contains("internal static int RunSync(", runtime); + Assert.IsFalse(runtime.Contains("internal static Task RunTask(", StringComparison.Ordinal)); + Assert.IsFalse(runtime.Contains("internal static ValueTask RunValueTaskString(", StringComparison.Ordinal)); + Assert.IsFalse(runtime.Contains("internal static async IAsyncEnumerable RunAsyncEnumerable(", StringComparison.Ordinal)); + } + + [TestMethod] + public void Generated_Runtime_EmitsOnlyRequiredFacades_ForValueTaskString() + { + var source = """ + using Esolang.Funge; + using System.Threading.Tasks; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("minimal-vts.b98")] + public static partial ValueTask Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("minimal-vts.b98", "@")]); + AssertNoErrors(diag, comp); + + var runtime = comp.SyntaxTrees + .Select(static t => t.ToString()) + .Single(static text => text.Contains("internal static class FungeRuntime", StringComparison.Ordinal)); + + Assert.Contains("internal static ValueTask RunValueTaskString(", runtime); + Assert.IsFalse(runtime.Contains("internal static int RunSync(", StringComparison.Ordinal)); + Assert.IsFalse(runtime.Contains("internal static Task RunTaskInt(", StringComparison.Ordinal)); + Assert.IsFalse(runtime.Contains("internal static IEnumerable RunEnumerable(", StringComparison.Ordinal)); + } + + [TestMethod] + public void Runtime_SyncCancellationToken_CancelsInfiniteLoop() + { + var source = """ + using Esolang.Funge; + using System.Threading; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("infinite-loop.b98")] + public static partial int Run(CancellationToken cancellationToken = default); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("infinite-loop.b98", ">")]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + var t = asm.GetType("TestProject.TestClass")!; + var m = t.GetMethod("Run")!; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = Assert.Throws(() => m.Invoke(null, [cts.Token])); + Assert.IsNotNull(ex?.InnerException); + Assert.IsInstanceOfType(ex!.InnerException, typeof(OperationCanceledException)); + } + + [TestMethod] + public void Diagnostic_SourceFileNotFound_FG0004() + { + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("nonexistent.b98")] + public static partial void Run(); + } + """; + RunGenerators(source, out _, out var diag); + Assert.IsTrue(diag.Any(d => d.Id == "FG0004"), "Expected FG0004"); + } + + [TestMethod] + public void Diagnostic_DuplicateInputParameter_FG0006() + { + var source = """ + using Esolang.Funge; + using System.IO; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial void Run(TextReader a, TextReader b); + } + """; + RunGenerators(source, out _, out var diag, + additionalFiles: [("test.b98", "@")]); + Assert.IsTrue(diag.Any(d => d.Id == "FG0006"), "Expected FG0006"); + } + + [TestMethod] + public void Diagnostic_ReturnOutputConflict_FG0007() + { + var source = """ + using Esolang.Funge; + using System.IO; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial string Run(TextWriter output); + } + """; + RunGenerators(source, out _, out var diag, + additionalFiles: [("test.b98", "@")]); + Assert.IsTrue(diag.Any(d => d.Id == "FG0007"), "Expected FG0007"); + } + + [TestMethod] + public void Runtime_SelfModifiedOutputWithoutOutputInterface_Throws() + { + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial void Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "68*2-s<<@")]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + var t = asm.GetType("TestProject.TestClass") + ?? asm.GetType("TestClass"); + Assert.IsNotNull(t, "Failed to find generated type TestProject.TestClass."); + var m = t.GetMethod("Run", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance); + Assert.IsNotNull(m, "Failed to find generated method Run."); + + var ex = Assert.Throws(() => m!.Invoke(null, [])); + Assert.IsNotNull(ex); + Assert.IsNotNull(ex.InnerException); + Assert.IsInstanceOfType(ex.InnerException, typeof(InvalidOperationException)); + Assert.Contains("without an output interface", ex.InnerException!.Message); + } + + [TestMethod] + public void Runtime_SelfModifiedInputWithoutInputInterface_Throws() + { + var source = """ + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod("test.b98")] + public static partial void Run(); + } + """; + RunGenerators(source, out var comp, out var diag, + additionalFiles: [("test.b98", "66*2+s<<@")]); + AssertNoErrors(diag, comp); + + var asm = Emit(comp, TestCancellationToken); + var t = asm.GetType("TestProject.TestClass") + ?? asm.GetType("TestClass"); + Assert.IsNotNull(t, "Failed to find generated type TestProject.TestClass."); + var m = t.GetMethod("Run", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance); + Assert.IsNotNull(m, "Failed to find generated method Run."); + + var ex = Assert.Throws(() => m!.Invoke(null, [])); + Assert.IsNotNull(ex); + Assert.IsNotNull(ex.InnerException); + Assert.IsInstanceOfType(ex.InnerException, typeof(InvalidOperationException)); + Assert.Contains("without an input interface", ex.InnerException!.Message); + } + + // ----------------------------------------------------------------------- + // InlineSource with multiple lines + // ----------------------------------------------------------------------- + + [TestMethod] + public void InlineSource_RawStringWithInnerQuotes_InspectGenerated() + { + // Inspect how the generator processes raw string literals in InlineSource + // This test outputs the generated code to verify the processing + var source = """" + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod(InlineSource = """ + @ + """)] + public static partial void Run(); + } + """"; + RunGenerators(source, out var comp, out var diag); + AssertNoErrors(diag, comp); + + var generated = comp.SyntaxTrees + .Select(static t => t.ToString()) + .Single(static text => text.Contains("Generated from: ", StringComparison.Ordinal)); + Assert.Contains("__cells[(0, 0, 0)] = 64;", generated); + + // Output all generated syntax trees for inspection + TestContext.WriteLine("=== Generated Syntax Trees ==="); + foreach (var tree in comp.SyntaxTrees) + { + TestContext.WriteLine($"\n--- {tree.FilePath} ---"); + TestContext.WriteLine(tree.GetText().ToString()); + } + } + + [TestMethod] + public void InlineSource_MultiLine_BasicProgram() + { + // Verify multiline raw string is mapped to X/Y at Z=0 as expected. + var source = """" + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod(InlineSource = """ + >v + ^@ + """)] + public static partial void Run(); + } + """"; + RunGenerators(source, out var comp, out var diag); + AssertNoErrors(diag, comp); + + var generated = comp.SyntaxTrees + .Select(static t => t.ToString()) + .Single(static text => text.Contains("Generated from: ", StringComparison.Ordinal)); + Assert.Contains("__cells[(0, 0, 0)] = 62;", generated); // '>' + Assert.Contains("__cells[(1, 0, 0)] = 118;", generated); // 'v' + Assert.Contains("__cells[(0, 1, 0)] = 94;", generated); // '^' + Assert.Contains("__cells[(1, 1, 0)] = 64;", generated); // '@' + } + + [TestMethod] + public void InlineSource_WithEscapedNewlines() + { + // Test InlineSource with escaped newlines (\n) instead of literal raw strings + // This avoids indentation issues with raw string literals + var source = """" + using Esolang.Funge; + namespace TestProject; + partial class TestClass + { + [GenerateFungeMethod(InlineSource = "2:.@")] + public static partial void Run(); + } + """"; + RunGenerators(source, out var comp, out var diag); + AssertNoErrors(diag, comp); + } +} + +/// Fake AdditionalText for testing. +file sealed class TestAdditionalText(string path, string content) : AdditionalText +{ + public override string Path { get; } = path; + public override SourceText? GetText(CancellationToken cancellationToken = default) + => SourceText.From(content, Encoding.UTF8); +} + + diff --git a/Generator/DiagnosticDescriptors.cs b/Generator/DiagnosticDescriptors.cs index 501bfde..81ffe09 100644 --- a/Generator/DiagnosticDescriptors.cs +++ b/Generator/DiagnosticDescriptors.cs @@ -1,121 +1,121 @@ -using Microsoft.CodeAnalysis; - -namespace Esolang.Funge.Generator; - -/// -/// Provides diagnostic definitions reported during source generation. -/// -public static class DiagnosticDescriptors -{ - const string Category = "Funge"; - - /// - /// FG0001: Invalid source path parameter. - /// - public static readonly DiagnosticDescriptor InvalidSourcePathParameter = new( - id: "FG0001", - title: "Invalid source path parameter", - messageFormat: "The source path parameter of the attribute on the method '{0}' must not be null or empty", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); - - /// - /// FG0002: Unsupported return type. - /// - public static readonly DiagnosticDescriptor InvalidReturnType = new( - id: "FG0002", - title: "Unsupported return type", - messageFormat: "The method return type '{0}' is not supported for Funge code generation", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); - - /// - /// FG0003: Unsupported parameter type. - /// - public static readonly DiagnosticDescriptor InvalidParameter = new( - id: "FG0003", - title: "Unsupported parameter type", - messageFormat: "The parameter '{0}' of the method has an unsupported type", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); - - /// - /// FG0004: Source file not found. - /// - public static readonly DiagnosticDescriptor SourceFileNotFound = new( - id: "FG0004", - title: "Funge source file not found", - messageFormat: "The Funge source file '{0}' could not be found in AdditionalFiles", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); - - /// - /// FG0005: Consumer language version is below C# 8.0. - /// - public static readonly DiagnosticDescriptor LanguageVersionTooLow = new( - id: "FG0005", - title: "Language version too low", - messageFormat: "Funge source generation requires C# 8.0 or later (current: {0})", - category: Category, - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - /// - /// FG0006: Duplicate parameter type. - /// - public static readonly DiagnosticDescriptor DuplicateParameter = new( - id: "FG0006", - title: "Duplicate parameter type", - messageFormat: "The parameter type '{0}' appears more than once in method '{1}'", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); - - /// - /// FG0007: Return type and output parameter conflict. - /// - public static readonly DiagnosticDescriptor ReturnOutputConflict = new( - id: "FG0007", - title: "Return type and output parameter conflict", - messageFormat: "Method '{0}' has both a non-void return type and an output parameter (TextWriter/PipeWriter); use one or the other", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); - - /// - /// FG0008: Output interface required. - /// - public static readonly DiagnosticDescriptor RequiredOutputInterface = new( - id: "FG0008", - title: "Output interface required", - messageFormat: "Method '{0}' uses Funge output instructions but has no output (return string/IEnumerable<byte> or a TextWriter/PipeWriter parameter)", - category: Category, - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true); - - /// - /// FG0009: Input interface required. - /// - public static readonly DiagnosticDescriptor RequiredInputInterface = new( - id: "FG0009", - title: "Input interface required", - messageFormat: "Method '{0}' uses Funge input instructions but has no input (string, TextReader, or PipeReader parameter)", - category: Category, - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true); - - /// - /// FG0010: Unused input interface. - /// - public static readonly DiagnosticDescriptor UnusedInputInterface = new( - id: "FG0010", - title: "Unused input interface", - messageFormat: "Method '{0}' has an input parameter but the Funge source does not use any input instructions", - category: Category, - defaultSeverity: DiagnosticSeverity.Hidden, - isEnabledByDefault: true); -} +using Microsoft.CodeAnalysis; + +namespace Esolang.Funge.Generator; + +/// +/// Provides diagnostic definitions reported during source generation. +/// +public static class DiagnosticDescriptors +{ + const string Category = "Funge"; + + /// + /// FG0001: Invalid source path parameter. + /// + public static readonly DiagnosticDescriptor InvalidSourcePathParameter = new( + id: "FG0001", + title: "Invalid source path parameter", + messageFormat: "The source path parameter of the attribute on the method '{0}' must not be null or empty", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// FG0002: Unsupported return type. + /// + public static readonly DiagnosticDescriptor InvalidReturnType = new( + id: "FG0002", + title: "Unsupported return type", + messageFormat: "The method return type '{0}' is not supported for Funge code generation", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// FG0003: Unsupported parameter type. + /// + public static readonly DiagnosticDescriptor InvalidParameter = new( + id: "FG0003", + title: "Unsupported parameter type", + messageFormat: "The parameter '{0}' of the method has an unsupported type", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// FG0004: Source file not found. + /// + public static readonly DiagnosticDescriptor SourceFileNotFound = new( + id: "FG0004", + title: "Funge source file not found", + messageFormat: "The Funge source file '{0}' could not be found in AdditionalFiles", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// FG0005: Consumer language version is below C# 8.0. + /// + public static readonly DiagnosticDescriptor LanguageVersionTooLow = new( + id: "FG0005", + title: "Language version too low", + messageFormat: "Funge source generation requires C# 8.0 or later (current: {0})", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// FG0006: Duplicate parameter type. + /// + public static readonly DiagnosticDescriptor DuplicateParameter = new( + id: "FG0006", + title: "Duplicate parameter type", + messageFormat: "The parameter type '{0}' appears more than once in method '{1}'", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// FG0007: Return type and output parameter conflict. + /// + public static readonly DiagnosticDescriptor ReturnOutputConflict = new( + id: "FG0007", + title: "Return type and output parameter conflict", + messageFormat: "Method '{0}' has both a non-void return type and an output parameter (TextWriter/PipeWriter); use one or the other", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// FG0008: Output interface required. + /// + public static readonly DiagnosticDescriptor RequiredOutputInterface = new( + id: "FG0008", + title: "Output interface required", + messageFormat: "Method '{0}' uses Funge output instructions but has no output (return string/IEnumerable<byte> or a TextWriter/PipeWriter parameter)", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + + /// + /// FG0009: Input interface required. + /// + public static readonly DiagnosticDescriptor RequiredInputInterface = new( + id: "FG0009", + title: "Input interface required", + messageFormat: "Method '{0}' uses Funge input instructions but has no input (string, TextReader, or PipeReader parameter)", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + + /// + /// FG0010: Unused input interface. + /// + public static readonly DiagnosticDescriptor UnusedInputInterface = new( + id: "FG0010", + title: "Unused input interface", + messageFormat: "Method '{0}' has an input parameter but the Funge source does not use any input instructions", + category: Category, + defaultSeverity: DiagnosticSeverity.Hidden, + isEnabledByDefault: true); +} diff --git a/Generator/MethodGenerator.Runtime.cs b/Generator/MethodGenerator.Runtime.cs index 8155765..bcd25da 100644 --- a/Generator/MethodGenerator.Runtime.cs +++ b/Generator/MethodGenerator.Runtime.cs @@ -1,216 +1,1224 @@ -using System.Text; - -namespace Esolang.Funge.Generator; - -partial class MethodGenerator -{ - const string FungeRuntimeFileName = "FungeRuntime.g.cs"; - - static void EmitRuntimeIfNeeded(Microsoft.CodeAnalysis.SourceProductionContext ctx, bool needed) - { - if (!needed) return; - ctx.AddSource(FungeRuntimeFileName, BuildRuntimeSource()); - } - - static string BuildRuntimeSource() => """ - // - #nullable enable - #pragma warning disable CS1591 - using System; - using System.Collections.Generic; - using System.IO; - - namespace Esolang.Funge.__Generated - { - internal static class FungeRuntime - { - internal static void Run( - Dictionary<(int, int), int> cells, - int minX, int minY, int maxX, int maxY, - TextReader input, - TextWriter output, - bool hasInput, - bool hasOutput) - { - int px = 0, py = 0, dx = 1, dy = 0; - bool stringMode = false; - var stack = new List(); - var rng = new Random(); - - int GetCell(int x, int y) => cells.TryGetValue((x, y), out var v) ? v : ' '; - void SetCell(int x, int y, int val) - { - if (val == ' ') cells.Remove((x, y)); - else cells[(x, y)] = val; - } - int Pop() { if (stack.Count == 0) return 0; var v = stack[stack.Count - 1]; stack.RemoveAt(stack.Count - 1); return v; } - void Push(int v) => stack.Add(v); - - (int nx, int ny) Advance(int x, int y, int ddx, int ddy) - { - if (maxX < minX) return (x, y); - int nx = x + ddx, ny = y + ddy; - int w = maxX - minX + 1, h = maxY - minY + 1; - if (nx < minX) nx = maxX - ((minX - nx - 1) % w); - else if (nx > maxX) nx = minX + ((nx - maxX - 1) % w); - if (ny < minY) ny = maxY - ((minY - ny - 1) % h); - else if (ny > maxY) ny = minY + ((ny - maxY - 1) % h); - return (nx, ny); - } - - bool IsSgmlSpace(int c) => c is ' ' or '\t' or '\f' or '\v'; - - bool stopped = false; - - void ExecuteInstruction(int cell, ref bool suppressAdvance) - { - switch (cell) - { - case ' ': case '\t': case '\f': case '\v': case 'z': break; - case '!': Push(Pop() == 0 ? 1 : 0); break; - case '$': Pop(); break; - case ':': { int v = Pop(); Push(v); Push(v); break; } - case '\\': { int b = Pop(), a = Pop(); Push(b); Push(a); break; } - case 'n': stack.Clear(); break; - case '+': { int b = Pop(), a = Pop(); Push(a + b); break; } - case '-': { int b = Pop(), a = Pop(); Push(a - b); break; } - case '*': { int b = Pop(), a = Pop(); Push(a * b); break; } - case '/': { int b = Pop(), a = Pop(); Push(b == 0 ? 0 : a / b); break; } - case '%': { int b = Pop(), a = Pop(); Push(b == 0 ? 0 : a % b); break; } - case '`': { int b = Pop(), a = Pop(); Push(a > b ? 1 : 0); break; } - case '0': case '1': case '2': case '3': case '4': - case '5': case '6': case '7': case '8': case '9': Push(cell - '0'); break; - case 'a': Push(10); break; case 'b': Push(11); break; case 'c': Push(12); break; - case 'd': Push(13); break; case 'e': Push(14); break; case 'f': Push(15); break; - case '>': dx = 1; dy = 0; break; - case '<': dx = -1; dy = 0; break; - case '^': dx = 0; dy = -1; break; - case 'v': dx = 0; dy = 1; break; - case '?': - switch (rng.Next(4)) { case 0: dx=1;dy=0;break; case 1:dx=-1;dy=0;break; case 2:dx=0;dy=-1;break; default:dx=0;dy=1;break; } - break; - case '_': { int v = Pop(); dx = v == 0 ? 1 : -1; dy = 0; break; } - case '|': { int v = Pop(); dy = v == 0 ? 1 : -1; dx = 0; break; } - case '[': { int ndx = dy, ndy = -dx; dx = ndx; dy = ndy; break; } - case ']': { int ndx = -dy, ndy = dx; dx = ndx; dy = ndy; break; } - case 'r': dx = -dx; dy = -dy; break; - case 'x': { int ndy = Pop(), ndx = Pop(); dx = ndx; dy = ndy; break; } - case 'w': { int b = Pop(), a = Pop(); if(a>b){int ndx=-dy,ndy=dx;dx=ndx;dy=ndy;}else if(a= 0 ? dx : -dx, jdy = s >= 0 ? dy : -dy, abs = s < 0 ? -s : s; - for (int i = 0; i < abs; i++) (px, py) = Advance(px, py, jdx, jdy); - suppressAdvance = true; break; - } - case ';': - (px, py) = Advance(px, py, dx, dy); - while (GetCell(px, py) != ';') (px, py) = Advance(px, py, dx, dy); - break; - case '\'': (px, py) = Advance(px, py, dx, dy); Push(GetCell(px, py)); break; - case 's': { int sv = Pop(); (px, py) = Advance(px, py, dx, dy); SetCell(px, py, sv); break; } - case '"': stringMode = true; break; - case 'g': { int gy = Pop(), gx = Pop(); Push(GetCell(gx, gy)); break; } - case 'p': { int gy = Pop(), gx = Pop(), pv = Pop(); SetCell(gx, gy, pv); break; } - case '.': - if (!hasOutput) throw new InvalidOperationException("Funge output instruction '.' executed without an output interface."); - output.Write(Pop()); output.Write(' '); break; - case ',': - if (!hasOutput) throw new InvalidOperationException("Funge output instruction ',' executed without an output interface."); - output.Write((char)Pop()); break; - case '&': - { - if (!hasInput) throw new InvalidOperationException("Funge input instruction '&' executed without an input interface."); - var line = input.ReadLine(); if(line==null){dx=-dx;dy=-dy;}else Push(int.TryParse(line.Trim(),out int iv)?iv:0); break; - } - case '~': - { - if (!hasInput) throw new InvalidOperationException("Funge input instruction '~' executed without an input interface."); - int ch = input.Read(); if(ch<0){dx=-dx;dy=-dy;}else Push(ch); break; - } - case 'k': - { - int n = Pop(); - var (ix, iy) = Advance(px, py, dx, dy); - while (true) - { - int c = GetCell(ix, iy); - if (IsSgmlSpace(c)) - { - (ix, iy) = Advance(ix, iy, dx, dy); - } - else if (c == ';') - { - (ix, iy) = Advance(ix, iy, dx, dy); - while (GetCell(ix, iy) != ';') (ix, iy) = Advance(ix, iy, dx, dy); - (ix, iy) = Advance(ix, iy, dx, dy); - } - else - { - break; - } - } - - if (n == 0) - { - (px, py) = (ix, iy); - } - else - { - int operand = GetCell(ix, iy); - for (int i = 0; i < n && !stopped; i++) - { - bool dummy = false; - ExecuteInstruction(operand, ref dummy); - } - } - break; - } - case '@': stopped = true; break; - case 'q': stopped = true; break; - default: if (cell >= 'A' && cell <= 'Z') { dx = -dx; dy = -dy; } break; - } - } - - while (!stopped) - { - int cell = GetCell(px, py); - if (stringMode) - { - if (cell == '"') - { - stringMode = false; - } - else if (IsSgmlSpace(cell)) - { - Push(' '); - while (true) - { - var (nx, ny) = Advance(px, py, dx, dy); - if (IsSgmlSpace(GetCell(nx, ny))) - { - (px, py) = (nx, ny); - } - else - { - break; - } - } - } - else - { - Push(cell); - } - (px, py) = Advance(px, py, dx, dy); - continue; - } - - bool suppressAdvance = false; - ExecuteInstruction(cell, ref suppressAdvance); - if (!stopped && !suppressAdvance) (px, py) = Advance(px, py, dx, dy); - } - } - } - } - """; -} +using System.Text; + +namespace Esolang.Funge.Generator; + +partial class MethodGenerator +{ + const string FungeRuntimeFileName = "FungeRuntime.g.cs"; + + [System.Flags] + enum RuntimeFacadeFeatures + { + None = 0, + RunSync = 1 << 0, + RunString = 1 << 1, + RunEnumerable = 1 << 2, + RunAsyncEnumerable = 1 << 3, + RunTask = 1 << 4, + RunTaskInt = 1 << 5, + RunTaskString = 1 << 6, + RunValueTask = 1 << 7, + RunValueTaskInt = 1 << 8, + RunValueTaskString = 1 << 9, + } + + static void EmitRuntimeIfNeeded(Microsoft.CodeAnalysis.SourceProductionContext ctx, RuntimeFacadeFeatures features) + { + if (features == RuntimeFacadeFeatures.None) return; + ctx.AddSource(FungeRuntimeFileName, BuildRuntimeSource(features)); + } + + static string BuildRuntimeFacadeMethods(RuntimeFacadeFeatures features) + { + var sb = new StringBuilder(); + + if ((features & RuntimeFacadeFeatures.RunSync) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static int RunSync( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + TextWriter output, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + return Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, output, hasInput, hasOutput, cancellationToken); + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunString) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static string RunString( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + using var output = new StringWriter(); + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, output, hasInput, hasOutput, cancellationToken); + return output.ToString(); + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunEnumerable) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static IEnumerable RunEnumerable( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + using var output = new StringWriter(); + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, output, hasInput, hasOutput, cancellationToken); + var bytes = System.Text.Encoding.UTF8.GetBytes(output.ToString()); + foreach (var b in bytes) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return b; + } + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunAsyncEnumerable) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static async IAsyncEnumerable RunAsyncEnumerable( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + bool hasInput, + bool hasOutput, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using var output = new StringWriter(); + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, output, hasInput, hasOutput, cancellationToken); + var bytes = System.Text.Encoding.UTF8.GetBytes(output.ToString()); + + foreach (var b in bytes) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return b; + await Task.Yield(); + } + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunTask) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static Task RunTask( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + TextWriter output, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, output, hasInput, hasOutput, cancellationToken); + }, cancellationToken); + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunTaskInt) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static Task RunTaskInt( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, TextWriter.Null, hasInput, hasOutput, cancellationToken), + cancellationToken); + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunTaskString) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static Task RunTaskString( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + using var output = new StringWriter(); + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, output, hasInput, hasOutput, cancellationToken); + return output.ToString(); + }, cancellationToken); + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunValueTask) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static ValueTask RunValueTask( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + TextWriter output, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + return new ValueTask(Task.Run(() => + { + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, output, hasInput, hasOutput, cancellationToken); + }, cancellationToken)); + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunValueTaskInt) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static ValueTask RunValueTaskInt( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + return new ValueTask(Task.Run(() => + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, TextWriter.Null, hasInput, hasOutput, cancellationToken), + cancellationToken)); + } + + """); + } + + if ((features & RuntimeFacadeFeatures.RunValueTaskString) != 0) + { + sb.Append(""" + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static ValueTask RunValueTaskString( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + return new ValueTask(Task.Run(() => + { + using var output = new StringWriter(); + Run(cells, minX, minY, minZ, maxX, maxY, maxZ, input, output, hasInput, hasOutput, cancellationToken); + return output.ToString(); + }, cancellationToken)); + } + + """); + } + + return sb.ToString(); + } + + static string BuildRuntimeSource(RuntimeFacadeFeatures features) => string.Concat(""" + // + #nullable enable + #pragma warning disable CS1591 + using System; + using System.Collections; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Linq; + using System.Runtime.CompilerServices; + using System.Threading; + using System.Threading.Tasks; + + namespace Esolang.Funge.__Generated + { + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static class FungeRuntime + { + + """, + BuildRuntimeFacadeMethods(features), """ + + private sealed class RuntimeStackStack + { + private readonly LinkedList> _stacks = new LinkedList>(); + + internal RuntimeStackStack() + { + _stacks.AddFirst(new Stack()); + } + + private RuntimeStackStack(LinkedList> stacks) + { + _stacks.Clear(); + foreach (var stack in stacks) + _stacks.AddLast(stack); + } + + internal Stack TOSS { get { return _stacks.First!.Value; } } + internal Stack SOSS { get { return _stacks.First!.Next!.Value; } } + internal bool HasSOSS { get { return _stacks.Count >= 2; } } + internal int StackCount { get { return _stacks.Count; } } + internal IEnumerable> AllStacks { get { return _stacks; } } + + internal void Push(int value) { TOSS.Push(value); } + internal int Pop() { return TOSS.Count > 0 ? TOSS.Pop() : 0; } + internal void ClearToss() { TOSS.Clear(); } + internal void PushNewStack() { _stacks.AddFirst(new Stack()); } + + internal void PopCurrentStack() + { + if (_stacks.Count > 1) + _stacks.RemoveFirst(); + } + + internal RuntimeStackStack Clone() + { + var list = new LinkedList>(); + foreach (var stack in _stacks) + list.AddLast(new Stack(stack.Reverse())); + return new RuntimeStackStack(list); + } + } + + private sealed class RuntimeIp + { + internal RuntimeIp(int id) + { + Id = id; + Delta = (1, 0, 0); + StackStack = new RuntimeStackStack(); + } + + internal int Id { get; } + internal (int X, int Y, int Z) Position; + internal (int X, int Y, int Z) Delta; + internal (int X, int Y, int Z) Offset; + internal RuntimeStackStack StackStack; + internal bool StringMode; + internal bool IsStopped; + + internal RuntimeIp CreateChild(int newId) + { + return new RuntimeIp(newId) + { + Position = Position, + Delta = (-Delta.X, -Delta.Y, -Delta.Z), + Offset = Offset, + StackStack = StackStack.Clone(), + StringMode = StringMode, + }; + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static int Run( + Dictionary<(int, int, int), int> cells, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + TextReader input, + TextWriter output, + bool hasInput, + bool hasOutput, + CancellationToken cancellationToken = default) + { + var rng = new Random(); + var commandLineArguments = Environment.GetCommandLineArgs(); + var environmentVariables = Environment.GetEnvironmentVariables() + .Cast() + .Select(static entry => string.Concat(entry.Key, "=", entry.Value)) + .ToArray(); + + int nextIpId = 1; + int exitCode = 0; + bool quit = false; + + int GetCell(int x, int y, int z) + { + int value; + return cells.TryGetValue((x, y, z), out value) ? value : ' '; + } + + void SetCell(int x, int y, int z, int value) + { + if (value == ' ') + cells.Remove((x, y, z)); + else + cells[(x, y, z)] = value; + } + + (int X, int Y, int Z) Advance((int X, int Y, int Z) pos, (int X, int Y, int Z) delta) + { + if (maxX < minX) + return pos; + + int nx = pos.X + delta.X; + int ny = pos.Y + delta.Y; + int nz = pos.Z + delta.Z; + + int width = maxX - minX + 1; + int height = maxY - minY + 1; + int depth = maxZ - minZ + 1; + + if (nx < minX) + nx = maxX - ((minX - nx - 1) % width); + else if (nx > maxX) + nx = minX + ((nx - maxX - 1) % width); + + if (ny < minY) + ny = maxY - ((minY - ny - 1) % height); + else if (ny > maxY) + ny = minY + ((ny - maxY - 1) % height); + + if (nz < minZ) + nz = maxZ - ((minZ - nz - 1) % depth); + else if (nz > maxZ) + nz = minZ + ((nz - maxZ - 1) % depth); + + return (nx, ny, nz); + } + + bool IsSgmlSpace(int c) + { + return c == ' ' || c == '\t' || c == '\f' || c == '\v'; + } + + (int X, int Y, int Z) PopVector(RuntimeStackStack stack) + { + int z = stack.Pop(); + int y = stack.Pop(); + int x = stack.Pop(); + return (x, y, z); + } + + void PushVector(RuntimeStackStack stack, (int X, int Y, int Z) vector) + { + stack.Push(vector.X); + stack.Push(vector.Y); + stack.Push(vector.Z); + } + + bool TryPopZeroTerminatedString(RuntimeStackStack stack, out string result) + { + var chars = new List(); + while (true) + { + int value = stack.Pop(); + if (value == 0) + { + result = new string(chars.ToArray()); + return true; + } + + if (value < char.MinValue || value > char.MaxValue) + { + result = string.Empty; + return false; + } + + chars.Add((char)value); + } + } + + bool TryInputFile((int X, int Y, int Z) leastPoint, string fileName, bool binaryMode, out (int X, int Y, int Z) size) + { + size = (0, 0, 0); + + byte[] bytes; + try + { + bytes = File.ReadAllBytes(fileName); + } + catch + { + return false; + } + + int x = 0, y = 0, z = 0; + bool wroteAny = false; + int maxVX = 0, maxVY = 0, maxVZ = 0; + + foreach (byte raw in bytes) + { + int cell = raw; + + if (!binaryMode) + { + if (cell == '\r') + continue; + if (cell == '\n') + { + x = 0; + y++; + continue; + } + if (cell == '\f') + { + x = 0; + y = 0; + z++; + continue; + } + if (cell == '\t' || cell == '\v') + cell = ' '; + } + + if (binaryMode || cell != ' ') + SetCell(leastPoint.X + x, leastPoint.Y + y, leastPoint.Z + z, cell); + + wroteAny = true; + if (x > maxVX) maxVX = x; + if (y > maxVY) maxVY = y; + if (z > maxVZ) maxVZ = z; + x++; + } + + size = wroteAny ? (maxVX, maxVY, maxVZ) : (0, 0, 0); + return true; + } + + bool TryOutputFile((int X, int Y, int Z) leastPoint, (int X, int Y, int Z) size, string fileName, bool linearText) + { + int sx = Math.Max(0, size.X); + int sy = Math.Max(0, size.Y); + int sz = Math.Max(0, size.Z); + + var rows = new List(); + for (int z = 0; z <= sz; z++) + { + for (int y = 0; y <= sy; y++) + { + var chars = new char[sx + 1]; + for (int x = 0; x <= sx; x++) + { + int c = GetCell(leastPoint.X + x, leastPoint.Y + y, leastPoint.Z + z); + chars[x] = c >= char.MinValue && c <= char.MaxValue ? (char)c : ' '; + } + + string row = new string(chars); + rows.Add(linearText ? row.TrimEnd(' ') : row); + } + + if (z != sz) + rows.Add("\f"); + } + + if (linearText) + { + while (rows.Count > 0 && rows[rows.Count - 1].Length == 0) + rows.RemoveAt(rows.Count - 1); + } + + string text = string.Join("\n", rows); + byte[] bytes = new byte[text.Length]; + for (int i = 0; i < text.Length; i++) + bytes[i] = (byte)(text[i] & 0xFF); + + try + { + File.WriteAllBytes(fileName, bytes); + return true; + } + catch + { + return false; + } + } + + int ExecuteSystemCommand(string command) + { + try + { + bool isWindows = + Environment.OSVersion.Platform == PlatformID.Win32NT || + Environment.OSVersion.Platform == PlatformID.Win32S || + Environment.OSVersion.Platform == PlatformID.Win32Windows || + Environment.OSVersion.Platform == PlatformID.WinCE; + + string fileName; + string arguments; + if (isWindows) + { + fileName = "cmd.exe"; + arguments = "/c " + command; + } + else + { + fileName = "/bin/sh"; + arguments = "-c \"" + command.Replace("\"", "\\\"") + "\""; + } + + var processStartInfo = new ProcessStartInfo(fileName, arguments) + { + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(processStartInfo); + if (process is null) + return -1; + + process.WaitForExit(); + return process.ExitCode; + } + catch + { + return -1; + } + } + + void PushSysInfo(RuntimeIp ip, int ipCount, int c) + { + var items = new List(); + + items.Add(0x01 | 0x02 | 0x04 | 0x08); + items.Add(4); + items.Add(unchecked((int)0x46756E67u)); + items.Add(9800); + items.Add(1); + items.Add(Path.DirectorySeparatorChar); + items.Add(3); + items.Add(ip.Id); + items.Add(0); + + items.Add(ip.Position.X); + items.Add(ip.Position.Y); + items.Add(ip.Position.Z); + + items.Add(ip.Delta.X); + items.Add(ip.Delta.Y); + items.Add(ip.Delta.Z); + + items.Add(ip.Offset.X); + items.Add(ip.Offset.Y); + items.Add(ip.Offset.Z); + + items.Add(minX); + items.Add(minY); + items.Add(minZ); + + items.Add(maxX - minX); + items.Add(maxY - minY); + items.Add(maxZ - minZ); + + var now = DateTime.Now; + items.Add(((now.Year - 1900) * 256 * 256) + (now.Month * 256) + now.Day); + items.Add((now.Hour * 256 * 256) + (now.Minute * 256) + now.Second); + + items.Add(ip.StackStack.StackCount); + foreach (var stack in ip.StackStack.AllStacks) + items.Add(stack.Count); + + foreach (var arg in commandLineArguments) + { + foreach (var ch in arg) + items.Add(ch); + items.Add(0); + } + items.Add(0); + + foreach (var env in environmentVariables) + { + foreach (var ch in env) + items.Add(ch); + items.Add(0); + } + items.Add(0); + + for (int i = items.Count - 1; i >= 0; i--) + ip.StackStack.Push(items[i]); + + if (c > 0) + { + var snapshot = ip.StackStack.TOSS.ToArray(); + int picked = c <= snapshot.Length ? snapshot[c - 1] : 0; + for (int i = 0; i < items.Count; i++) + ip.StackStack.Pop(); + ip.StackStack.Push(picked); + } + } + + var ips = new LinkedList(); + ips.AddFirst(new RuntimeIp(0)); + + void ExecuteInstruction(RuntimeIp ip, LinkedListNode ipNode, ref bool suppressAdvance, int? overrideCell) + { + int cell = overrideCell ?? GetCell(ip.Position.X, ip.Position.Y, ip.Position.Z); + + if (ip.StringMode) + { + if (cell == '"') + { + ip.StringMode = false; + } + else if (IsSgmlSpace(cell)) + { + ip.StackStack.Push(' '); + while (true) + { + var next = Advance(ip.Position, ip.Delta); + int nextCell = GetCell(next.X, next.Y, next.Z); + if (IsSgmlSpace(nextCell)) + ip.Position = next; + else + break; + } + } + else + { + ip.StackStack.Push(cell); + } + return; + } + + switch (cell) + { + case ' ': case '\t': case '\f': case '\v': case 'z': + break; + + case '!': + ip.StackStack.Push(ip.StackStack.Pop() == 0 ? 1 : 0); + break; + + case '$': + ip.StackStack.Pop(); + break; + + case ':': + { + int v = ip.StackStack.Pop(); + ip.StackStack.Push(v); + ip.StackStack.Push(v); + break; + } + + case '\\': + { + int b = ip.StackStack.Pop(); + int a = ip.StackStack.Pop(); + ip.StackStack.Push(b); + ip.StackStack.Push(a); + break; + } + + case 'n': + ip.StackStack.ClearToss(); + break; + + case '+': + { + int b = ip.StackStack.Pop(); + int a = ip.StackStack.Pop(); + ip.StackStack.Push(a + b); + break; + } + + case '-': + { + int b = ip.StackStack.Pop(); + int a = ip.StackStack.Pop(); + ip.StackStack.Push(a - b); + break; + } + + case '*': + { + int b = ip.StackStack.Pop(); + int a = ip.StackStack.Pop(); + ip.StackStack.Push(a * b); + break; + } + + case '/': + { + int b = ip.StackStack.Pop(); + int a = ip.StackStack.Pop(); + ip.StackStack.Push(b == 0 ? 0 : a / b); + break; + } + + case '%': + { + int b = ip.StackStack.Pop(); + int a = ip.StackStack.Pop(); + ip.StackStack.Push(b == 0 ? 0 : a % b); + break; + } + + case '`': + { + int b = ip.StackStack.Pop(); + int a = ip.StackStack.Pop(); + ip.StackStack.Push(a > b ? 1 : 0); + break; + } + + case '0': case '1': case '2': case '3': case '4': + case '5': case '6': case '7': case '8': case '9': + ip.StackStack.Push(cell - '0'); + break; + case 'a': ip.StackStack.Push(10); break; + case 'b': ip.StackStack.Push(11); break; + case 'c': ip.StackStack.Push(12); break; + case 'd': ip.StackStack.Push(13); break; + case 'e': ip.StackStack.Push(14); break; + case 'f': ip.StackStack.Push(15); break; + + case '>': ip.Delta = (1, 0, 0); break; + case '<': ip.Delta = (-1, 0, 0); break; + case '^': ip.Delta = (0, -1, 0); break; + case 'v': ip.Delta = (0, 1, 0); break; + case 'h': ip.Delta = (0, 0, -1); break; + case 'l': ip.Delta = (0, 0, 1); break; + case '?': + switch (rng.Next(6)) + { + case 0: ip.Delta = (1, 0, 0); break; + case 1: ip.Delta = (-1, 0, 0); break; + case 2: ip.Delta = (0, -1, 0); break; + case 3: ip.Delta = (0, 1, 0); break; + case 4: ip.Delta = (0, 0, -1); break; + default: ip.Delta = (0, 0, 1); break; + } + break; + case 'm': + { + int v = ip.StackStack.Pop(); + ip.Delta = v == 0 ? (0, 0, 1) : (0, 0, -1); + break; + } + case '_': + { + int v = ip.StackStack.Pop(); + ip.Delta = v == 0 ? (1, 0, 0) : (-1, 0, 0); + break; + } + case '|': + { + int v = ip.StackStack.Pop(); + ip.Delta = v == 0 ? (0, 1, 0) : (0, -1, 0); + break; + } + case '[': + ip.Delta = (ip.Delta.Y, -ip.Delta.X, 0); + break; + case ']': + ip.Delta = (-ip.Delta.Y, ip.Delta.X, 0); + break; + case 'r': + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + case 'x': + { + var v = PopVector(ip.StackStack); + ip.Delta = v; + break; + } + case 'w': + { + int b = ip.StackStack.Pop(); + int a = ip.StackStack.Pop(); + if (a > b) + ip.Delta = (-ip.Delta.Y, ip.Delta.X, 0); + else if (a < b) + ip.Delta = (ip.Delta.Y, -ip.Delta.X, 0); + break; + } + + case '#': + ip.Position = Advance(ip.Position, ip.Delta); + break; + case 'j': + { + int s = ip.StackStack.Pop(); + var step = s >= 0 ? ip.Delta : (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + int count = Math.Abs(s); + for (int i = 0; i < count; i++) + ip.Position = Advance(ip.Position, step); + suppressAdvance = true; + break; + } + case ';': + ip.Position = Advance(ip.Position, ip.Delta); + while (GetCell(ip.Position.X, ip.Position.Y, ip.Position.Z) != ';') + ip.Position = Advance(ip.Position, ip.Delta); + break; + + case '\'': + ip.Position = Advance(ip.Position, ip.Delta); + ip.StackStack.Push(GetCell(ip.Position.X, ip.Position.Y, ip.Position.Z)); + break; + case 's': + { + int sv = ip.StackStack.Pop(); + ip.Position = Advance(ip.Position, ip.Delta); + SetCell(ip.Position.X, ip.Position.Y, ip.Position.Z, sv); + break; + } + case '"': + ip.StringMode = true; + break; + + case 'g': + { + int z = ip.StackStack.Pop(); + int y = ip.StackStack.Pop(); + int x = ip.StackStack.Pop(); + ip.StackStack.Push(GetCell(x + ip.Offset.X, y + ip.Offset.Y, z + ip.Offset.Z)); + break; + } + case 'p': + { + int z = ip.StackStack.Pop(); + int y = ip.StackStack.Pop(); + int x = ip.StackStack.Pop(); + int v = ip.StackStack.Pop(); + SetCell(x + ip.Offset.X, y + ip.Offset.Y, z + ip.Offset.Z, v); + break; + } + + case '.': + if (!hasOutput) + throw new InvalidOperationException("Funge output instruction '.' executed without an output interface."); + output.Write(ip.StackStack.Pop()); + output.Write(' '); + break; + case ',': + if (!hasOutput) + throw new InvalidOperationException("Funge output instruction ',' executed without an output interface."); + output.Write((char)ip.StackStack.Pop()); + break; + case '&': + { + if (!hasInput) + throw new InvalidOperationException("Funge input instruction '&' executed without an input interface."); + var line = input.ReadLine(); + if (line == null) + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + else + { + int v; + ip.StackStack.Push(int.TryParse(line.Trim(), out v) ? v : 0); + } + break; + } + case '~': + { + if (!hasInput) + throw new InvalidOperationException("Funge input instruction '~' executed without an input interface."); + int ch = input.Read(); + if (ch < 0) + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + else + ip.StackStack.Push(ch); + break; + } + + case 'i': + { + string fileName; + if (!TryPopZeroTerminatedString(ip.StackStack, out fileName)) + { + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + + int flags = ip.StackStack.Pop(); + var va = PopVector(ip.StackStack); + va = (va.X + ip.Offset.X, va.Y + ip.Offset.Y, va.Z + ip.Offset.Z); + bool binaryMode = (flags & 1) != 0; + + (int X, int Y, int Z) vb; + if (!TryInputFile(va, fileName, binaryMode, out vb)) + { + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + + PushVector(ip.StackStack, (va.X - ip.Offset.X, va.Y - ip.Offset.Y, va.Z - ip.Offset.Z)); + PushVector(ip.StackStack, vb); + break; + } + + case 'o': + { + string fileName; + if (!TryPopZeroTerminatedString(ip.StackStack, out fileName)) + { + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + + int flags = ip.StackStack.Pop(); + var vb = PopVector(ip.StackStack); + var va = PopVector(ip.StackStack); + va = (va.X + ip.Offset.X, va.Y + ip.Offset.Y, va.Z + ip.Offset.Z); + bool linearText = (flags & 1) != 0; + + if (!TryOutputFile(va, vb, fileName, linearText)) + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + + case '@': + ip.IsStopped = true; + break; + case 'q': + exitCode = ip.StackStack.Pop(); + quit = true; + break; + + case 'k': + { + int n = ip.StackStack.Pop(); + var instrPos = Advance(ip.Position, ip.Delta); + while (true) + { + int c = GetCell(instrPos.X, instrPos.Y, instrPos.Z); + if (IsSgmlSpace(c)) + { + instrPos = Advance(instrPos, ip.Delta); + } + else if (c == ';') + { + instrPos = Advance(instrPos, ip.Delta); + while (GetCell(instrPos.X, instrPos.Y, instrPos.Z) != ';') + instrPos = Advance(instrPos, ip.Delta); + instrPos = Advance(instrPos, ip.Delta); + } + else + { + break; + } + } + + if (n == 0) + { + ip.Position = instrPos; + } + else if (n > 0) + { + int operand = GetCell(instrPos.X, instrPos.Y, instrPos.Z); + for (int i = 0; i < n && !ip.IsStopped && !quit; i++) + { + bool dummy = false; + ExecuteInstruction(ip, ipNode, ref dummy, operand); + } + } + break; + } + + case 't': + { + var child = ip.CreateChild(nextIpId++); + ips.AddAfter(ipNode, child); + break; + } + + case '{': + { + int n = ip.StackStack.Pop(); + + var items = new List(); + if (n > 0) + { + for (int i = 0; i < n; i++) + items.Add(ip.StackStack.Pop()); + } + + ip.StackStack.Push(ip.Offset.X); + ip.StackStack.Push(ip.Offset.Y); + ip.StackStack.Push(ip.Offset.Z); + + ip.StackStack.PushNewStack(); + + if (n > 0) + { + for (int i = items.Count - 1; i >= 0; i--) + ip.StackStack.Push(items[i]); + } + else if (n < 0) + { + var soss = ip.StackStack.SOSS; + for (int i = 0; i < -n; i++) + soss.Push(0); + } + + ip.Offset = Advance(ip.Position, ip.Delta); + break; + } + + case '}': + { + int n = ip.StackStack.Pop(); + if (!ip.StackStack.HasSOSS) + { + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + + var items = new List(); + for (int i = 0; i < Math.Max(0, n); i++) + items.Add(ip.StackStack.Pop()); + + ip.StackStack.PopCurrentStack(); + + int oz = ip.StackStack.Pop(); + int oy = ip.StackStack.Pop(); + int ox = ip.StackStack.Pop(); + ip.Offset = (ox, oy, oz); + + if (n < 0) + { + for (int i = 0; i < -n; i++) + ip.StackStack.Pop(); + } + + for (int i = items.Count - 1; i >= 0; i--) + ip.StackStack.Push(items[i]); + break; + } + + case 'u': + { + int n = ip.StackStack.Pop(); + if (!ip.StackStack.HasSOSS) + { + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + + var soss = ip.StackStack.SOSS; + if (n > 0) + { + for (int i = 0; i < n; i++) + ip.StackStack.Push(soss.Count > 0 ? soss.Pop() : 0); + } + else if (n < 0) + { + for (int i = 0; i < -n; i++) + soss.Push(ip.StackStack.Pop()); + } + break; + } + + case 'y': + { + int c = ip.StackStack.Pop(); + PushSysInfo(ip, ips.Count, c); + break; + } + + case '(': + case ')': + { + int n = ip.StackStack.Pop(); + for (int i = 0; i < n; i++) + ip.StackStack.Pop(); + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + + case '=': + { + string command; + if (!TryPopZeroTerminatedString(ip.StackStack, out command)) + { + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + + ip.StackStack.Push(ExecuteSystemCommand(command)); + break; + } + + default: + if (cell >= 'A' && cell <= 'Z') + ip.Delta = (-ip.Delta.X, -ip.Delta.Y, -ip.Delta.Z); + break; + } + } + + while (ips.Count > 0 && !quit) + { + cancellationToken.ThrowIfCancellationRequested(); + var node = ips.First; + while (node != null && !quit) + { + cancellationToken.ThrowIfCancellationRequested(); + var nextNode = node.Next; + var ip = node.Value; + + bool suppressAdvance = false; + ExecuteInstruction(ip, node, ref suppressAdvance, null); + + if (ip.IsStopped || quit) + { + ips.Remove(node); + } + else if (!suppressAdvance) + { + ip.Position = Advance(ip.Position, ip.Delta); + } + + node = nextNode; + } + } + + return exitCode; + } + } + } + """); +} diff --git a/Generator/MethodGenerator.cs b/Generator/MethodGenerator.cs index 27e82d9..3d6e49a 100644 --- a/Generator/MethodGenerator.cs +++ b/Generator/MethodGenerator.cs @@ -1,615 +1,695 @@ -using Esolang.Funge.Parser; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using System.Collections.Immutable; -using System.Text; - -namespace Esolang.Funge.Generator; - -/// -/// A source generator that emits method implementations from GenerateFungeMethodAttribute. -/// -[Generator(LanguageNames.CSharp)] -public sealed partial class MethodGenerator : IIncrementalGenerator -{ - /// The standard auto-generated file header. - public const string CommentAutoGenerated = "// "; - - /// The namespace where the generated attribute is placed. - public const string NameSpaceName = "Esolang.Funge"; - - /// The generated attribute class name. - public const string AttributeName = "GenerateFungeMethodAttribute"; - - /// The source file name used to aggregate generated methods. - public const string GeneratedMethodsFileName = "GenerateFungeMethod.g.cs"; - - const string GeneratedMethodsFileHeader = $$""" - {{CommentAutoGenerated}} - #nullable enable - #pragma warning disable CS0219 - #pragma warning disable CS1998 - - """; - - // ----------------------------------------------------------------------- - // Enumerations for execution signature binding - // ----------------------------------------------------------------------- - - enum ReturnKind - { - Void, - String, - Task, - TaskString, - ValueTask, - ValueTaskString, - EnumerableByte, - AsyncEnumerableByte, - Invalid, - } - - enum InputKind { None, String, TextReader, PipeReader } - - enum OutputKind { None, TextWriter, PipeWriter, ReturnString, ReturnEnumerable, ReturnAsyncEnumerable } - - readonly struct ExecutionBinding( - bool isValid, - ReturnKind returnKind, - InputKind inputKind, - OutputKind outputKind, - string inputExpression, - string outputExpression, - string? cancellationTokenName, - string? errorId, - Location? location = null) - { - public bool IsValid { get; } = isValid; - public ReturnKind ReturnKind { get; } = returnKind; - public InputKind InputKind { get; } = inputKind; - public OutputKind OutputKind { get; } = outputKind; - public string InputExpression { get; } = inputExpression; - public string OutputExpression { get; } = outputExpression; - public string? CancellationTokenName { get; } = cancellationTokenName; - public string? ErrorId { get; } = errorId; - public Location? Location { get; } = location; - public bool HasExplicitInput => InputKind is not InputKind.None; - public bool HasExplicitOutput => OutputKind is not OutputKind.None; - } - - // ----------------------------------------------------------------------- - // IIncrementalGenerator.Initialize - // ----------------------------------------------------------------------- - - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - context.RegisterPostInitializationOutput(static ctx => - ctx.AddSource("GenerateFungeMethodAttribute.cs", $$""" - {{CommentAutoGenerated}} - using System; - using System.Diagnostics; - namespace {{NameSpaceName}} { - [Conditional("COMPILE_TIME_ONLY")] - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] - internal sealed class {{AttributeName}} : Attribute - { - /// - /// Generate a Funge-98 method implementation from a source file. - /// - /// Path to the Funge-98 source file (.b98). Ignored when is set. - internal {{AttributeName}}(string sourcePath = "") { } - /// - /// Inline Funge-98 source code. When non-empty, sourcePath is ignored. - /// - public string InlineSource = ""; - } - } - """)); - - const string fullAttributeName = NameSpaceName + "." + AttributeName; - var source = context.SyntaxProvider.ForAttributeWithMetadataName( - fullAttributeName, - static (node, _) => node is MethodDeclarationSyntax, - static (ctx, _) => ctx); - - var generatedTargets = source.Collect(); - - var additionalFiles = context.AdditionalTextsProvider - .Select(static (text, token) => (text.Path, Text: text.GetText(token)?.ToString())) - .Collect(); - - var languageVersion = context.ParseOptionsProvider - .Select(static (opts, _) => - opts is CSharpParseOptions csOpts ? csOpts.LanguageVersion : LanguageVersion.Default); - - var projectDirectory = context.AnalyzerConfigOptionsProvider - .Select(static (provider, _) => - provider.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory", out var dir) - || provider.GlobalOptions.TryGetValue("build_property.ProjectDir", out dir) - ? dir : null); - - var inputs = generatedTargets - .Combine(additionalFiles) - .Combine(languageVersion) - .Combine(projectDirectory); - - context.RegisterSourceOutput(inputs, static (ctx, input) => - { - var (((sources, files), langVersion), projDir) = input; - - if (sources.IsDefaultOrEmpty) - return; - - var methodSb = new StringBuilder(GeneratedMethodsFileHeader); - var emittedCount = 0; - - foreach (var syntaxCtx in sources) - { - var method = (MethodDeclarationSyntax)syntaxCtx.TargetNode; - var symbol = (IMethodSymbol)syntaxCtx.TargetSymbol; - - if (!IsLanguageVersionAtLeastCSharp8(langVersion)) - { - ctx.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.LanguageVersionTooLow, - method.Identifier.GetLocation(), - langVersion.ToDisplayString())); - } - - // Get sourcePath attribute argument - var attrData = syntaxCtx.Attributes.FirstOrDefault(); - if (attrData is null) continue; - - var sourcePath = attrData.ConstructorArguments.FirstOrDefault().Value as string; - - // Check for inline source (named argument takes priority over file path) - string? inlineSource = null; - foreach (var namedArg in attrData.NamedArguments) - { - if (namedArg.Key == "InlineSource") - { - inlineSource = namedArg.Value.Value as string; - break; - } - } - - if (string.IsNullOrWhiteSpace(inlineSource) && string.IsNullOrWhiteSpace(sourcePath)) - { - ctx.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.InvalidSourcePathParameter, - method.Identifier.GetLocation(), - symbol.Name)); - continue; - } - - // Bind the method signature - var binding = BindExecutionSignature(symbol, method); - if (!binding.IsValid) - { - if (binding.ErrorId == DiagnosticDescriptors.InvalidReturnType.Id) - ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.InvalidReturnType, - binding.Location ?? method.Identifier.GetLocation(), - symbol.ReturnType.ToDisplayString())); - else if (binding.ErrorId == DiagnosticDescriptors.DuplicateParameter.Id) - ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.DuplicateParameter, - binding.Location ?? method.Identifier.GetLocation(), - symbol.Name, symbol.Name)); - else if (binding.ErrorId == DiagnosticDescriptors.ReturnOutputConflict.Id) - ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.ReturnOutputConflict, - binding.Location ?? method.Identifier.GetLocation(), - symbol.Name)); - else - ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.InvalidParameter, - binding.Location ?? method.Identifier.GetLocation(), - symbol.Name)); - continue; - } - - // Resolve source text: inline takes priority over file - string? sourceText = null; - if (!string.IsNullOrWhiteSpace(inlineSource)) - { - sourceText = inlineSource; - } - else - { - foreach (var (filePath, fileText) in files) - { - if (filePath is null || fileText is null) continue; - var normalizedSource = NormalizePath(sourcePath!); - var normalizedFile = NormalizePath(filePath); - // Strip the ".funge.txt" intermediate suffix that the .targets file appends - const string fungeSuffix = ".funge.txt"; - var compareFile = normalizedFile.EndsWith(fungeSuffix, StringComparison.OrdinalIgnoreCase) - ? normalizedFile.Substring(0, normalizedFile.Length - fungeSuffix.Length) - : normalizedFile; - if (string.Equals(compareFile, normalizedSource, StringComparison.OrdinalIgnoreCase) - || compareFile.EndsWith("/" + normalizedSource, StringComparison.OrdinalIgnoreCase) - || string.Equals(System.IO.Path.GetFileName(compareFile), - System.IO.Path.GetFileName(normalizedSource), StringComparison.OrdinalIgnoreCase)) - { - sourceText = fileText; - break; - } - } - - if (sourceText is null) - { - ctx.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.SourceFileNotFound, - method.Identifier.GetLocation(), - sourcePath)); - continue; - } - } - - // Parse the Funge space - var space = FungeParser.Parse(sourceText!); - - // Scan for I/O usage - var (usesOutput, usesInput) = ScanFungeIo(space); - - if (usesOutput && !binding.HasExplicitOutput) - ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiredOutputInterface, - method.Identifier.GetLocation(), symbol.Name)); - - if (usesInput && !binding.HasExplicitInput) - ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiredInputInterface, - method.Identifier.GetLocation(), symbol.Name)); - - if (!usesInput && binding.HasExplicitInput) - ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnusedInputInterface, - method.Identifier.GetLocation(), symbol.Name)); - - var displayPath = !string.IsNullOrWhiteSpace(inlineSource) ? "" : sourcePath!; - var emitted = EmitMethod(symbol, method, space, binding, projDir, displayPath); - methodSb.AppendLine(emitted); - emittedCount++; - } - - if (emittedCount > 0) - ctx.AddSource(GeneratedMethodsFileName, methodSb.ToString()); - - EmitRuntimeIfNeeded(ctx, emittedCount > 0); - }); - } - - // ----------------------------------------------------------------------- - // Signature binding - // ----------------------------------------------------------------------- - - static ExecutionBinding BindExecutionSignature(IMethodSymbol method, MethodDeclarationSyntax syntax) - { - var returnKind = method.ReturnType switch - { - { SpecialType: SpecialType.System_Void } => ReturnKind.Void, - { Name: "String", ContainingNamespace.Name: "System" } => ReturnKind.String, - INamedTypeSymbol t when t.Name == "Task" && t.TypeArguments.Length == 0 - => ReturnKind.Task, - INamedTypeSymbol t when t.Name == "Task" && t.TypeArguments.Length == 1 - && t.TypeArguments[0].SpecialType == SpecialType.System_String - => ReturnKind.TaskString, - INamedTypeSymbol t when t.Name == "ValueTask" && t.TypeArguments.Length == 0 - => ReturnKind.ValueTask, - INamedTypeSymbol t when t.Name == "ValueTask" && t.TypeArguments.Length == 1 - && t.TypeArguments[0].SpecialType == SpecialType.System_String - => ReturnKind.ValueTaskString, - INamedTypeSymbol t when t.Name == "IEnumerable" && t.TypeArguments.Length == 1 - && t.TypeArguments[0].SpecialType == SpecialType.System_Byte - => ReturnKind.EnumerableByte, - INamedTypeSymbol t when t.Name == "IAsyncEnumerable" && t.TypeArguments.Length == 1 - && t.TypeArguments[0].SpecialType == SpecialType.System_Byte - => ReturnKind.AsyncEnumerableByte, - _ => ReturnKind.Invalid, - }; - - if (returnKind == ReturnKind.Invalid) - return new(false, returnKind, InputKind.None, OutputKind.None, "", "", null, - DiagnosticDescriptors.InvalidReturnType.Id); - - var outputKind = returnKind switch - { - ReturnKind.String or ReturnKind.TaskString or ReturnKind.ValueTaskString - => OutputKind.ReturnString, - ReturnKind.EnumerableByte => OutputKind.ReturnEnumerable, - ReturnKind.AsyncEnumerableByte => OutputKind.ReturnAsyncEnumerable, - _ => OutputKind.None, - }; - - var inputKind = InputKind.None; - var inputExpr = ""; - var outputExpr = ""; - string? cancellationTokenName = null; - var hasCancellationToken = false; - - foreach (var p in method.Parameters) - { - if (p.RefKind is not RefKind.None) - return new(false, returnKind, inputKind, outputKind, "", "", null, - DiagnosticDescriptors.InvalidParameter.Id, p.Locations.FirstOrDefault()); - - if (p.Type.SpecialType == SpecialType.System_String) - { - if (inputKind is not InputKind.None) - return new(false, returnKind, inputKind, outputKind, "", "", null, - DiagnosticDescriptors.DuplicateParameter.Id, p.Locations.FirstOrDefault()); - inputKind = InputKind.String; - inputExpr = p.Name; - continue; - } - - var typeName = p.Type.ToDisplayString(); - - if (typeName == "System.IO.TextReader") - { - if (inputKind is not InputKind.None) - return new(false, returnKind, inputKind, outputKind, "", "", null, - DiagnosticDescriptors.DuplicateParameter.Id, p.Locations.FirstOrDefault()); - inputKind = InputKind.TextReader; - inputExpr = p.Name; - continue; - } - - if (typeName == "System.IO.Pipelines.PipeReader") - { - if (inputKind is not InputKind.None) - return new(false, returnKind, inputKind, outputKind, "", "", null, - DiagnosticDescriptors.DuplicateParameter.Id, p.Locations.FirstOrDefault()); - inputKind = InputKind.PipeReader; - inputExpr = p.Name; - continue; - } - - if (typeName == "System.IO.TextWriter") - { - if (returnKind is not ReturnKind.Void) - return new(false, returnKind, inputKind, outputKind, inputExpr, p.Name, - cancellationTokenName, DiagnosticDescriptors.ReturnOutputConflict.Id, - p.Locations.FirstOrDefault()); - if (outputKind is not OutputKind.None) - return new(false, returnKind, inputKind, outputKind, inputExpr, p.Name, - cancellationTokenName, DiagnosticDescriptors.DuplicateParameter.Id, - p.Locations.FirstOrDefault()); - outputKind = OutputKind.TextWriter; - outputExpr = p.Name; - continue; - } - - if (typeName == "System.IO.Pipelines.PipeWriter") - { - if (returnKind is not ReturnKind.Void) - return new(false, returnKind, inputKind, outputKind, inputExpr, p.Name, - cancellationTokenName, DiagnosticDescriptors.ReturnOutputConflict.Id, - p.Locations.FirstOrDefault()); - if (outputKind is not OutputKind.None) - return new(false, returnKind, inputKind, outputKind, inputExpr, p.Name, - cancellationTokenName, DiagnosticDescriptors.DuplicateParameter.Id, - p.Locations.FirstOrDefault()); - outputKind = OutputKind.PipeWriter; - outputExpr = p.Name; - continue; - } - - if (typeName == "System.Threading.CancellationToken") - { - if (hasCancellationToken) - return new(false, returnKind, inputKind, outputKind, inputExpr, outputExpr, - cancellationTokenName, DiagnosticDescriptors.DuplicateParameter.Id, - p.Locations.FirstOrDefault()); - hasCancellationToken = true; - cancellationTokenName = p.Name; - continue; - } - - return new(false, returnKind, inputKind, outputKind, inputExpr, outputExpr, - cancellationTokenName, DiagnosticDescriptors.InvalidParameter.Id, - p.Locations.FirstOrDefault()); - } - - return new(true, returnKind, inputKind, outputKind, inputExpr, outputExpr, cancellationTokenName, null); - } - - // ----------------------------------------------------------------------- - // Method emit - // ----------------------------------------------------------------------- - - static string EmitMethod( - IMethodSymbol symbol, - MethodDeclarationSyntax syntax, - FungeSpace space, - ExecutionBinding binding, - string? projDir, - string sourcePath) - { - var ns = symbol.ContainingType.ContainingNamespace?.IsGlobalNamespace == false - ? symbol.ContainingType.ContainingNamespace.ToDisplayString() : null; - - var typeKeyword = symbol.ContainingType.TypeKind switch - { - TypeKind.Struct when symbol.ContainingType.IsRecord => "record struct", - TypeKind.Struct => "struct", - TypeKind.Interface => "interface", - TypeKind.Class when symbol.ContainingType.IsRecord => "record", - _ => "class", - }; - - var typeName = symbol.ContainingType.Name; - var accessibility = GetAccessibility(symbol.DeclaredAccessibility); - var staticMod = symbol.IsStatic ? " static" : string.Empty; - var asyncMod = binding.ReturnKind == ReturnKind.AsyncEnumerableByte ? " async" : string.Empty; - var returnTypeSyntax = symbol.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - var paramList = string.Join(", ", System.Linq.Enumerable.Select(symbol.Parameters, p => - { - var prefix = (binding.ReturnKind == ReturnKind.AsyncEnumerableByte - && binding.CancellationTokenName is not null - && string.Equals(p.Name, binding.CancellationTokenName, StringComparison.Ordinal)) - ? "[global::System.Runtime.CompilerServices.EnumeratorCancellation] " - : string.Empty; - return prefix + p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + " " + p.Name; - })); - - var relPath = projDir is not null ? MakeRelative(projDir, sourcePath) : sourcePath; - var sb = new StringBuilder(); - - if (ns is not null) { sb.Append("namespace ").Append(ns).AppendLine(" {").AppendLine(); } - sb.Append("partial ").Append(typeKeyword).Append(' ').AppendLine(typeName); - sb.AppendLine("{"); - sb.AppendLine($" // Generated from: {relPath}"); - sb.Append(" ").Append(accessibility).Append(staticMod).Append(asyncMod) - .Append(" partial ").Append(returnTypeSyntax).Append(' ') - .Append(symbol.Name).Append('(').Append(paramList).AppendLine(")"); - sb.AppendLine(" {"); - - EmitBody(sb, space, binding); - - sb.AppendLine(" }"); - sb.AppendLine("}"); - if (ns is not null) sb.AppendLine("}"); - return sb.ToString(); - } - - static void EmitBody(StringBuilder sb, FungeSpace space, ExecutionBinding binding) - { - var inputExpr = binding.InputKind switch - { - InputKind.None => "global::System.IO.TextReader.Null", - InputKind.String => - $"new global::System.IO.StringReader({binding.InputExpression} ?? string.Empty)", - InputKind.TextReader => binding.InputExpression, - InputKind.PipeReader => - $"new global::System.IO.StreamReader({binding.InputExpression}.AsStream())", - _ => "global::System.IO.TextReader.Null", - }; - - EmitSpaceData(sb, space); - - switch (binding.ReturnKind) - { - case ReturnKind.String: - case ReturnKind.TaskString: - case ReturnKind.ValueTaskString: - case ReturnKind.EnumerableByte: - case ReturnKind.AsyncEnumerableByte: - sb.AppendLine(" var __fungeOutput = new global::System.IO.StringWriter();"); - EmitRuntimeRunCall(sb, inputExpr, "__fungeOutput", binding.HasExplicitInput, binding.HasExplicitOutput); - break; - case ReturnKind.Void: - case ReturnKind.Task: - case ReturnKind.ValueTask: - { - if (binding.OutputKind == OutputKind.PipeWriter) - { - sb.AppendLine($" using var __fungeOutput = new global::System.IO.StreamWriter({binding.OutputExpression}.AsStream(), global::System.Text.Encoding.UTF8, 1024, leaveOpen: true);"); - EmitRuntimeRunCall(sb, inputExpr, "__fungeOutput", binding.HasExplicitInput, binding.HasExplicitOutput); - } - else - { - var outExpr = binding.OutputKind == OutputKind.TextWriter - ? binding.OutputExpression - : "global::System.IO.TextWriter.Null"; - EmitRuntimeRunCall(sb, inputExpr, outExpr, binding.HasExplicitInput, binding.HasExplicitOutput); - } - break; - } - } - - switch (binding.ReturnKind) - { - case ReturnKind.String: - sb.AppendLine(" return __fungeOutput.ToString();"); - break; - case ReturnKind.Task: - sb.AppendLine(" return global::System.Threading.Tasks.Task.CompletedTask;"); - break; - case ReturnKind.TaskString: - sb.AppendLine(" return global::System.Threading.Tasks.Task.FromResult(__fungeOutput.ToString());"); - break; - case ReturnKind.ValueTask: - sb.AppendLine(" return default(global::System.Threading.Tasks.ValueTask);"); - break; - case ReturnKind.ValueTaskString: - sb.AppendLine(" return new global::System.Threading.Tasks.ValueTask(__fungeOutput.ToString());"); - break; - case ReturnKind.EnumerableByte: - case ReturnKind.AsyncEnumerableByte: - sb.AppendLine(" foreach (var __b in global::System.Text.Encoding.UTF8.GetBytes(__fungeOutput.ToString()))"); - sb.AppendLine(" yield return __b;"); - break; - } - } - - static void EmitRuntimeRunCall(StringBuilder sb, string inputExpr, string outputExpr, bool hasInput, bool hasOutput) - { - sb.AppendLine(" global::Esolang.Funge.__Generated.FungeRuntime.Run("); - sb.AppendLine($" __cells, __minX, __minY, __maxX, __maxY, {inputExpr}, {outputExpr}, {(hasInput ? "true" : "false")}, {(hasOutput ? "true" : "false")});"); - } - - static void EmitSpaceData(StringBuilder sb, FungeSpace space) - { - sb.AppendLine($" int __minX = {space.MinX}, __minY = {space.MinY}, __maxX = {space.MaxX}, __maxY = {space.MaxY};"); - sb.AppendLine(" var __cells = new global::System.Collections.Generic.Dictionary<(int, int), int>();"); - for (var y = space.MinY; y <= space.MaxY; y++) - for (var x = space.MinX; x <= space.MaxX; x++) - { - var val = space[new FungeVector(x, y)]; - if (val != ' ') - sb.AppendLine($" __cells[({x}, {y})] = {val};"); - } - } - - // ----------------------------------------------------------------------- - // I/O scan - // ----------------------------------------------------------------------- - - static (bool usesOutput, bool usesInput) ScanFungeIo(FungeSpace space) - { - bool usesOutput = false, usesInput = false; - for (var y = space.MinY; y <= space.MaxY; y++) - for (var x = space.MinX; x <= space.MaxX; x++) - { - var c = space[new FungeVector(x, y)]; - if (c is '.' or ',') usesOutput = true; - if (c is '&' or '~') usesInput = true; - if (usesOutput && usesInput) return (true, true); - } - return (usesOutput, usesInput); - } - - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - static string GetAccessibility(Accessibility accessibility) => accessibility switch - { - Accessibility.Public => "public", - Accessibility.Protected => "protected", - Accessibility.Internal => "internal", - Accessibility.Private => "private", - Accessibility.ProtectedAndInternal => "private protected", - Accessibility.ProtectedOrInternal => "protected internal", - _ => string.Empty, - }; - - static bool IsLanguageVersionAtLeastCSharp8(LanguageVersion v) => v switch - { - LanguageVersion.Default => true, - LanguageVersion.Latest => true, - LanguageVersion.Preview => true, - LanguageVersion.LatestMajor => true, - _ => v >= LanguageVersion.CSharp8, - }; - - static string NormalizePath(string path) => path.Replace('\\', '/').TrimStart('/'); - - static string MakeRelative(string baseDir, string fullPath) - { - var sep = System.IO.Path.DirectorySeparatorChar.ToString(); - if (!baseDir.EndsWith(sep)) baseDir += sep; - return fullPath.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase) - ? fullPath.Substring(baseDir.Length) - : fullPath; - } -} +using Esolang.Funge.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Text; + +namespace Esolang.Funge.Generator; + +/// +/// A source generator that emits method implementations from GenerateFungeMethodAttribute. +/// +[Generator(LanguageNames.CSharp)] +public sealed partial class MethodGenerator : IIncrementalGenerator +{ + /// The standard auto-generated file header. + public const string CommentAutoGenerated = "// "; + + /// The namespace where the generated attribute is placed. + public const string NameSpaceName = "Esolang.Funge"; + + /// The generated attribute class name. + public const string AttributeName = "GenerateFungeMethodAttribute"; + + /// The source file name used to aggregate generated methods. + public const string GeneratedMethodsFileName = "GenerateFungeMethod.g.cs"; + + const string GeneratedMethodsFileHeader = $$""" + {{CommentAutoGenerated}} + #nullable enable + #pragma warning disable CS0219 + #pragma warning disable CS1998 + + """; + + // ----------------------------------------------------------------------- + // Enumerations for execution signature binding + // ----------------------------------------------------------------------- + + enum ReturnKind + { + Void, + Int, + String, + Task, + TaskInt, + TaskString, + ValueTask, + ValueTaskInt, + ValueTaskString, + EnumerableByte, + AsyncEnumerableByte, + Invalid, + } + + enum InputKind { None, String, TextReader, PipeReader } + + enum OutputKind { None, TextWriter, PipeWriter, ReturnString, ReturnEnumerable, ReturnAsyncEnumerable } + + readonly struct ExecutionBinding( + bool isValid, + ReturnKind returnKind, + InputKind inputKind, + OutputKind outputKind, + string inputExpression, + string outputExpression, + string? cancellationTokenName, + string? errorId, + Location? location = null) + { + public bool IsValid { get; } = isValid; + public ReturnKind ReturnKind { get; } = returnKind; + public InputKind InputKind { get; } = inputKind; + public OutputKind OutputKind { get; } = outputKind; + public string InputExpression { get; } = inputExpression; + public string OutputExpression { get; } = outputExpression; + public string? CancellationTokenName { get; } = cancellationTokenName; + public string? ErrorId { get; } = errorId; + public Location? Location { get; } = location; + public bool HasExplicitInput => InputKind is not InputKind.None; + public bool HasExplicitOutput => OutputKind is not OutputKind.None; + } + + // ----------------------------------------------------------------------- + // IIncrementalGenerator.Initialize + // ----------------------------------------------------------------------- + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static ctx => + ctx.AddSource("GenerateFungeMethodAttribute.cs", $$""" + {{CommentAutoGenerated}} + using System; + using System.Diagnostics; + namespace {{NameSpaceName}} { + [Conditional("COMPILE_TIME_ONLY")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + internal sealed class {{AttributeName}} : Attribute + { + /// + /// Generate a Funge-98 method implementation from a source file. + /// + /// Path to the Funge-98 source file (.b98). Ignored when is set. + internal {{AttributeName}}(string sourcePath = "") { } + /// + /// Inline Funge-98 source code. When non-empty, sourcePath is ignored. + /// + public string InlineSource = ""; + } + } + """)); + + const string fullAttributeName = NameSpaceName + "." + AttributeName; + var source = context.SyntaxProvider.ForAttributeWithMetadataName( + fullAttributeName, + static (node, _) => node is MethodDeclarationSyntax, + static (ctx, _) => ctx); + + var generatedTargets = source.Collect(); + + var additionalFiles = context.AdditionalTextsProvider + .Select(static (text, token) => (text.Path, Text: text.GetText(token)?.ToString())) + .Collect(); + + var languageVersion = context.ParseOptionsProvider + .Select(static (opts, _) => + opts is CSharpParseOptions csOpts ? csOpts.LanguageVersion : LanguageVersion.Default); + + var projectDirectory = context.AnalyzerConfigOptionsProvider + .Select(static (provider, _) => + provider.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory", out var dir) + || provider.GlobalOptions.TryGetValue("build_property.ProjectDir", out dir) + ? dir : null); + + var inputs = generatedTargets + .Combine(additionalFiles) + .Combine(languageVersion) + .Combine(projectDirectory); + + context.RegisterSourceOutput(inputs, static (ctx, input) => + { + var (((sources, files), langVersion), projDir) = input; + + if (sources.IsDefaultOrEmpty) + return; + + var methodSb = new StringBuilder(GeneratedMethodsFileHeader); + var emittedCount = 0; + var runtimeFeatures = RuntimeFacadeFeatures.None; + + foreach (var syntaxCtx in sources) + { + var method = (MethodDeclarationSyntax)syntaxCtx.TargetNode; + var symbol = (IMethodSymbol)syntaxCtx.TargetSymbol; + + if (!IsLanguageVersionAtLeastCSharp8(langVersion)) + { + ctx.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.LanguageVersionTooLow, + method.Identifier.GetLocation(), + langVersion.ToDisplayString())); + } + + // Get sourcePath attribute argument + var attrData = syntaxCtx.Attributes.FirstOrDefault(); + if (attrData is null) continue; + + var sourcePath = attrData.ConstructorArguments.FirstOrDefault().Value as string; + + // Check for inline source (named argument takes priority over file path) + string? inlineSource = null; + foreach (var namedArg in attrData.NamedArguments) + { + if (namedArg.Key == "InlineSource") + { + inlineSource = namedArg.Value.Value as string; + break; + } + } + + if (string.IsNullOrWhiteSpace(inlineSource) && string.IsNullOrWhiteSpace(sourcePath)) + { + ctx.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidSourcePathParameter, + method.Identifier.GetLocation(), + symbol.Name)); + continue; + } + + // Bind the method signature + var binding = BindExecutionSignature(symbol, method); + if (!binding.IsValid) + { + if (binding.ErrorId == DiagnosticDescriptors.InvalidReturnType.Id) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.InvalidReturnType, + binding.Location ?? method.Identifier.GetLocation(), + symbol.ReturnType.ToDisplayString())); + else if (binding.ErrorId == DiagnosticDescriptors.DuplicateParameter.Id) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.DuplicateParameter, + binding.Location ?? method.Identifier.GetLocation(), + symbol.Name, symbol.Name)); + else if (binding.ErrorId == DiagnosticDescriptors.ReturnOutputConflict.Id) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.ReturnOutputConflict, + binding.Location ?? method.Identifier.GetLocation(), + symbol.Name)); + else + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.InvalidParameter, + binding.Location ?? method.Identifier.GetLocation(), + symbol.Name)); + continue; + } + + // Resolve source text: inline takes priority over file + string? sourceText = null; + if (!string.IsNullOrWhiteSpace(inlineSource)) + { + sourceText = inlineSource; + } + else + { + foreach (var (filePath, fileText) in files) + { + if (filePath is null || fileText is null) continue; + var normalizedSource = NormalizePath(sourcePath!); + var normalizedFile = NormalizePath(filePath); + // Strip the ".funge.txt" intermediate suffix that the .targets file appends + const string fungeSuffix = ".funge.txt"; + var compareFile = normalizedFile.EndsWith(fungeSuffix, StringComparison.OrdinalIgnoreCase) + ? normalizedFile.Substring(0, normalizedFile.Length - fungeSuffix.Length) + : normalizedFile; + if (string.Equals(compareFile, normalizedSource, StringComparison.OrdinalIgnoreCase) + || compareFile.EndsWith("/" + normalizedSource, StringComparison.OrdinalIgnoreCase) + || string.Equals(System.IO.Path.GetFileName(compareFile), + System.IO.Path.GetFileName(normalizedSource), StringComparison.OrdinalIgnoreCase)) + { + sourceText = fileText; + break; + } + } + + if (sourceText is null) + { + ctx.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.SourceFileNotFound, + method.Identifier.GetLocation(), + sourcePath)); + continue; + } + } + + // Parse the Funge space + var space = FungeParser.Parse(sourceText!); + + // Scan for I/O usage + var (usesOutput, usesInput) = ScanFungeIo(space); + + if (usesOutput && !binding.HasExplicitOutput) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiredOutputInterface, + method.Identifier.GetLocation(), symbol.Name)); + + if (usesInput && !binding.HasExplicitInput) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiredInputInterface, + method.Identifier.GetLocation(), symbol.Name)); + + if (!usesInput && binding.HasExplicitInput) + ctx.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnusedInputInterface, + method.Identifier.GetLocation(), symbol.Name)); + + var displayPath = !string.IsNullOrWhiteSpace(inlineSource) ? "" : sourcePath!; + var emitted = EmitMethod(symbol, method, space, binding, projDir, displayPath); + methodSb.AppendLine(emitted); + emittedCount++; + runtimeFeatures |= GetRuntimeFacadeFeatures(binding.ReturnKind); + } + + if (emittedCount > 0) + ctx.AddSource(GeneratedMethodsFileName, methodSb.ToString()); + + EmitRuntimeIfNeeded(ctx, runtimeFeatures); + }); + } + + static RuntimeFacadeFeatures GetRuntimeFacadeFeatures(ReturnKind returnKind) => returnKind switch + { + ReturnKind.Void or ReturnKind.Int => RuntimeFacadeFeatures.RunSync, + ReturnKind.String => RuntimeFacadeFeatures.RunString, + ReturnKind.Task => RuntimeFacadeFeatures.RunTask, + ReturnKind.TaskInt => RuntimeFacadeFeatures.RunTaskInt, + ReturnKind.TaskString => RuntimeFacadeFeatures.RunTaskString, + ReturnKind.ValueTask => RuntimeFacadeFeatures.RunValueTask, + ReturnKind.ValueTaskInt => RuntimeFacadeFeatures.RunValueTaskInt, + ReturnKind.ValueTaskString => RuntimeFacadeFeatures.RunValueTaskString, + ReturnKind.EnumerableByte => RuntimeFacadeFeatures.RunEnumerable, + ReturnKind.AsyncEnumerableByte => RuntimeFacadeFeatures.RunAsyncEnumerable, + _ => RuntimeFacadeFeatures.None, + }; + + // ----------------------------------------------------------------------- + // Signature binding + // ----------------------------------------------------------------------- + + static ExecutionBinding BindExecutionSignature(IMethodSymbol method, MethodDeclarationSyntax syntax) + { + var returnKind = method.ReturnType switch + { + { SpecialType: SpecialType.System_Void } => ReturnKind.Void, + { SpecialType: SpecialType.System_Int32 } => ReturnKind.Int, + { Name: "String", ContainingNamespace.Name: "System" } => ReturnKind.String, + INamedTypeSymbol t when t.Name == "Task" && t.TypeArguments.Length == 0 + => ReturnKind.Task, + INamedTypeSymbol t when t.Name == "Task" && t.TypeArguments.Length == 1 + && t.TypeArguments[0].SpecialType == SpecialType.System_Int32 + => ReturnKind.TaskInt, + INamedTypeSymbol t when t.Name == "Task" && t.TypeArguments.Length == 1 + && t.TypeArguments[0].SpecialType == SpecialType.System_String + => ReturnKind.TaskString, + INamedTypeSymbol t when t.Name == "ValueTask" && t.TypeArguments.Length == 0 + => ReturnKind.ValueTask, + INamedTypeSymbol t when t.Name == "ValueTask" && t.TypeArguments.Length == 1 + && t.TypeArguments[0].SpecialType == SpecialType.System_Int32 + => ReturnKind.ValueTaskInt, + INamedTypeSymbol t when t.Name == "ValueTask" && t.TypeArguments.Length == 1 + && t.TypeArguments[0].SpecialType == SpecialType.System_String + => ReturnKind.ValueTaskString, + INamedTypeSymbol t when t.Name == "IEnumerable" && t.TypeArguments.Length == 1 + && t.TypeArguments[0].SpecialType == SpecialType.System_Byte + => ReturnKind.EnumerableByte, + INamedTypeSymbol t when t.Name == "IAsyncEnumerable" && t.TypeArguments.Length == 1 + && t.TypeArguments[0].SpecialType == SpecialType.System_Byte + => ReturnKind.AsyncEnumerableByte, + _ => ReturnKind.Invalid, + }; + + if (returnKind == ReturnKind.Invalid) + return new(false, returnKind, InputKind.None, OutputKind.None, "", "", null, + DiagnosticDescriptors.InvalidReturnType.Id); + + var outputKind = returnKind switch + { + ReturnKind.String or ReturnKind.TaskString or ReturnKind.ValueTaskString + => OutputKind.ReturnString, + ReturnKind.EnumerableByte => OutputKind.ReturnEnumerable, + ReturnKind.AsyncEnumerableByte => OutputKind.ReturnAsyncEnumerable, + _ => OutputKind.None, + }; + + var inputKind = InputKind.None; + var inputExpr = ""; + var outputExpr = ""; + string? cancellationTokenName = null; + var hasCancellationToken = false; + + foreach (var p in method.Parameters) + { + if (p.RefKind is not RefKind.None) + return new(false, returnKind, inputKind, outputKind, "", "", null, + DiagnosticDescriptors.InvalidParameter.Id, p.Locations.FirstOrDefault()); + + if (p.Type.SpecialType == SpecialType.System_String) + { + if (inputKind is not InputKind.None) + return new(false, returnKind, inputKind, outputKind, "", "", null, + DiagnosticDescriptors.DuplicateParameter.Id, p.Locations.FirstOrDefault()); + inputKind = InputKind.String; + inputExpr = p.Name; + continue; + } + + var typeName = p.Type.ToDisplayString(); + + if (typeName == "System.IO.TextReader") + { + if (inputKind is not InputKind.None) + return new(false, returnKind, inputKind, outputKind, "", "", null, + DiagnosticDescriptors.DuplicateParameter.Id, p.Locations.FirstOrDefault()); + inputKind = InputKind.TextReader; + inputExpr = p.Name; + continue; + } + + if (typeName == "System.IO.Pipelines.PipeReader") + { + if (inputKind is not InputKind.None) + return new(false, returnKind, inputKind, outputKind, "", "", null, + DiagnosticDescriptors.DuplicateParameter.Id, p.Locations.FirstOrDefault()); + inputKind = InputKind.PipeReader; + inputExpr = p.Name; + continue; + } + + if (typeName == "System.IO.TextWriter") + { + if (returnKind is not ReturnKind.Void) + return new(false, returnKind, inputKind, outputKind, inputExpr, p.Name, + cancellationTokenName, DiagnosticDescriptors.ReturnOutputConflict.Id, + p.Locations.FirstOrDefault()); + if (outputKind is not OutputKind.None) + return new(false, returnKind, inputKind, outputKind, inputExpr, p.Name, + cancellationTokenName, DiagnosticDescriptors.DuplicateParameter.Id, + p.Locations.FirstOrDefault()); + outputKind = OutputKind.TextWriter; + outputExpr = p.Name; + continue; + } + + if (typeName == "System.IO.Pipelines.PipeWriter") + { + if (returnKind is not ReturnKind.Void) + return new(false, returnKind, inputKind, outputKind, inputExpr, p.Name, + cancellationTokenName, DiagnosticDescriptors.ReturnOutputConflict.Id, + p.Locations.FirstOrDefault()); + if (outputKind is not OutputKind.None) + return new(false, returnKind, inputKind, outputKind, inputExpr, p.Name, + cancellationTokenName, DiagnosticDescriptors.DuplicateParameter.Id, + p.Locations.FirstOrDefault()); + outputKind = OutputKind.PipeWriter; + outputExpr = p.Name; + continue; + } + + if (typeName == "System.Threading.CancellationToken") + { + if (hasCancellationToken) + return new(false, returnKind, inputKind, outputKind, inputExpr, outputExpr, + cancellationTokenName, DiagnosticDescriptors.DuplicateParameter.Id, + p.Locations.FirstOrDefault()); + hasCancellationToken = true; + cancellationTokenName = p.Name; + continue; + } + + return new(false, returnKind, inputKind, outputKind, inputExpr, outputExpr, + cancellationTokenName, DiagnosticDescriptors.InvalidParameter.Id, + p.Locations.FirstOrDefault()); + } + + return new(true, returnKind, inputKind, outputKind, inputExpr, outputExpr, cancellationTokenName, null); + } + + // ----------------------------------------------------------------------- + // Method emit + // ----------------------------------------------------------------------- + + static string EmitMethod( + IMethodSymbol symbol, + MethodDeclarationSyntax syntax, + FungeSpace space, + ExecutionBinding binding, + string? projDir, + string sourcePath) + { + var ns = symbol.ContainingType.ContainingNamespace?.IsGlobalNamespace == false + ? symbol.ContainingType.ContainingNamespace.ToDisplayString() : null; + + var typeKeyword = symbol.ContainingType.TypeKind switch + { + TypeKind.Struct when symbol.ContainingType.IsRecord => "record struct", + TypeKind.Struct => "struct", + TypeKind.Interface => "interface", + TypeKind.Class when symbol.ContainingType.IsRecord => "record", + _ => "class", + }; + + var typeName = symbol.ContainingType.Name; + var accessibility = GetAccessibility(symbol.DeclaredAccessibility); + var staticMod = symbol.IsStatic ? " static" : string.Empty; + var asyncMod = binding.ReturnKind == ReturnKind.AsyncEnumerableByte ? " async" : string.Empty; + var returnTypeSyntax = symbol.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var paramList = string.Join(", ", System.Linq.Enumerable.Select(symbol.Parameters, p => + { + var prefix = (binding.ReturnKind == ReturnKind.AsyncEnumerableByte + && binding.CancellationTokenName is not null + && string.Equals(p.Name, binding.CancellationTokenName, StringComparison.Ordinal)) + ? "[global::System.Runtime.CompilerServices.EnumeratorCancellation] " + : string.Empty; + return prefix + p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + " " + p.Name; + })); + + var relPath = projDir is not null ? MakeRelative(projDir, sourcePath) : sourcePath; + var sb = new StringBuilder(); + + if (ns is not null) { sb.Append("namespace ").Append(ns).AppendLine(" {").AppendLine(); } + sb.Append("partial ").Append(typeKeyword).Append(' ').AppendLine(typeName); + sb.AppendLine("{"); + sb.AppendLine($" // Generated from: {relPath}"); + sb.Append(" ").Append(accessibility).Append(staticMod).Append(asyncMod) + .Append(" partial ").Append(returnTypeSyntax).Append(' ') + .Append(symbol.Name).Append('(').Append(paramList).AppendLine(")"); + sb.AppendLine(" {"); + + EmitBody(sb, space, binding); + + sb.AppendLine(" }"); + sb.AppendLine("}"); + if (ns is not null) sb.AppendLine("}"); + return sb.ToString(); + } + + static void EmitBody(StringBuilder sb, FungeSpace space, ExecutionBinding binding) + { + var inputExpr = binding.InputKind switch + { + InputKind.None => "global::System.IO.TextReader.Null", + InputKind.String => + $"new global::System.IO.StringReader({binding.InputExpression} ?? string.Empty)", + InputKind.TextReader => binding.InputExpression, + InputKind.PipeReader => + $"new global::System.IO.StreamReader({binding.InputExpression}.AsStream())", + _ => "global::System.IO.TextReader.Null", + }; + var cancellationTokenExpr = binding.CancellationTokenName is null + ? "global::System.Threading.CancellationToken.None" + : binding.CancellationTokenName; + + EmitSpaceData(sb, space); + + switch (binding.ReturnKind) + { + case ReturnKind.Void: + { + if (binding.OutputKind == OutputKind.PipeWriter) + { + sb.AppendLine($" using var __fungeOutput = new global::System.IO.StreamWriter({binding.OutputExpression}.AsStream(), global::System.Text.Encoding.UTF8, 1024, leaveOpen: true);"); + sb.AppendLine(" global::Esolang.Funge.__Generated.FungeRuntime.RunSync("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, __fungeOutput, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + } + else + { + var outExpr = binding.OutputKind == OutputKind.TextWriter + ? binding.OutputExpression + : "global::System.IO.TextWriter.Null"; + sb.AppendLine(" global::Esolang.Funge.__Generated.FungeRuntime.RunSync("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {outExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + } + break; + } + + case ReturnKind.Int: + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunSync("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, global::System.IO.TextWriter.Null, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + break; + + case ReturnKind.String: + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunString("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + break; + + case ReturnKind.Task: + { + if (binding.OutputKind == OutputKind.PipeWriter) + { + sb.AppendLine($" using var __fungeOutput = new global::System.IO.StreamWriter({binding.OutputExpression}.AsStream(), global::System.Text.Encoding.UTF8, 1024, leaveOpen: true);"); + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunTask("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, __fungeOutput, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + } + else + { + var outExpr = binding.OutputKind == OutputKind.TextWriter + ? binding.OutputExpression + : "global::System.IO.TextWriter.Null"; + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunTask("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {outExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + } + break; + } + + case ReturnKind.TaskInt: + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunTaskInt("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + break; + + case ReturnKind.TaskString: + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunTaskString("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + break; + + case ReturnKind.ValueTask: + { + if (binding.OutputKind == OutputKind.PipeWriter) + { + sb.AppendLine($" using var __fungeOutput = new global::System.IO.StreamWriter({binding.OutputExpression}.AsStream(), global::System.Text.Encoding.UTF8, 1024, leaveOpen: true);"); + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunValueTask("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, __fungeOutput, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + } + else + { + var outExpr = binding.OutputKind == OutputKind.TextWriter + ? binding.OutputExpression + : "global::System.IO.TextWriter.Null"; + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunValueTask("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {outExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + } + break; + } + + case ReturnKind.ValueTaskInt: + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunValueTaskInt("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + break; + + case ReturnKind.ValueTaskString: + sb.AppendLine(" return global::Esolang.Funge.__Generated.FungeRuntime.RunValueTaskString("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr});"); + break; + + case ReturnKind.EnumerableByte: + sb.AppendLine(" foreach (var __b in global::Esolang.Funge.__Generated.FungeRuntime.RunEnumerable("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr}))"); + sb.AppendLine(" yield return __b;"); + break; + + case ReturnKind.AsyncEnumerableByte: + sb.AppendLine(" await foreach (var __b in global::Esolang.Funge.__Generated.FungeRuntime.RunAsyncEnumerable("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {(binding.HasExplicitInput ? "true" : "false")}, {(binding.HasExplicitOutput ? "true" : "false")}, {cancellationTokenExpr}))"); + sb.AppendLine(" yield return __b;"); + break; + } + } + + static void EmitRuntimeRunCall(StringBuilder sb, string inputExpr, string outputExpr, bool hasInput, bool hasOutput) + { + sb.AppendLine(" global::Esolang.Funge.__Generated.FungeRuntime.Run("); + sb.AppendLine($" __cells, __minX, __minY, __minZ, __maxX, __maxY, __maxZ, {inputExpr}, {outputExpr}, {(hasInput ? "true" : "false")}, {(hasOutput ? "true" : "false")});"); + } + + static void EmitSpaceData(StringBuilder sb, FungeSpace space) + { + sb.AppendLine($" int __minX = {space.MinX}, __minY = {space.MinY}, __minZ = {space.MinZ}, __maxX = {space.MaxX}, __maxY = {space.MaxY}, __maxZ = {space.MaxZ};"); + sb.AppendLine(" var __cells = new global::System.Collections.Generic.Dictionary<(int, int, int), int>();"); + for (var z = space.MinZ; z <= space.MaxZ; z++) + for (var y = space.MinY; y <= space.MaxY; y++) + for (var x = space.MinX; x <= space.MaxX; x++) + { + var val = space[new FungeVector(x, y, z)]; + if (val != ' ') + sb.AppendLine($" __cells[({x}, {y}, {z})] = {val};"); + } + } + + // ----------------------------------------------------------------------- + // I/O scan + // ----------------------------------------------------------------------- + + static (bool usesOutput, bool usesInput) ScanFungeIo(FungeSpace space) + { + bool usesOutput = false, usesInput = false; + for (var z = space.MinZ; z <= space.MaxZ; z++) + for (var y = space.MinY; y <= space.MaxY; y++) + for (var x = space.MinX; x <= space.MaxX; x++) + { + var c = space[new FungeVector(x, y, z)]; + if (c is '.' or ',') usesOutput = true; + if (c is '&' or '~') usesInput = true; + if (usesOutput && usesInput) return (true, true); + } + return (usesOutput, usesInput); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + static string GetAccessibility(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Protected => "protected", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => string.Empty, + }; + + static bool IsLanguageVersionAtLeastCSharp8(LanguageVersion v) => v switch + { + LanguageVersion.Default => true, + LanguageVersion.Latest => true, + LanguageVersion.Preview => true, + LanguageVersion.LatestMajor => true, + _ => v >= LanguageVersion.CSharp8, + }; + + static string NormalizePath(string path) => path.Replace('\\', '/').TrimStart('/'); + + static string MakeRelative(string baseDir, string fullPath) + { + var sep = System.IO.Path.DirectorySeparatorChar.ToString(); + if (!baseDir.EndsWith(sep)) baseDir += sep; + return fullPath.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase) + ? fullPath.Substring(baseDir.Length) + : fullPath; + } +} diff --git a/Generator/README.md b/Generator/README.md index dd449cb..3ac6513 100644 --- a/Generator/README.md +++ b/Generator/README.md @@ -10,12 +10,15 @@ The generator reads the Funge-98 source (from a file or inline) and emits a comp ### Supported return types | Return type | Description | -|---|---| +| --- | --- | | `void` | Run to completion; discard output | +| `int` | Return program exit code (`q` pops stack top, otherwise `0`) | | `string` | Collect all output and return as a string | | `Task` | Async run; discard output | +| `Task` | Async run; return program exit code | | `Task` | Async run; return output string | | `ValueTask` | Async run; discard output | +| `ValueTask` | Async run; return program exit code | | `ValueTask` | Async run; return output string | | `IEnumerable` | Yield output bytes synchronously | | `IAsyncEnumerable` | Yield output bytes asynchronously | @@ -23,7 +26,7 @@ The generator reads the Funge-98 source (from a file or inline) and emits a comp ### Supported parameter types | Parameter type | Role | -|---|---| +| --- | --- | | `string` | Input fed to the program (`&` / `~`) | | `System.IO.TextReader` | Input reader | | `System.IO.Pipelines.PipeReader` | Input as pipe | @@ -33,7 +36,7 @@ The generator reads the Funge-98 source (from a file or inline) and emits a comp ## Installation -``` +```bash dotnet add package Esolang.Funge.Generator ``` @@ -83,10 +86,37 @@ Funge-98 code can be embedded directly as a string literal using `InlineSource` public static partial string HelloWorldInline(); ``` +## 3D (Trefunge) support + +The generator fully supports 3D Funge-98 (Trefunge) programs. +Within a source file, the form-feed character (`\f`, U+000C) separates Z-layers. + +```.b98 +# Layer Z=0 — jump into layer Z=1 +l +``` + +### (form-feed character here separates layers) + +```.b98 +# Layer Z=1 — runs the Hello World program +>64+"!dlroW ,olleH">:#,_@ +``` + +**3D direction instructions:** + +| Instruction | Description | +| --- | --- | +| `h` | Set delta to High `(0, 0, −1)` | +| `l` | Set delta to Low `(0, 0, +1)` | +| `m` | Pop value; zero → Low, non-zero → High | + +The generated runtime automatically handles XYZ coordinates, Z-axis wrapping, and 3D `g`/`p`/`x` operands. + ## Diagnostics | ID | Severity | Description | -|---|---|---| +| --- | --- | --- | | FG0001 | Error | `sourcePath` is empty and `InlineSource` is not set | | FG0002 | Error | Unsupported return type | | FG0003 | Error | Unsupported parameter type | @@ -100,30 +130,31 @@ public static partial string HelloWorldInline(); ## Funge-98 Compliance -The generated runtime (`FungeRuntime`) implements a **single-IP, single-stack 2D subset** of Befunge-98, -sufficient for programs that do not rely on concurrency, stack stack operations, or fingerprints. +The generated runtime (`FungeRuntime`) implements Funge-98 core execution including Trefunge 3D navigation (`h` / `l` / `m`), +concurrency (`t`), and stack stack operations (`{` / `}` / `u`), while still excluding fingerprints. | Category | Instructions | Status | -|---|---|---| +| --- | --- | --- | | Stack | `0`–`9` `a`–`f` `:` `$` `\` `n` | ✅ | | Arithmetic | `+` `-` `*` `/` `%` | ✅ | | Comparison | `` ` `` `!` | ✅ | -| Direction | `>` `<` `^` `v` `?` `[` `]` `r` `x` `w` | ✅ | +| Direction | `>` `<` `^` `v` `h` `l` `?` `[` `]` `r` `x` `m` `w` | ✅ | | Branching | `_` `\|` | ✅ | | Movement | `#` `;` `j` | ✅ | | String / char | `"` `'` `s` | ✅ (stringmode contiguous spaces are SGML-style) | -| Storage (self-modifying) | `g` `p` | 🟡 storage offset not applied | +| Storage (self-modifying) | `g` `p` (with storage offset) | ✅ | | I/O | `.` `,` `&` `~` | ✅ | | Misc | `z` `@` | ✅ | -| Exit code | `q` | 🟡 terminates normally but exit code is discarded | +| Exit code | `q` | ✅ pops stack top and returns it as method exit code (`@` returns `0`) | | Iteration | `k` | ✅ | -| Concurrency | `t` | ❌ not implemented (single IP only) | -| Stack stack | `{` `}` `u` | ❌ not implemented | -| System info | `y` | ❌ not implemented | -| File I/O | `i` `o` | ❌ not implemented | -| System exec | `=` | ❌ not implemented | +| Concurrency | `t` | ✅ | +| Stack stack | `{` `}` `u` | ✅ | +| System info | `y` | ✅ | +| File I/O | `i` `o` | ✅ | +| System exec | `=` | ✅ | | Fingerprints | `(` `)` `A`–`Z` | ❌ reflects (not implemented) | -| 3D (Trefunge) | `h` `l` `m` | ❌ not implemented (2D only) | +| 3D (Trefunge) | `h` `l` `m` | ✅ | +| ND-generalized space | dimensions > 3 | ❌ not implemented | ## References diff --git a/Interpreter.Tests/ProgramTests.cs b/Interpreter.Tests/ProgramTests.cs index cdadf4f..22cf736 100644 --- a/Interpreter.Tests/ProgramTests.cs +++ b/Interpreter.Tests/ProgramTests.cs @@ -1,56 +1,56 @@ -using Esolang.Funge.Interpreter; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Esolang.Funge.Interpreter.Tests; - -[TestClass] -public class ProgramTests -{ - const string HelloWorldProgram = "64+\"!dlroW ,olleH\">:#,_@"; - - [TestMethod] - public async Task RunAsync_HelpOption_ReturnsZero() - { - var exitCode = await Program.RunAsync(["--help"]); - Assert.AreEqual(0, exitCode); - } - - [TestMethod] - public async Task RunAsync_HelloWorld_ReturnsZero() - { - var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.b98"); - try - { - await File.WriteAllTextAsync(path, HelloWorldProgram); - - var exitCode = await Program.RunAsync([path]); - Assert.AreEqual(0, exitCode); - } - finally - { - if (File.Exists(path)) - File.Delete(path); - } - } - - [TestMethod] - public async Task RunAsync_CancelledToken_StopsInfiniteProgram() - { - var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.b98"); - try - { - await File.WriteAllTextAsync(path, ">"); - - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - - var exitCode = await Program.RunAsync([path], cancellation.Token); - Assert.AreEqual(0, exitCode); - } - finally - { - if (File.Exists(path)) - File.Delete(path); - } - } -} +using Esolang.Funge.Interpreter; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Esolang.Funge.Interpreter.Tests; + +[TestClass] +public class ProgramTests +{ + const string HelloWorldProgram = "64+\"!dlroW ,olleH\">:#,_@"; + + [TestMethod] + public async Task RunAsync_HelpOption_ReturnsZero() + { + var exitCode = await Program.RunAsync(["--help"]); + Assert.AreEqual(0, exitCode); + } + + [TestMethod] + public async Task RunAsync_HelloWorld_ReturnsZero() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.b98"); + try + { + await File.WriteAllTextAsync(path, HelloWorldProgram); + + var exitCode = await Program.RunAsync([path]); + Assert.AreEqual(0, exitCode); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + [TestMethod] + public async Task RunAsync_CancelledToken_StopsInfiniteProgram() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.b98"); + try + { + await File.WriteAllTextAsync(path, ">"); + + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var exitCode = await Program.RunAsync([path], cancellation.Token); + Assert.AreEqual(0, exitCode); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } +} diff --git a/Interpreter/FungeInterpreterExtensions.cs b/Interpreter/FungeInterpreterExtensions.cs index da1f46e..830eea3 100644 --- a/Interpreter/FungeInterpreterExtensions.cs +++ b/Interpreter/FungeInterpreterExtensions.cs @@ -1,37 +1,46 @@ -using Esolang.Funge.Parser; -using Esolang.Funge.Processor; -using System.CommandLine; - -namespace Esolang.Funge.Interpreter; - -/// -/// Extension methods that compose the dotnet-funge CLI commands. -/// -public static class FungeInterpreterExtensions -{ - /// - /// Builds and returns the root command for the dotnet-funge tool. - /// - public static RootCommand BuildRootCommand() - { - var pathArgument = new Argument("path") - { - Description = "Path to a Funge-98 source file (.b98).", - }; - - var rootCommand = new RootCommand("Run Funge-98 (Befunge-98) programs.") - { - pathArgument, - }; - - rootCommand.SetAction((parseResult, cancellationToken) => - { - var path = parseResult.GetValue(pathArgument)!; - var space = FungeParser.ParseFile(path); - var proc = new FungeProcessor(space, Console.Out, Console.In); - return Task.FromResult(proc.Run(cancellationToken)); - }); - - return rootCommand; - } -} +using Esolang.Funge.Parser; +using Esolang.Funge.Processor; +using System.Collections; +using System.CommandLine; + +namespace Esolang.Funge.Interpreter; + +/// +/// Extension methods that compose the dotnet-funge CLI commands. +/// +public static class FungeInterpreterExtensions +{ + /// + /// Builds and returns the root command for the dotnet-funge tool. + /// + public static RootCommand BuildRootCommand() + { + var pathArgument = new Argument("path") + { + Description = "Path to a Funge-98 source file (.b98).", + }; + + var rootCommand = new RootCommand("Run Funge-98 (Befunge-98) programs.") + { + pathArgument, + }; + + rootCommand.SetAction((parseResult, cancellationToken) => + { + var path = parseResult.GetValue(pathArgument)!; + var space = FungeParser.ParseFile(path); + var env = Environment.GetEnvironmentVariables() + .Cast() + .Select(static entry => $"{entry.Key}={entry.Value}"); + var proc = new FungeProcessor( + space, + Console.Out, + Console.In, + commandLineArguments: [path], + environmentVariables: env); + return Task.FromResult(proc.RunToEnd(cancellationToken: cancellationToken)); + }); + + return rootCommand; + } +} diff --git a/Interpreter/Program.cs b/Interpreter/Program.cs index b530114..4c0934d 100644 --- a/Interpreter/Program.cs +++ b/Interpreter/Program.cs @@ -1,40 +1,40 @@ -namespace Esolang.Funge.Interpreter; - -/// -/// Entry point for the dotnet-funge command-line tool. -/// -public static class Program -{ - /// - /// Runs the command-line pipeline and returns the process exit code. - /// - /// Command-line arguments. - /// Token to cancel command execution. - /// The exit code. - public static async Task RunAsync(string[] args, CancellationToken cancellationToken = default) - { - var rootCommand = FungeInterpreterExtensions.BuildRootCommand(); - return await rootCommand.Parse(args).InvokeAsync(cancellationToken: cancellationToken); - } - - /// Application entry point. - public static async Task Main(string[] args) - { - using var cancellation = new CancellationTokenSource(); - void OnCancelKeyPress(object? _, ConsoleCancelEventArgs e) - { - e.Cancel = true; - cancellation.Cancel(); - } - - Console.CancelKeyPress += OnCancelKeyPress; - try - { - return await RunAsync(args, cancellation.Token); - } - finally - { - Console.CancelKeyPress -= OnCancelKeyPress; - } - } -} +namespace Esolang.Funge.Interpreter; + +/// +/// Entry point for the dotnet-funge command-line tool. +/// +public static class Program +{ + /// + /// Runs the command-line pipeline and returns the process exit code. + /// + /// Command-line arguments. + /// Token to cancel command execution. + /// The exit code. + public static async Task RunAsync(string[] args, CancellationToken cancellationToken = default) + { + var rootCommand = FungeInterpreterExtensions.BuildRootCommand(); + return await rootCommand.Parse(args).InvokeAsync(cancellationToken: cancellationToken); + } + + /// Application entry point. + public static async Task Main(string[] args) + { + using var cancellation = new CancellationTokenSource(); + void OnCancelKeyPress(object? _, ConsoleCancelEventArgs e) + { + e.Cancel = true; + cancellation.Cancel(); + } + + Console.CancelKeyPress += OnCancelKeyPress; + try + { + return await RunAsync(args, cancellation.Token); + } + finally + { + Console.CancelKeyPress -= OnCancelKeyPress; + } + } +} diff --git a/Interpreter/README.md b/Interpreter/README.md index 8c985e2..dcde688 100644 --- a/Interpreter/README.md +++ b/Interpreter/README.md @@ -1,26 +1,26 @@ # dotnet-funge -Command-line interpreter for [Funge-98](https://github.com/catseye/Funge-98/blob/master/doc/funge98.markdown) (Befunge-98) programs. +Command-line interpreter for [Funge-98](https://github.com/catseye/Funge-98/blob/master/doc/funge98.markdown) programs. ## Installation -``` +```bash dotnet tool install -g dotnet-funge ``` ## Usage -``` +```bash dotnet-funge ``` | Argument | Description | -|---|---| +| --- | --- | | `` | Path to a Funge-98 source file (`.b98`) | ### Example -``` +```bash dotnet-funge hello.b98 ``` @@ -30,20 +30,20 @@ The process exit code reflects the value passed to `q`; it is `0` if the program ## Funge-98 Compliance -Delegates execution to `Esolang.Funge.Processor`, which targets **Befunge-98** (2D). +Delegates execution to `Esolang.Funge.Processor`, including Trefunge 3D directions (`h`/`l`/`m`). For detailed processor-level behavior, refer to the processor package documentation. | Area | Status | -|---|---| +| --- | --- | | Core instruction set (stack, arithmetic, comparison, direction, I/O, storage, movement) | ✅ | | Funge-98 extensions (`k` iterate, `t` concurrency, `{`/`}`/`u` stack stack) | ✅ | -| System info (`y`) | 🟡 env vars / command-line args are empty | +| System info (`y`) | ✅ | | Standard I/O (`&` `~` `,` `.`) connected to stdin / stdout | ✅ | | Exit code via `q` | ✅ | | Fingerprints (`(` `)` `A`–`Z`) | ❌ reflects (not implemented) | -| File I/O (`i` `o`) | ❌ reflects (not implemented) | -| System exec (`=`) | ❌ reflects (not implemented) | -| 3D / Trefunge (`h` `l` `m`) | ❌ reflects (2D only) | +| File I/O (`i` `o`) | ✅ | +| System exec (`=`) | ✅ | +| 3D / Trefunge (`h` `l` `m`) | ✅ | ## References diff --git a/Parser.Tests/FungeParserTests.cs b/Parser.Tests/FungeParserTests.cs index 7fade70..5e27f98 100644 --- a/Parser.Tests/FungeParserTests.cs +++ b/Parser.Tests/FungeParserTests.cs @@ -1,144 +1,167 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Esolang.Funge.Parser.Tests; - -[TestClass] -public class FungeParserTests -{ - [TestMethod] - public void ParseSingleChar_StoresCorrectly() - { - var space = FungeParser.Parse("@"); - Assert.AreEqual('@', space[new FungeVector(0, 0)]); - } - - [TestMethod] - public void ParseSpace_ReturnsDefaultCell() - { - var space = FungeParser.Parse(" @"); - Assert.AreEqual(' ', space[new FungeVector(0, 0)]); - Assert.AreEqual('@', space[new FungeVector(1, 0)]); - } - - [TestMethod] - public void ParseMultiLine_CorrectCoordinates() - { - var source = "AB\nCD"; - var space = FungeParser.Parse(source); - Assert.AreEqual('A', space[new FungeVector(0, 0)]); - Assert.AreEqual('B', space[new FungeVector(1, 0)]); - Assert.AreEqual('C', space[new FungeVector(0, 1)]); - Assert.AreEqual('D', space[new FungeVector(1, 1)]); - } - - [TestMethod] - public void ParseCrLf_IgnoresCarriageReturn() - { - var space = FungeParser.Parse("A\r\nB"); - Assert.AreEqual('A', space[new FungeVector(0, 0)]); - Assert.AreEqual('B', space[new FungeVector(0, 1)]); - } - - [TestMethod] - public void UnsetCell_ReturnsSpace() - { - var space = FungeParser.Parse("@"); - Assert.AreEqual(' ', space[new FungeVector(99, 99)]); - } - - [TestMethod] - public void BoundingBox_CorrectAfterParse() - { - var space = FungeParser.Parse("AB\nCD"); - Assert.AreEqual(0, space.MinX); - Assert.AreEqual(0, space.MinY); - Assert.AreEqual(1, space.MaxX); - Assert.AreEqual(1, space.MaxY); - } - - [TestMethod] - public void BoundingBox_IncludesSpacesInSource() - { - var space = FungeParser.Parse("A "); - Assert.AreEqual(0, space.MinX); - Assert.AreEqual(2, space.MaxX); - Assert.AreEqual(0, space.MinY); - Assert.AreEqual(0, space.MaxY); - } - - [TestMethod] - public void Parse_SgmlSpaces_AreTreatedAsSpaceCells() - { - var space = FungeParser.Parse("A\t\f\vB"); - Assert.AreEqual('A', space[new FungeVector(0, 0)]); - Assert.AreEqual(' ', space[new FungeVector(1, 0)]); - Assert.AreEqual(' ', space[new FungeVector(2, 0)]); - Assert.AreEqual(' ', space[new FungeVector(3, 0)]); - Assert.AreEqual('B', space[new FungeVector(4, 0)]); - } -} - -[TestClass] -public class FungeVectorTests -{ - [TestMethod] - public void RotateRight_EastBecomeSouth() - => Assert.AreEqual(FungeVector.South, FungeVector.East.RotateRight()); - - [TestMethod] - public void RotateRight_SouthBecomeWest() - => Assert.AreEqual(FungeVector.West, FungeVector.South.RotateRight()); - - [TestMethod] - public void RotateLeft_EastBecomeNorth() - => Assert.AreEqual(FungeVector.North, FungeVector.East.RotateLeft()); - - [TestMethod] - public void Reflect_EastBecomeWest() - => Assert.AreEqual(FungeVector.West, FungeVector.East.Reflect()); - - [TestMethod] - public void Addition() - => Assert.AreEqual(new FungeVector(3, 5), new FungeVector(1, 2) + new FungeVector(2, 3)); -} - -[TestClass] -public class FungeSpaceTests -{ - [TestMethod] - public void Advance_WrapsEastBeyondMaxX() - { - var space = FungeParser.Parse("ABC"); - // MinX=0, MaxX=2, Width=3 - // Advance East from (2,0): next (3,0) -> wraps to (0,0) - var next = space.Advance(new FungeVector(2, 0), FungeVector.East); - Assert.AreEqual(new FungeVector(0, 0), next); - } - - [TestMethod] - public void Advance_WrapsWestBeyondMinX() - { - var space = FungeParser.Parse("ABC"); - var next = space.Advance(new FungeVector(0, 0), FungeVector.West); - Assert.AreEqual(new FungeVector(2, 0), next); - } - - [TestMethod] - public void Advance_WrapsSouthBeyondMaxY() - { - var space = FungeParser.Parse("A\nB\nC"); - var next = space.Advance(new FungeVector(0, 2), FungeVector.South); - Assert.AreEqual(new FungeVector(0, 0), next); - } - - [TestMethod] - public void SetCell_UpdatesBoundingBox() - { - var space = new FungeSpace(); - space[new FungeVector(5, 10)] = 'X'; - Assert.AreEqual(5, space.MinX); - Assert.AreEqual(5, space.MaxX); - Assert.AreEqual(10, space.MinY); - Assert.AreEqual(10, space.MaxY); - } -} +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Esolang.Funge.Parser.Tests; + +[TestClass] +public class FungeParserTests +{ + [TestMethod] + public void ParseSingleChar_StoresCorrectly() + { + var space = FungeParser.Parse("@"); + Assert.AreEqual('@', space[new FungeVector(0, 0)]); + } + + [TestMethod] + public void ParseSpace_ReturnsDefaultCell() + { + var space = FungeParser.Parse(" @"); + Assert.AreEqual(' ', space[new FungeVector(0, 0)]); + Assert.AreEqual('@', space[new FungeVector(1, 0)]); + } + + [TestMethod] + public void ParseMultiLine_CorrectCoordinates() + { + var source = "AB\nCD"; + var space = FungeParser.Parse(source); + Assert.AreEqual('A', space[new FungeVector(0, 0)]); + Assert.AreEqual('B', space[new FungeVector(1, 0)]); + Assert.AreEqual('C', space[new FungeVector(0, 1)]); + Assert.AreEqual('D', space[new FungeVector(1, 1)]); + } + + [TestMethod] + public void ParseCrLf_IgnoresCarriageReturn() + { + var space = FungeParser.Parse("A\r\nB"); + Assert.AreEqual('A', space[new FungeVector(0, 0)]); + Assert.AreEqual('B', space[new FungeVector(0, 1)]); + } + + [TestMethod] + public void UnsetCell_ReturnsSpace() + { + var space = FungeParser.Parse("@"); + Assert.AreEqual(' ', space[new FungeVector(99, 99)]); + } + + [TestMethod] + public void BoundingBox_CorrectAfterParse() + { + var space = FungeParser.Parse("AB\nCD"); + Assert.AreEqual(0, space.MinX); + Assert.AreEqual(0, space.MinY); + Assert.AreEqual(0, space.MinZ); + Assert.AreEqual(1, space.MaxX); + Assert.AreEqual(1, space.MaxY); + Assert.AreEqual(0, space.MaxZ); + } + + [TestMethod] + public void BoundingBox_IncludesSpacesInSource() + { + var space = FungeParser.Parse("A "); + Assert.AreEqual(0, space.MinX); + Assert.AreEqual(2, space.MaxX); + Assert.AreEqual(0, space.MinY); + Assert.AreEqual(0, space.MaxY); + Assert.AreEqual(0, space.MinZ); + Assert.AreEqual(0, space.MaxZ); + } + + [TestMethod] + public void Parse_SgmlSpaces_AreTreatedAsSpaceCells() + { + var space = FungeParser.Parse("A\t\vB"); + Assert.AreEqual('A', space[new FungeVector(0, 0)]); + Assert.AreEqual(' ', space[new FungeVector(1, 0)]); + Assert.AreEqual(' ', space[new FungeVector(2, 0)]); + Assert.AreEqual('B', space[new FungeVector(3, 0)]); + } + + [TestMethod] + public void Parse_FormFeed_StartsNewLayer() + { + var space = FungeParser.Parse("A\fB"); + Assert.AreEqual('A', space[new FungeVector(0, 0, 0)]); + Assert.AreEqual('B', space[new FungeVector(0, 0, 1)]); + Assert.AreEqual(0, space.MinZ); + Assert.AreEqual(1, space.MaxZ); + } +} + +[TestClass] +public class FungeVectorTests +{ + [TestMethod] + public void RotateRight_EastBecomeSouth() + => Assert.AreEqual(FungeVector.South, FungeVector.East.RotateRight()); + + [TestMethod] + public void RotateRight_SouthBecomeWest() + => Assert.AreEqual(FungeVector.West, FungeVector.South.RotateRight()); + + [TestMethod] + public void RotateLeft_EastBecomeNorth() + => Assert.AreEqual(FungeVector.North, FungeVector.East.RotateLeft()); + + [TestMethod] + public void Reflect_EastBecomeWest() + => Assert.AreEqual(FungeVector.West, FungeVector.East.Reflect()); + + [TestMethod] + public void Addition() + => Assert.AreEqual(new FungeVector(3, 5, 7), new FungeVector(1, 2, 3) + new FungeVector(2, 3, 4)); +} + +[TestClass] +public class FungeSpaceTests +{ + [TestMethod] + public void Advance_WrapsEastBeyondMaxX() + { + var space = FungeParser.Parse("ABC"); + // MinX=0, MaxX=2, Width=3 + // Advance East from (2,0): next (3,0) -> wraps to (0,0) + var next = space.Advance(new FungeVector(2, 0), FungeVector.East); + Assert.AreEqual(new FungeVector(0, 0), next); + } + + [TestMethod] + public void Advance_WrapsWestBeyondMinX() + { + var space = FungeParser.Parse("ABC"); + var next = space.Advance(new FungeVector(0, 0), FungeVector.West); + Assert.AreEqual(new FungeVector(2, 0), next); + } + + [TestMethod] + public void Advance_WrapsSouthBeyondMaxY() + { + var space = FungeParser.Parse("A\nB\nC"); + var next = space.Advance(new FungeVector(0, 2), FungeVector.South); + Assert.AreEqual(new FungeVector(0, 0), next); + } + + [TestMethod] + public void SetCell_UpdatesBoundingBox() + { + var space = new FungeSpace(); + space[new FungeVector(5, 10, 15)] = 'X'; + Assert.AreEqual(5, space.MinX); + Assert.AreEqual(5, space.MaxX); + Assert.AreEqual(10, space.MinY); + Assert.AreEqual(10, space.MaxY); + Assert.AreEqual(15, space.MinZ); + Assert.AreEqual(15, space.MaxZ); + } + + [TestMethod] + public void Advance_WrapsLowBeyondMaxZ() + { + var space = FungeParser.Parse("A\fB"); + var next = space.Advance(new FungeVector(0, 0, 1), FungeVector.Low); + Assert.AreEqual(new FungeVector(0, 0, 0), next); + } +} diff --git a/Parser/FungeParser.cs b/Parser/FungeParser.cs index ddc9f20..3c9000f 100644 --- a/Parser/FungeParser.cs +++ b/Parser/FungeParser.cs @@ -1,45 +1,46 @@ -namespace Esolang.Funge.Parser; - -/// -/// Parses Funge-98 source text into a . -/// -public static class FungeParser -{ - /// - /// Parses a Funge-98 source string into a populated . - /// Each character is placed at its (column, row) coordinate. - /// Space characters (ASCII 32) are not stored; they use the default cell value. - /// - /// The Funge-98 source text. - /// A containing the program. - public static FungeSpace Parse(string source) - { - var space = new FungeSpace(); - int x = 0, y = 0; - foreach (var ch in source) - { - if (ch == '\r') continue; - if (ch == '\n') { x = 0; y++; continue; } - - var cell = ch switch - { - '\t' or '\f' or '\v' => ' ', - _ => ch, - }; - - var pos = new FungeVector(x, y); - space.EnsureBounds(pos); - if (cell != ' ') - space[pos] = cell; - x++; - } - return space; - } - - /// - /// Reads a file and parses its contents as a Funge-98 program. - /// - /// Path to the source file. - /// A containing the program. - public static FungeSpace ParseFile(string path) => Parse(File.ReadAllText(path)); -} +namespace Esolang.Funge.Parser; + +/// +/// Parses Funge-98 source text into a . +/// +public static class FungeParser +{ + /// + /// Parses a Funge-98 source string into a populated . + /// Each character is placed at its (column, row) coordinate. + /// Space characters (ASCII 32) are not stored; they use the default cell value. + /// + /// The Funge-98 source text. + /// A containing the program. + public static FungeSpace Parse(string source) + { + var space = new FungeSpace(); + int x = 0, y = 0, z = 0; + foreach (var ch in source) + { + if (ch == '\r') continue; + if (ch == '\n') { x = 0; y++; continue; } + if (ch == '\f') { x = 0; y = 0; z++; continue; } + + var cell = ch switch + { + '\t' or '\v' => ' ', + _ => ch, + }; + + var pos = new FungeVector(x, y, z); + space.EnsureBounds(pos); + if (cell != ' ') + space[pos] = cell; + x++; + } + return space; + } + + /// + /// Reads a file and parses its contents as a Funge-98 program. + /// + /// Path to the source file. + /// A containing the program. + public static FungeSpace ParseFile(string path) => Parse(File.ReadAllText(path)); +} diff --git a/Parser/FungeSpace.cs b/Parser/FungeSpace.cs index bec74b4..11024a3 100644 --- a/Parser/FungeSpace.cs +++ b/Parser/FungeSpace.cs @@ -1,99 +1,115 @@ -namespace Esolang.Funge.Parser; - -/// -/// Represents the Funge-98 program space: a sparse, conceptually infinite 2D grid of integer cells. -/// Unset cells default to the space character (ASCII 32). -/// -public sealed class FungeSpace -{ - private readonly Dictionary _cells = new(); - private int _minX, _minY, _maxX, _maxY; - private bool _hasAny; - - private void IncludeInBounds(FungeVector pos) - { - if (!_hasAny) - { - _minX = _maxX = pos.X; - _minY = _maxY = pos.Y; - _hasAny = true; - return; - } - - if (pos.X < _minX) _minX = pos.X; - if (pos.X > _maxX) _maxX = pos.X; - if (pos.Y < _minY) _minY = pos.Y; - if (pos.Y > _maxY) _maxY = pos.Y; - } - - /// - /// Gets or sets the integer value at the given position. - /// Unset positions return ' ' (32). - /// Setting a cell to ' ' removes it from the space. - /// - public int this[FungeVector pos] - { - get => _cells.TryGetValue(pos, out var v) ? v : ' '; - set - { - if (value == ' ') - { - _cells.Remove(pos); - } - else - { - _cells[pos] = value; - IncludeInBounds(pos); - } - } - } - - /// - /// Ensures the given position is included in the Least Significant Bounding Box - /// even when the cell value is a space and therefore not explicitly stored. - /// - public void EnsureBounds(FungeVector pos) => IncludeInBounds(pos); - - /// Minimum X coordinate of the populated bounding box. - public int MinX => _minX; - - /// Minimum Y coordinate of the populated bounding box. - public int MinY => _minY; - - /// Maximum X coordinate of the populated bounding box. - public int MaxX => _maxX; - - /// Maximum Y coordinate of the populated bounding box. - public int MaxY => _maxY; - - /// - /// Advances a position by , wrapping around the Least Significant - /// Bounding Box (LSAB) when the result would leave it. - /// - /// Current position. - /// Movement delta. - /// The next position after wrapping. - public FungeVector Advance(FungeVector pos, FungeVector delta) - { - if (!_hasAny) - return pos; - - var nextX = pos.X + delta.X; - var nextY = pos.Y + delta.Y; - - var width = _maxX - _minX + 1; - var height = _maxY - _minY + 1; - - if (nextX < _minX) - nextX = _maxX - ((_minX - nextX - 1) % width); - else if (nextX > _maxX) - nextX = _minX + ((nextX - _maxX - 1) % width); - - if (nextY < _minY) - nextY = _maxY - ((_minY - nextY - 1) % height); - else if (nextY > _maxY) - nextY = _minY + ((nextY - _maxY - 1) % height); - - return new FungeVector(nextX, nextY); - } -} +namespace Esolang.Funge.Parser; + +/// +/// Represents the Funge-98 program space: a sparse, conceptually infinite 3D grid of integer cells. +/// Unset cells default to the space character (ASCII 32). +/// +public sealed class FungeSpace +{ + private readonly Dictionary _cells = new(); + private int _minX, _minY, _minZ, _maxX, _maxY, _maxZ; + private bool _hasAny; + + private void IncludeInBounds(FungeVector pos) + { + if (!_hasAny) + { + _minX = _maxX = pos.X; + _minY = _maxY = pos.Y; + _minZ = _maxZ = pos.Z; + _hasAny = true; + return; + } + + if (pos.X < _minX) _minX = pos.X; + if (pos.X > _maxX) _maxX = pos.X; + if (pos.Y < _minY) _minY = pos.Y; + if (pos.Y > _maxY) _maxY = pos.Y; + if (pos.Z < _minZ) _minZ = pos.Z; + if (pos.Z > _maxZ) _maxZ = pos.Z; + } + + /// + /// Gets or sets the integer value at the given position. + /// Unset positions return ' ' (32). + /// Setting a cell to ' ' removes it from the space. + /// + public int this[FungeVector pos] + { + get => _cells.TryGetValue(pos, out var v) ? v : ' '; + set + { + if (value == ' ') + { + _cells.Remove(pos); + } + else + { + _cells[pos] = value; + IncludeInBounds(pos); + } + } + } + + /// + /// Ensures the given position is included in the Least Significant Bounding Box + /// even when the cell value is a space and therefore not explicitly stored. + /// + public void EnsureBounds(FungeVector pos) => IncludeInBounds(pos); + + /// Minimum X coordinate of the populated bounding box. + public int MinX => _minX; + + /// Minimum Y coordinate of the populated bounding box. + public int MinY => _minY; + + /// Maximum X coordinate of the populated bounding box. + public int MaxX => _maxX; + + /// Maximum Y coordinate of the populated bounding box. + public int MaxY => _maxY; + + /// Minimum Z coordinate of the populated bounding box. + public int MinZ => _minZ; + + /// Maximum Z coordinate of the populated bounding box. + public int MaxZ => _maxZ; + + /// + /// Advances a position by , wrapping around the Least Significant + /// Bounding Box (LSAB) when the result would leave it. + /// + /// Current position. + /// Movement delta. + /// The next position after wrapping. + public FungeVector Advance(FungeVector pos, FungeVector delta) + { + if (!_hasAny) + return pos; + + var nextX = pos.X + delta.X; + var nextY = pos.Y + delta.Y; + var nextZ = pos.Z + delta.Z; + + var width = _maxX - _minX + 1; + var height = _maxY - _minY + 1; + var depth = _maxZ - _minZ + 1; + + if (nextX < _minX) + nextX = _maxX - ((_minX - nextX - 1) % width); + else if (nextX > _maxX) + nextX = _minX + ((nextX - _maxX - 1) % width); + + if (nextY < _minY) + nextY = _maxY - ((_minY - nextY - 1) % height); + else if (nextY > _maxY) + nextY = _minY + ((nextY - _maxY - 1) % height); + + if (nextZ < _minZ) + nextZ = _maxZ - ((_minZ - nextZ - 1) % depth); + else if (nextZ > _maxZ) + nextZ = _minZ + ((nextZ - _maxZ - 1) % depth); + + return new FungeVector(nextX, nextY, nextZ); + } +} diff --git a/Parser/FungeVector.cs b/Parser/FungeVector.cs index 982ad40..1b559c3 100644 --- a/Parser/FungeVector.cs +++ b/Parser/FungeVector.cs @@ -1,73 +1,86 @@ -namespace Esolang.Funge.Parser; - -/// -/// Represents a 2D integer vector used for positions and deltas in Funge-98. -/// -public readonly struct FungeVector : IEquatable -{ - /// The X component. - public int X { get; } - - /// The Y component. - public int Y { get; } - - /// Initializes a new with the given components. - public FungeVector(int x, int y) { X = x; Y = y; } - - /// - public bool Equals(FungeVector other) => X == other.X && Y == other.Y; - - /// - public override bool Equals(object? obj) => obj is FungeVector v && Equals(v); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y); - - /// Equality operator. - public static bool operator ==(FungeVector left, FungeVector right) => left.Equals(right); - - /// Inequality operator. - public static bool operator !=(FungeVector left, FungeVector right) => !left.Equals(right); - - /// - public override string ToString() => $"({X}, {Y})"; - - /// Delta for East direction (right): (1, 0). - public static readonly FungeVector East = new(1, 0); - - /// Delta for West direction (left): (-1, 0). - public static readonly FungeVector West = new(-1, 0); - - /// Delta for North direction (up): (0, -1). - public static readonly FungeVector North = new(0, -1); - - /// Delta for South direction (down): (0, 1). - public static readonly FungeVector South = new(0, 1); - - /// Adds two vectors. - public static FungeVector operator +(FungeVector a, FungeVector b) => new(a.X + b.X, a.Y + b.Y); - - /// Subtracts two vectors. - public static FungeVector operator -(FungeVector a, FungeVector b) => new(a.X - b.X, a.Y - b.Y); - - /// Negates a vector. - public static FungeVector operator -(FungeVector a) => new(-a.X, -a.Y); - - /// Scales a vector by a scalar. - public static FungeVector operator *(FungeVector a, int scalar) => new(a.X * scalar, a.Y * scalar); - - /// - /// Rotates 90 degrees clockwise (Turn Right ]). - /// - public FungeVector RotateRight() => new(-Y, X); - - /// - /// Rotates 90 degrees counter-clockwise (Turn Left [). - /// - public FungeVector RotateLeft() => new(Y, -X); - - /// - /// Reflects the vector, reversing direction (r). - /// - public FungeVector Reflect() => new(-X, -Y); -} +using System.Diagnostics; + +namespace Esolang.Funge.Parser; + +/// +/// Represents a 3D integer vector used for positions and deltas in Funge-98. +/// +/// Initializes a new with the given components. +[DebuggerDisplay($"{{{nameof(ToString)}}}")] +public readonly struct FungeVector(int x, int y, int z) : IEquatable +{ + /// The X component. + public int X { get; } = x; + + /// The Y component. + public int Y { get; } = y; + + /// The Z component. + public int Z { get; } = z; + + /// Initializes a new in 2D (Z=0). + public FungeVector(int x, int y) : this(x, y, 0) { } + + /// + public bool Equals(FungeVector other) => X == other.X && Y == other.Y && Z == other.Z; + + /// + public override bool Equals(object? obj) => obj is FungeVector v && Equals(v); + + /// + public override int GetHashCode() => HashCode.Combine(HashCode.Combine(X, Y), Z); + + /// Equality operator. + public static bool operator ==(FungeVector left, FungeVector right) => left.Equals(right); + + /// Inequality operator. + public static bool operator !=(FungeVector left, FungeVector right) => !left.Equals(right); + + /// + public override string ToString() => $"({X}, {Y}, {Z})"; + + /// Delta for East direction (right): (1, 0). + public static readonly FungeVector East = new(1, 0); + + /// Delta for West direction (left): (-1, 0). + public static readonly FungeVector West = new(-1, 0); + + /// Delta for North direction (up): (0, -1). + public static readonly FungeVector North = new(0, -1); + + /// Delta for South direction (down): (0, 1). + public static readonly FungeVector South = new(0, 1); + + /// Delta for High direction (towards -Z): (0, 0, -1). + public static readonly FungeVector High = new(0, 0, -1); + + /// Delta for Low direction (towards +Z): (0, 0, 1). + public static readonly FungeVector Low = new(0, 0, 1); + + /// Adds two vectors. + public static FungeVector operator +(FungeVector a, FungeVector b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); + + /// Subtracts two vectors. + public static FungeVector operator -(FungeVector a, FungeVector b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); + + /// Negates a vector. + public static FungeVector operator -(FungeVector a) => new(-a.X, -a.Y, -a.Z); + + /// Scales a vector by a scalar. + public static FungeVector operator *(FungeVector a, int scalar) => new(a.X * scalar, a.Y * scalar, a.Z * scalar); + + /// + /// Rotates 90 degrees clockwise (Turn Right ]). + /// + public FungeVector RotateRight() => new(-Y, X, Z); + + /// + /// Rotates 90 degrees counter-clockwise (Turn Left [). + /// + public FungeVector RotateLeft() => new(Y, -X, Z); + + /// + /// Reflects the vector, reversing direction (r). + /// + public FungeVector Reflect() => new(-X, -Y, -Z); +} diff --git a/Parser/README.md b/Parser/README.md index 4cf754b..4707813 100644 --- a/Parser/README.md +++ b/Parser/README.md @@ -8,8 +8,8 @@ This library provides the fundamental data structures for representing and manip | Type | Description | |---|---| -| `FungeVector` | Immutable 2D coordinate `(X, Y)` | -| `FungeSpace` | Sparse infinite 2D grid of integer cells (space = 32) | +| `FungeVector` | Immutable 3D coordinate `(X, Y, Z)` | +| `FungeSpace` | Sparse infinite 3D grid of integer cells (space = 32) | | `FungeParser` | Parses Funge-98 source text into a `FungeSpace` | ## Installation diff --git a/Parser/Shared/HashCode.cs b/Parser/Shared/HashCode.cs index 82827ce..96fdf12 100644 --- a/Parser/Shared/HashCode.cs +++ b/Parser/Shared/HashCode.cs @@ -1,15 +1,15 @@ -#if !NETSTANDARD2_1_OR_GREATER && !NET5_0_OR_GREATER -// Minimal HashCode polyfill for netstandard2.0 -namespace System; - -internal static class HashCode -{ - public static int Combine(T1 v1, T2 v2) - { - var h1 = v1?.GetHashCode() ?? 0; - var h2 = v2?.GetHashCode() ?? 0; - var rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27); - return ((int)rol5 + h1) ^ h2; - } -} -#endif +#if !NETSTANDARD2_1_OR_GREATER && !NET5_0_OR_GREATER +// Minimal HashCode polyfill for netstandard2.0 +namespace System; + +internal static class HashCode +{ + public static int Combine(T1 v1, T2 v2) + { + var h1 = v1?.GetHashCode() ?? 0; + var h2 = v2?.GetHashCode() ?? 0; + var rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27); + return ((int)rol5 + h1) ^ h2; + } +} +#endif diff --git a/Parser/Shared/IsExternalInit.cs b/Parser/Shared/IsExternalInit.cs index bf98ee8..1330bba 100644 --- a/Parser/Shared/IsExternalInit.cs +++ b/Parser/Shared/IsExternalInit.cs @@ -1,8 +1,8 @@ -#if !NET5_0_OR_GREATER -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace System.Runtime.CompilerServices; - -internal sealed class IsExternalInit { } -#endif +#if !NET5_0_OR_GREATER +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Runtime.CompilerServices; + +internal sealed class IsExternalInit { } +#endif diff --git a/Processor.Tests/FungeProcessorTests.cs b/Processor.Tests/FungeProcessorTests.cs index ff52784..ac14038 100644 --- a/Processor.Tests/FungeProcessorTests.cs +++ b/Processor.Tests/FungeProcessorTests.cs @@ -1,226 +1,342 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Esolang.Funge.Processor.Tests; - -[TestClass] -public class FungeProcessorTests -{ - public TestContext TestContext { get; set; } = default!; - - private string Run(string source, string? input = null) - { - var space = Parser.FungeParser.Parse(source); - var output = new StringWriter(); - var reader = input is null ? TextReader.Null : new StringReader(input); - var proc = new FungeProcessor(space, output, reader); - proc.Run(TestContext.CancellationTokenSource.Token); - return output.ToString(); - } - - private int RunGetExitCode(string source) - { - var space = Parser.FungeParser.Parse(source); - var proc = new FungeProcessor(space, TextWriter.Null, TextReader.Null); - return proc.Run(TestContext.CancellationTokenSource.Token); - } - - // ── Termination ──────────────────────────────────────────────────────── - - [TestMethod] - [Timeout(5000)] - public void Stop_EmptyProgram_Wraps() - { - // No @ → program loops but should terminate via cancellation - // Just ensure an immediate @ exits - var result = Run("@"); - Assert.AreEqual(string.Empty, result); - } - - [TestMethod] - public void Quit_ReturnsExitCode() - => Assert.AreEqual(8, RunGetExitCode("42*q")); - - // ── Output ──────────────────────────────────────────────────────────── - - [TestMethod] - public void OutputChar_SingleChar() - => Assert.AreEqual("H", Run("\"H\",@")); - - [TestMethod] - public void OutputInt_WithTrailingSpace() - => Assert.AreEqual("10 ", Run("55+.@")); - - // ── Arithmetic ──────────────────────────────────────────────────────── - - [TestMethod] - public void Add() - => Assert.AreEqual("7 ", Run("34+.@")); - - [TestMethod] - public void Subtract() - => Assert.AreEqual("2 ", Run("53-.@")); - - [TestMethod] - public void Multiply() - => Assert.AreEqual("12 ", Run("34*.@")); - - [TestMethod] - public void Divide() - => Assert.AreEqual("1 ", Run("96/.@")); - - [TestMethod] - public void Remainder() - => Assert.AreEqual("1 ", Run("72%.@")); - - [TestMethod] - public void GreaterThan_True() - => Assert.AreEqual("1 ", Run("53`.@")); - - [TestMethod] - public void GreaterThan_False() - => Assert.AreEqual("0 ", Run("35`.@")); - - [TestMethod] - public void LogicalNot_Zero() - => Assert.AreEqual("1 ", Run("0!.@")); - - [TestMethod] - public void LogicalNot_NonZero() - => Assert.AreEqual("0 ", Run("5!.@")); - - // ── Stack ───────────────────────────────────────────────────────────── - - [TestMethod] - public void Duplicate() - => Assert.AreEqual("5 5 ", Run("5:..@")); - - [TestMethod] - public void Swap() - => Assert.AreEqual("5 3 ", Run("53\\..@")); - - [TestMethod] - public void Pop_Discard() - => Assert.AreEqual("5 ", Run("53$.@")); - - // ── Direction ───────────────────────────────────────────────────────── - - [TestMethod] -#pragma warning disable IDE0022 - public void EastWestIf_Zero_GoesEast() - { - // 0_ → East → . outputs next pop (0) then @ - Assert.AreEqual("0 ", Run("0_.@")); - } -#pragma warning restore IDE0022 - - [TestMethod] - [Timeout(5000)] -#pragma warning disable IDE0022 - public void NorthSouthIf_NonZero_GoesNorth() - { - const string source = "v @\n>1|"; - Assert.AreEqual(string.Empty, Run(source)); - } -#pragma warning restore IDE0022 - - [TestMethod] - [Timeout(5000)] -#pragma warning disable IDE0022 - public void EastWestIf_NonZero_GoesWest() - { - // "1_" at positions 0-1. After '_', go West, wrap to rightmost char... - // Hard to test in single row. Use '@' placement. - // "1_@" → goes West to nothing... let's try another approach - // Just verify we can stop: if nonzero, go West; space wraps; '@' at start doesn't help - // Skip complex direction tests here; covered by Hello World test below - Assert.AreEqual(string.Empty, Run("1_@")); // goes West, wraps, hits '_' etc. – eventually '@' or loops - } -#pragma warning restore IDE0022 - - // ── Hex digits ──────────────────────────────────────────────────────── - - [TestMethod] - public void HexDigits() - => Assert.AreEqual("15 14 13 12 11 10 ", Run("abcdef......@")); - - // ── String mode ─────────────────────────────────────────────────────── - - [TestMethod] -#pragma warning disable IDE0022 - public void StringMode_PushesChars() - { - // "Hi" pushes 'H'=72 then 'i'=105; i is on top - Assert.AreEqual("iH", Run("\"Hi\",,@")); - } -#pragma warning restore IDE0022 - - [TestMethod] - public void StringMode_ContiguousSpaces_PushSingleSpace() - => Assert.AreEqual("49 ", Run("\" 1\".,@")); - - // ── Trampoline ──────────────────────────────────────────────────────── - - [TestMethod] - [Timeout(5000)] -#pragma warning disable IDE0022 - public void Trampoline_SkipsOne() - { - // "#.@" → skip '.', execute '@' → empty output - Assert.AreEqual(string.Empty, Run("#.@")); - } -#pragma warning restore IDE0022 - - [TestMethod] - public void SgmlSpaces_DoNotReflect() - => Assert.AreEqual("1 ", Run("1\t\f\v.@")); - - // ── FungeSpace get/put ──────────────────────────────────────────────── - - [TestMethod] -#pragma warning disable IDE0022 - public void GetPut_ReadWrite() - { - // p pops y,x,v. Build v=65 via 8*8+1, then store at (5,0) and read back. - Assert.AreEqual("65 ", Run("88*1+50p50g.@")); - } -#pragma warning restore IDE0022 - - // ── Hello World ─────────────────────────────────────────────────────── - - [TestMethod] - [Timeout(5000)] -#pragma warning disable IDE0022 - public void HelloWorld_Classic() - { - // Classic Befunge-98 Hello World (one-liner) - const string src = "\"olleH\">:#,_@"; - Assert.AreEqual("Hello", Run(src)); - } -#pragma warning restore IDE0022 - - [TestMethod] - [Timeout(5000)] -#pragma warning disable IDE0022 - public void HelloWorld_WithExclamation() - { - const string src = "\"!dlroW ,olleH\">:#,_@"; - Assert.AreEqual("Hello, World!", Run(src)); - } -#pragma warning restore IDE0022 - - // ── Input ───────────────────────────────────────────────────────────── - - [TestMethod] - public void InputChar_EchoBack() - => Assert.AreEqual("A", Run("~,@", "A")); - - [TestMethod] - public void InputInt_EchoBack() - => Assert.AreEqual("42 ", Run("&.@", "42\n")); - - // ── Quit exit code ──────────────────────────────────────────────────── - - [TestMethod] - public void Quit_ExitCode7() - => Assert.AreEqual(7, RunGetExitCode("7q")); -} +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Esolang.Funge.Processor.Tests; + +[TestClass] +public class FungeProcessorTests +{ + public TestContext TestContext { get; set; } = default!; + + private string Run(string source, string? input = null) + { + var space = Parser.FungeParser.Parse(source); + var output = new StringWriter(); + var reader = input is null ? TextReader.Null : new StringReader(input); + var proc = new FungeProcessor(space, output, reader); + proc.Run(TestContext.CancellationTokenSource.Token); + return output.ToString(); + } + + private int RunGetExitCode(string source) + { + var space = Parser.FungeParser.Parse(source); + var proc = new FungeProcessor(space, TextWriter.Null, TextReader.Null); + return proc.Run(TestContext.CancellationTokenSource.Token); + } + + private static string EncodeZeroGnirts(string value) + => $"0\"{new string(value.Reverse().ToArray())}\""; + + // ── Termination ──────────────────────────────────────────────────────── + + [TestMethod] + [Timeout(5000)] + public void Stop_EmptyProgram_Wraps() + { + // No @ → program loops but should terminate via cancellation + // Just ensure an immediate @ exits + var result = Run("@"); + Assert.AreEqual(string.Empty, result); + } + + [TestMethod] + public void Quit_ReturnsExitCode() + => Assert.AreEqual(8, RunGetExitCode("42*q")); + + // ── Output ──────────────────────────────────────────────────────────── + + [TestMethod] + public void OutputChar_SingleChar() + => Assert.AreEqual("H", Run("\"H\",@")); + + [TestMethod] + public void OutputInt_WithTrailingSpace() + => Assert.AreEqual("10 ", Run("55+.@")); + + // ── Arithmetic ──────────────────────────────────────────────────────── + + [TestMethod] + public void Add() + => Assert.AreEqual("7 ", Run("34+.@")); + + [TestMethod] + public void Subtract() + => Assert.AreEqual("2 ", Run("53-.@")); + + [TestMethod] + public void Multiply() + => Assert.AreEqual("12 ", Run("34*.@")); + + [TestMethod] + public void Divide() + => Assert.AreEqual("1 ", Run("96/.@")); + + [TestMethod] + public void Remainder() + => Assert.AreEqual("1 ", Run("72%.@")); + + [TestMethod] + public void GreaterThan_True() + => Assert.AreEqual("1 ", Run("53`.@")); + + [TestMethod] + public void GreaterThan_False() + => Assert.AreEqual("0 ", Run("35`.@")); + + [TestMethod] + public void LogicalNot_Zero() + => Assert.AreEqual("1 ", Run("0!.@")); + + [TestMethod] + public void LogicalNot_NonZero() + => Assert.AreEqual("0 ", Run("5!.@")); + + // ── Stack ───────────────────────────────────────────────────────────── + + [TestMethod] + public void Duplicate() + => Assert.AreEqual("5 5 ", Run("5:..@")); + + [TestMethod] + public void Swap() + => Assert.AreEqual("5 3 ", Run("53\\..@")); + + [TestMethod] + public void Pop_Discard() + => Assert.AreEqual("5 ", Run("53$.@")); + + // ── Direction ───────────────────────────────────────────────────────── + + [TestMethod] +#pragma warning disable IDE0022 + public void EastWestIf_Zero_GoesEast() + { + // 0_ → East → . outputs next pop (0) then @ + Assert.AreEqual("0 ", Run("0_.@")); + } +#pragma warning restore IDE0022 + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] +#pragma warning disable IDE0022 + public void NorthSouthIf_NonZero_GoesNorth() + { + const string source = "v @\n>1|"; + Assert.AreEqual(string.Empty, Run(source)); + } +#pragma warning restore IDE0022 + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] +#pragma warning disable IDE0022 + public void EastWestIf_NonZero_GoesWest() + { + // "1_" at positions 0-1. After '_', go West, wrap to rightmost char... + // Hard to test in single row. Use '@' placement. + // "1_@" → goes West to nothing... let's try another approach + // Just verify we can stop: if nonzero, go West; space wraps; '@' at start doesn't help + // Skip complex direction tests here; covered by Hello World test below + Assert.AreEqual(string.Empty, Run("1_@")); // goes West, wraps, hits '_' etc. – eventually '@' or loops + } +#pragma warning restore IDE0022 + + // ── Hex digits ──────────────────────────────────────────────────────── + + [TestMethod] + public void HexDigits() + => Assert.AreEqual("15 14 13 12 11 10 ", Run("abcdef......@")); + + // ── String mode ─────────────────────────────────────────────────────── + + [TestMethod] +#pragma warning disable IDE0022 + public void StringMode_PushesChars() + { + // "Hi" pushes 'H'=72 then 'i'=105; i is on top + Assert.AreEqual("iH", Run("\"Hi\",,@")); + } +#pragma warning restore IDE0022 + + [TestMethod] + public void StringMode_ContiguousSpaces_PushSingleSpace() + => Assert.AreEqual("49 ", Run("\" 1\".,@")); + + // ── Trampoline ──────────────────────────────────────────────────────── + + [TestMethod] + [Timeout(5000)] +#pragma warning disable IDE0022 + public void Trampoline_SkipsOne() + { + // "#.@" → skip '.', execute '@' → empty output + Assert.AreEqual(string.Empty, Run("#.@")); + } +#pragma warning restore IDE0022 + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] + public void SgmlSpaces_DoNotReflect() + => Assert.AreEqual("1 ", Run("1\t\v.@")); + + // ── FungeSpace get/put ──────────────────────────────────────────────── + + [TestMethod] +#pragma warning disable IDE0022 + public void GetPut_ReadWrite() + { + // p pops z,y,x,v. Build v=65 via 8*8+1, then store at (5,0,0) and read back. + Assert.AreEqual("65 ", Run("88*1+500p500g.@")); + } +#pragma warning restore IDE0022 + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] + public void GoHigh_ChangesDeltaToNegativeZ() + => Assert.AreEqual(7, RunGetExitCode("h\f\f>7q")); + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] + public void GoLow_ChangesDeltaToPositiveZ() + => Assert.AreEqual(7, RunGetExitCode("l\f>7q")); + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] + public void HighLowIf_Zero_GoesLow() + => Assert.AreEqual(1, RunGetExitCode("0m\f >1q\f >2q")); + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] + public void HighLowIf_NonZero_GoesHigh() + => Assert.AreEqual(2, RunGetExitCode("1m\f >1q\f >2q")); + + // ── Hello World ─────────────────────────────────────────────────────── + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] +#pragma warning disable IDE0022 + public void HelloWorld_Classic() + { + // Classic Befunge-98 Hello World (one-liner) + const string src = "\"olleH\">:#,_@"; + Assert.AreEqual("Hello", Run(src)); + } +#pragma warning restore IDE0022 + + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] +#pragma warning disable IDE0022 + public void HelloWorld_WithExclamation() + { + const string src = "\"!dlroW ,olleH\">:#,_@"; + Assert.AreEqual("Hello, World!", Run(src)); + } +#pragma warning restore IDE0022 + + // ── Input ───────────────────────────────────────────────────────────── + + [TestMethod] + public void InputChar_EchoBack() + => Assert.AreEqual("A", Run("~,@", "A")); + + [TestMethod] + public void InputInt_EchoBack() + => Assert.AreEqual("42 ", Run("&.@", "42\n")); + + [TestMethod] + public void InputFile_LoadsFileIntoSpace() + { + var originalDir = Directory.GetCurrentDirectory(); + var tempDir = Path.Combine(Path.GetTempPath(), $"funge-io-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + Directory.SetCurrentDirectory(tempDir); + File.WriteAllText("input.txt", "A"); + + // Va=(0,0,0), flags=0 (text mode), STR=0"input.txt" (0gnirts) + var output = Run("00000\"txt.tupni\"in000g.@"); + Assert.AreEqual("65 ", output); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + Directory.Delete(tempDir, recursive: true); + } + } + + [TestMethod] + public void OutputFile_WritesSpaceRegion() + { + var originalDir = Directory.GetCurrentDirectory(); + var tempDir = Path.Combine(Path.GetTempPath(), $"funge-io-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + Directory.SetCurrentDirectory(tempDir); + + // store 'A' at (0,0,0), then output Va=(0,0,0), Vb=(0,0,0), flags=0, STR=0"output.txt" + _ = Run("88*1+000p00000000\"txt.tuptuo\"o@"); + + var bytes = File.ReadAllBytes(Path.Combine(tempDir, "output.txt")); + CollectionAssert.AreEqual(new byte[] { 65 }, bytes); + } + finally + { + Directory.SetCurrentDirectory(originalDir); + Directory.Delete(tempDir, recursive: true); + } + } + + [TestMethod] + public void SysInfo_ReportsFileIoSupportFlags() + => Assert.AreEqual("15 ", Run("1y.@")); + + [TestMethod] + public void SystemExec_ReturnsExitCode() + { + const string command = "exit 7"; + var program = $"{EncodeZeroGnirts(command)}=.@"; + Assert.AreEqual("7 ", Run(program)); + } + + [TestMethod] + public void SystemExec_SetsNonZeroOnCommandFailure() + { + const string command = "this_command_should_not_exist_12345"; + var program = $"{EncodeZeroGnirts(command)}=q"; + Assert.AreNotEqual(0, RunGetExitCode(program)); + } + + // ── Quit exit code ──────────────────────────────────────────────────── + + [TestMethod] + public void Quit_ExitCode7() + => Assert.AreEqual(7, RunGetExitCode("7q")); + + [TestMethod] + public void RunToEnd_UsesProvidedTextIo() + { + var space = Parser.FungeParser.Parse("&.@"); + var output = new StringWriter(); + var input = new StringReader("42\n"); + var proc = new FungeProcessor(space, TextWriter.Null, TextReader.Null); + + var exitCode = proc.RunToEnd(input, output, TestContext.CancellationTokenSource.Token); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual("42 ", output.ToString()); + } + + [TestMethod] + public async Task RunToEndAsync_ReturnsExitCode() + { + var space = Parser.FungeParser.Parse("7q"); + var proc = new FungeProcessor(space, TextWriter.Null, TextReader.Null); + + var exitCode = await proc.RunToEndAsync(cancellationToken: TestContext.CancellationTokenSource.Token); + + Assert.AreEqual(7, exitCode); + } +} diff --git a/Processor/Esolang.Funge.Processor.csproj b/Processor/Esolang.Funge.Processor.csproj index 27e025f..3c44c82 100644 --- a/Processor/Esolang.Funge.Processor.csproj +++ b/Processor/Esolang.Funge.Processor.csproj @@ -20,6 +20,11 @@ false + + + + + diff --git a/Processor/FungeProcessor.cs b/Processor/FungeProcessor.cs index bddd335..b031c60 100644 --- a/Processor/FungeProcessor.cs +++ b/Processor/FungeProcessor.cs @@ -1,618 +1,907 @@ -using Esolang.Funge.Parser; - -namespace Esolang.Funge.Processor; - -/// -/// Executes a Funge-98 program loaded into a . -/// Supports the full core instruction set including concurrent IPs (t) and -/// the stack stack ({/}/u). -/// Fingerprints ((/)) and file I/O (i/o) reflect (not implemented). -/// 3-D instructions (h/l/m) reflect in 2-D mode. -/// -public sealed class FungeProcessor -{ - private readonly FungeSpace _space; - private readonly TextWriter _output; - private readonly TextReader _input; - private readonly Random _random = new(); - private int _nextIpId; - - /// - /// Initializes a new with the given program space and optional I/O. - /// - /// The parsed Funge-98 program space. - /// Output writer; defaults to . - /// Input reader; defaults to . - public FungeProcessor(FungeSpace space, TextWriter? output = null, TextReader? input = null) - { - _space = space; - _output = output ?? Console.Out; - _input = input ?? Console.In; - } - - /// - /// Runs the Funge-98 program and returns the process exit code. - /// The program starts with a single IP at (0,0) moving East. - /// - /// Token to cancel execution. - /// Exit code: 0 unless the program used q. - public int Run(CancellationToken cancellationToken = default) - { - var ips = new LinkedList(); - ips.AddFirst(new InstructionPointer(_nextIpId++)); - var exitCode = 0; - var quit = false; - - while (ips.Count > 0 && !quit && !cancellationToken.IsCancellationRequested) - { - var node = ips.First!; - while (node is not null && !quit && !cancellationToken.IsCancellationRequested) - { - var nextNode = node.Next; - var ip = node.Value; - - var suppressAdvance = false; - ExecuteInstruction(ip, ips, node, ref exitCode, ref quit, ref suppressAdvance); - - if (ip.IsStopped || quit) - { - ips.Remove(node); - } - else if (!suppressAdvance) - { - ip.Position = _space.Advance(ip.Position, ip.Delta); - } - - node = nextNode; - } - } - - return exitCode; - } - - private void ExecuteInstruction( - InstructionPointer ip, - LinkedList ips, - LinkedListNode ipNode, - ref int exitCode, - ref bool quit, - ref bool suppressAdvance, - int? overrideCell = null) - { - var cell = overrideCell ?? _space[ip.Position]; - - // String mode: push each character until closing " - if (ip.StringMode) - { - if (cell == '"') - { - ip.StringMode = false; - } - else if (cell is ' ' or '\t' or '\f' or '\v') - { - // Funge-98 stringmode treats contiguous spaces SGML-style: - // one pushed space, one tick. - ip.StackStack.Push(' '); - while (true) - { - var next = _space.Advance(ip.Position, ip.Delta); - var nextCell = _space[next]; - if (nextCell is ' ' or '\t' or '\f' or '\v') - { - ip.Position = next; - } - else - { - break; - } - } - } - else - { - ip.StackStack.Push(cell); - } - return; - } - - switch (cell) - { - // ── No-ops ────────────────────────────────────────────────────── - case ' ': // Space: no-op (IP passes through) - case '\t': // SGML space: tab - case '\f': // SGML space: form feed - case '\v': // SGML space: vertical tab - case 'z': // z: explicit no-op - break; - - // ── Stack manipulation ─────────────────────────────────────────── - case '!': // Logical Not - ip.StackStack.Push(ip.StackStack.Pop() == 0 ? 1 : 0); - break; - - case '$': // Pop - ip.StackStack.Pop(); - break; - - case ':': // Duplicate - { - var v = ip.StackStack.Pop(); - ip.StackStack.Push(v); - ip.StackStack.Push(v); - break; - } - - case '\\': // Swap - { - int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); - ip.StackStack.Push(b); - ip.StackStack.Push(a); - break; - } - - case 'n': // Clear Stack - ip.StackStack.ClearToss(); - break; - - // ── Arithmetic ─────────────────────────────────────────────────── - case '+': - { - int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); - ip.StackStack.Push(a + b); - break; - } - - case '-': - { - int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); - ip.StackStack.Push(a - b); - break; - } - - case '*': - { - int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); - ip.StackStack.Push(a * b); - break; - } - - case '/': - { - int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); - ip.StackStack.Push(b == 0 ? 0 : a / b); - break; - } - - case '%': // Remainder - { - int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); - ip.StackStack.Push(b == 0 ? 0 : a % b); - break; - } - - case '`': // Greater Than - { - int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); - ip.StackStack.Push(a > b ? 1 : 0); - break; - } - - // ── Digit/hex pushers ──────────────────────────────────────────── - case '0' or '1' or '2' or '3' or '4' - or '5' or '6' or '7' or '8' or '9': - ip.StackStack.Push(cell - '0'); - break; - - case 'a': ip.StackStack.Push(10); break; - case 'b': ip.StackStack.Push(11); break; - case 'c': ip.StackStack.Push(12); break; - case 'd': ip.StackStack.Push(13); break; - case 'e': ip.StackStack.Push(14); break; - case 'f': ip.StackStack.Push(15); break; - - // ── Direction ─────────────────────────────────────────────────── - case '>': ip.Delta = FungeVector.East; break; - case '<': ip.Delta = FungeVector.West; break; - case '^': ip.Delta = FungeVector.North; break; - case 'v': ip.Delta = FungeVector.South; break; - - case '?': // Go Away: random cardinal direction - ip.Delta = _random.Next(4) switch - { - 0 => FungeVector.East, - 1 => FungeVector.West, - 2 => FungeVector.North, - _ => FungeVector.South, - }; - break; - - case '_': // East-West If - ip.Delta = ip.StackStack.Pop() == 0 ? FungeVector.East : FungeVector.West; - break; - - case '|': // North-South If - ip.Delta = ip.StackStack.Pop() == 0 ? FungeVector.South : FungeVector.North; - break; - - case '[': // Turn Left (CCW 90°) - ip.Delta = ip.Delta.RotateLeft(); - break; - - case ']': // Turn Right (CW 90°) - ip.Delta = ip.Delta.RotateRight(); - break; - - case 'r': // Reflect - ip.Delta = ip.Delta.Reflect(); - break; - - case 'x': // Absolute Delta - { - int dy = ip.StackStack.Pop(), dx = ip.StackStack.Pop(); - ip.Delta = new FungeVector(dx, dy); - break; - } - - case 'w': // Compare - { - int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); - if (a > b) ip.Delta = ip.Delta.RotateRight(); - else if (a < b) ip.Delta = ip.Delta.RotateLeft(); - // a == b: no change (acts as 'z') - break; - } - - // ── Movement modifiers ─────────────────────────────────────────── - case '#': // Trampoline: skip next cell - ip.Position = _space.Advance(ip.Position, ip.Delta); - break; - - case 'j': // Jump Forward s cells (suppressAdvance: sets position directly) - { - var s = ip.StackStack.Pop(); - var dir = s >= 0 ? ip.Delta : ip.Delta.Reflect(); - for (var i = 0; i < Math.Abs(s); i++) - ip.Position = _space.Advance(ip.Position, dir); - suppressAdvance = true; - break; - } - - case ';': // Jump Over: skip until next ; - ip.Position = _space.Advance(ip.Position, ip.Delta); - while (_space[ip.Position] != ';') - ip.Position = _space.Advance(ip.Position, ip.Delta); - break; - - // ── Character fetch/store ──────────────────────────────────────── - case '\'': // Fetch Character: push value of next cell, skip it - ip.Position = _space.Advance(ip.Position, ip.Delta); - ip.StackStack.Push(_space[ip.Position]); - break; - - case 's': // Store Character: store to next cell, skip it - { - var val = ip.StackStack.Pop(); - ip.Position = _space.Advance(ip.Position, ip.Delta); - _space[ip.Position] = val; - break; - } - - // ── String mode ────────────────────────────────────────────────── - case '"': // Toggle Stringmode - ip.StringMode = true; - break; - - // ── FungeSpace get/put ─────────────────────────────────────────── - case 'g': // Get: read cell at (x+offset, y+offset) - { - int y = ip.StackStack.Pop(), x = ip.StackStack.Pop(); - ip.StackStack.Push(_space[new FungeVector(x + ip.Offset.X, y + ip.Offset.Y)]); - break; - } - - case 'p': // Put: write cell at (x+offset, y+offset) - { - int y = ip.StackStack.Pop(), x = ip.StackStack.Pop(); - var val = ip.StackStack.Pop(); - _space[new FungeVector(x + ip.Offset.X, y + ip.Offset.Y)] = val; - break; - } - - // ── I/O ────────────────────────────────────────────────────────── - case '.': // Output Integer - _output.Write(ip.StackStack.Pop()); - _output.Write(' '); - break; - - case ',': // Output Character - _output.Write((char)ip.StackStack.Pop()); - break; - - case '&': // Input Integer - { - var line = _input.ReadLine(); - if (line is null) { ip.Delta = ip.Delta.Reflect(); break; } - ip.StackStack.Push(int.TryParse(line.Trim(), out var v) ? v : 0); - break; - } - - case '~': // Input Character - { - var ch = _input.Read(); - if (ch < 0) ip.Delta = ip.Delta.Reflect(); - else ip.StackStack.Push(ch); - break; - } - - // ── Control flow ───────────────────────────────────────────────── - case '@': // Stop this IP - ip.IsStopped = true; - break; - - case 'q': // Quit program immediately - exitCode = ip.StackStack.Pop(); - quit = true; - break; - - case 'k': // Iterate: execute next instruction n times - { - var n = ip.StackStack.Pop(); - - // Advance past spaces AND semicolon-delimited sections to find the operand. - // Per spec, k skips spaces and ';'-enclosed regions just as normal execution would. - var instrPos = _space.Advance(ip.Position, ip.Delta); - while (true) - { - var c = _space[instrPos]; - if (c is ' ' or '\t' or '\f' or '\v') - { - instrPos = _space.Advance(instrPos, ip.Delta); - } - else if (c == ';') - { - // skip the semicolon section entirely (same as the ';' instruction) - instrPos = _space.Advance(instrPos, ip.Delta); - while (_space[instrPos] != ';') - instrPos = _space.Advance(instrPos, ip.Delta); - instrPos = _space.Advance(instrPos, ip.Delta); - } - else - { - break; - } - } - - if (n == 0) - { - // n=0: skip the operand. IP moves to instrPos, then normal advance passes it. - ip.Position = instrPos; - } - else - { - // n>0: execute the discovered operand n times, but execute it AT k. - // The operand is only searched for; its semantics apply at the current IP position. - // After k finishes, normal advancement continues from the IP's current position, - // so position-changing operands such as [ and # behave "from k". - var operand = _space[instrPos]; - for (var i = 0; i < n && !ip.IsStopped && !quit; i++) - { - var dummy = false; - ExecuteInstruction(ip, ips, ipNode, ref exitCode, ref quit, ref dummy, operand); - } - } - break; - } - - // ── Concurrency ────────────────────────────────────────────────── - case 't': // Split: create child IP with reflected delta - { - var child = ip.CreateChild(_nextIpId++); - ips.AddAfter(ipNode, child); - break; - } - - // ── Stack Stack operations ──────────────────────────────────────── - case '{': // Begin Block - { - var n = ip.StackStack.Pop(); - - // Collect n items from TOSS (top item first) - var items = new List(); - if (n > 0) - for (var i = 0; i < n; i++) items.Add(ip.StackStack.Pop()); - - // Push storage offset to current TOSS (will become SOSS) - ip.StackStack.Push(ip.Offset.X); - ip.StackStack.Push(ip.Offset.Y); - - // Push new empty stack (old TOSS becomes SOSS) - ip.StackStack.PushNewStack(); - - if (n > 0) - { - // Re-push items so original top is on top of new TOSS - for (var i = items.Count - 1; i >= 0; i--) - ip.StackStack.Push(items[i]); - } - else if (n < 0) - { - // Push |n| zeros to SOSS - var soss = ip.StackStack.SOSS!; - for (var i = 0; i < -n; i++) soss.Push(0); - } - - // Set storage offset to next cell position - ip.Offset = _space.Advance(ip.Position, ip.Delta); - break; - } - - case '}': // End Block - { - var n = ip.StackStack.Pop(); - if (!ip.StackStack.HasSOSS) - { - ip.Delta = ip.Delta.Reflect(); - break; - } - - // Collect items from TOSS - var items = new List(); - for (var i = 0; i < Math.Max(0, n); i++) items.Add(ip.StackStack.Pop()); - - // Pop current TOSS (discard remaining items) - ip.StackStack.PopCurrentStack(); - - // Restore storage offset (Y on top, then X) - var oy = ip.StackStack.Pop(); - var ox = ip.StackStack.Pop(); - ip.Offset = new FungeVector(ox, oy); - - // If n < 0, discard |n| items from (now current) TOSS - if (n < 0) - for (var i = 0; i < -n; i++) ip.StackStack.Pop(); - - // Push collected items (original top on top) - for (var i = items.Count - 1; i >= 0; i--) - ip.StackStack.Push(items[i]); - break; - } - - case 'u': // Stack Under Stack - { - var n = ip.StackStack.Pop(); - if (!ip.StackStack.HasSOSS) - { - ip.Delta = ip.Delta.Reflect(); - break; - } - var soss = ip.StackStack.SOSS!; - if (n > 0) - for (var i = 0; i < n; i++) ip.StackStack.Push(soss.Count > 0 ? soss.Pop() : 0); - else if (n < 0) - for (var i = 0; i < -n; i++) soss.Push(ip.StackStack.Pop()); - break; - } - - // ── System info ────────────────────────────────────────────────── - case 'y': // Get SysInfo - { - var c = ip.StackStack.Pop(); - PushSysInfo(ip, ips.Count, c); - break; - } - - // ── Fingerprints (reflect – not implemented) ───────────────────── - case '(': // Load Semantics - { - var n = ip.StackStack.Pop(); - for (var i = 0; i < n; i++) ip.StackStack.Pop(); - ip.Delta = ip.Delta.Reflect(); - break; - } - - case ')': // Unload Semantics - { - var n = ip.StackStack.Pop(); - for (var i = 0; i < n; i++) ip.StackStack.Pop(); - ip.Delta = ip.Delta.Reflect(); - break; - } - - // ── Optional / 3-D-only (reflect) ──────────────────────────────── - case '=': // Execute (system exec) – reflect - { - // Consume 0gnirts command string from stack - while (ip.StackStack.Pop() != 0) { } - ip.Delta = ip.Delta.Reflect(); - break; - } - - case 'i': // Input File – reflect - case 'o': // Output File – reflect - case 'h': // Go High (3-D) – reflect - case 'l': // Go Low (3-D) – reflect - case 'm': // High-Low If (3-D) – reflect - ip.Delta = ip.Delta.Reflect(); - break; - - default: - // A-Z: fingerprint-defined; reflect if not loaded - if (cell is >= 'A' and <= 'Z') - ip.Delta = ip.Delta.Reflect(); - // All other characters: no-op - break; - } - } - - /// - /// Pushes system information onto the TOSS of the given IP. - /// If is greater than zero, only item - /// (1-indexed from top) is left on the stack. - /// - private void PushSysInfo(InstructionPointer ip, int _, int c) - { - // Build list of items in order: items[0] will be last-pushed (item 1 from top) - List items = []; - - // 1. Flags: bit 0 = /t (concurrency supported) - items.Add(1); - // 2. Cell size in bytes - items.Add(4); - // 3. Interpreter handprint ("Fung" as big-endian int) - items.Add(unchecked((int)0x46756E67u)); - // 4. Version (Funge-98 = 9800) - items.Add(9800); - // 5. Operating paradigm (0 = system() unavailable) - items.Add(0); - // 6. Path separator - items.Add(Path.DirectorySeparatorChar); - // 7. Number of dimensions (2 = Befunge) - items.Add(2); - // 8. IP unique ID - items.Add(ip.Id); - // 9. IP team number - items.Add(0); - // 10-11. IP position (X, Y; Y on top) - items.Add(ip.Position.X); - items.Add(ip.Position.Y); - // 12-13. IP delta (dX, dY; dY on top) - items.Add(ip.Delta.X); - items.Add(ip.Delta.Y); - // 14-15. Storage offset (oX, oY; oY on top) - items.Add(ip.Offset.X); - items.Add(ip.Offset.Y); - // 16-17. Least point of LSAB (minX, minY; minY on top) - items.Add(_space.MinX); - items.Add(_space.MinY); - // 18-19. Greatest point of LSAB (maxX, maxY; maxY on top) - items.Add(_space.MaxX); - items.Add(_space.MaxY); - - var now = DateTime.Now; - // 20. Current date: (year-1900)*10000 + month*100 + day - items.Add(((now.Year - 1900) * 10000) + (now.Month * 100) + now.Day); - // 21. Current time: HH*10000 + MM*100 + SS - items.Add((now.Hour * 10000) + (now.Minute * 100) + now.Second); - // 22. Number of stacks in stack stack - items.Add(ip.StackStack.StackCount); - // 23+. Size of each stack (TOSS first) - foreach (var stack in ip.StackStack.AllStacks) - items.Add(stack.Count); - // Command-line args: empty list (single 0 terminator) - items.Add(0); - // Environment variables: empty list (single 0 terminator) - items.Add(0); - - // Push in reverse order so items[0] ends up on top (= item 1) - for (var i = items.Count - 1; i >= 0; i--) - ip.StackStack.Push(items[i]); - - if (c > 0) - { - // Pick the c-th item from top of pushed items - var popped = new int[items.Count]; - for (var i = 0; i < items.Count; i++) - popped[i] = ip.StackStack.Pop(); - ip.StackStack.Push(c <= items.Count ? popped[c - 1] : 0); - } - } -} +using Esolang.Funge.Parser; +using Esolang.Processor; +using System.Collections; +using System.Diagnostics; + +namespace Esolang.Funge.Processor; + +/// +/// Executes a Funge-98 program loaded into a . +/// Supports the full core instruction set including concurrent IPs (t) and +/// the stack stack ({/}/u). +/// Fingerprints ((/)) reflect (not implemented). +/// Includes Trefunge 3-D direction instructions (h/l/m). +/// +/// +/// Initializes a new with the given program space and optional I/O. +/// +/// The parsed Funge-98 program space. +/// Output writer; defaults to . +/// Input reader; defaults to . +/// Optional command-line arguments exposed by y. Defaults to host process args. +/// Optional environment variable entries (NAME=VALUE) exposed by y. Defaults to host process environment. +public sealed partial class FungeProcessor( + FungeSpace space, + TextWriter? output = null, + TextReader? input = null, + IEnumerable? commandLineArguments = null, + IEnumerable? environmentVariables = null) : ITextProcessor +{ + private readonly FungeSpace _space = space; + private readonly TextWriter _output = output ?? Console.Out; + private readonly TextReader _input = input ?? Console.In; + private readonly string[] _commandLineArguments = (commandLineArguments ?? Environment.GetCommandLineArgs()) +#pragma warning disable IDE0305 // コレクションの初期化を簡略化します + .ToArray(); +#pragma warning restore IDE0305 // コレクションの初期化を簡略化します + private readonly string[] _environmentVariables = [.. environmentVariables + ?? Environment.GetEnvironmentVariables() + .Cast() + .Select(static entry => $"{entry.Key}={entry.Value}")]; + private readonly Random _random = new(); + private int _nextIpId; + + /// + public FungeSpace Program => _space; + + /// + /// Runs the Funge-98 program and returns the process exit code. + /// The program starts with a single IP at (0,0) moving East. + /// + /// Token to cancel execution. + /// Exit code: 0 unless the program used q. + public int Run(CancellationToken cancellationToken = default) + => RunToEnd(null, null, cancellationToken); + + /// + public int RunToEnd(TextReader? input = null, TextWriter? output = null, CancellationToken cancellationToken = default) + { + var resolvedInput = input ?? _input; + var resolvedOutput = output ?? _output; + + var ips = new LinkedList(); + ips.AddFirst(new InstructionPointer(_nextIpId++)); + var exitCode = 0; + var quit = false; + + while (ips.Count > 0 && !quit && !cancellationToken.IsCancellationRequested) + { + var node = ips.First!; + while (node is not null && !quit && !cancellationToken.IsCancellationRequested) + { + var nextNode = node.Next; + var ip = node.Value; + + var suppressAdvance = false; + ExecuteInstruction(ip, ips, node, ref exitCode, ref quit, ref suppressAdvance, resolvedInput, resolvedOutput); + + if (ip.IsStopped || quit) + { + ips.Remove(node); + } + else if (!suppressAdvance) + { + ip.Position = _space.Advance(ip.Position, ip.Delta); + } + + node = nextNode; + } + } + + return exitCode; + } + + /// + public ValueTask RunToEndAsync(TextReader? input = null, TextWriter? output = null, CancellationToken cancellationToken = default) + => ValueTask.FromResult(RunToEnd(input, output, cancellationToken)); + + private void ExecuteInstruction( + InstructionPointer ip, + LinkedList ips, + LinkedListNode ipNode, + ref int exitCode, + ref bool quit, + ref bool suppressAdvance, + TextReader input, + TextWriter output, + int? overrideCell = null) + { + var cell = overrideCell ?? _space[ip.Position]; + + // String mode: push each character until closing " + if (ip.StringMode) + { + if (cell == '"') + { + ip.StringMode = false; + } + else if (cell is ' ' or '\t' or '\f' or '\v') + { + // Funge-98 stringmode treats contiguous spaces SGML-style: + // one pushed space, one tick. + ip.StackStack.Push(' '); + while (true) + { + var next = _space.Advance(ip.Position, ip.Delta); + var nextCell = _space[next]; + if (nextCell is ' ' or '\t' or '\f' or '\v') + { + ip.Position = next; + } + else + { + break; + } + } + } + else + { + ip.StackStack.Push(cell); + } + return; + } + + switch (cell) + { + // ── No-ops ────────────────────────────────────────────────────── + case ' ': // Space: no-op (IP passes through) + case '\t': // SGML space: tab + case '\f': // SGML space: form feed + case '\v': // SGML space: vertical tab + case 'z': // z: explicit no-op + break; + + // ── Stack manipulation ─────────────────────────────────────────── + case '!': // Logical Not + ip.StackStack.Push(ip.StackStack.Pop() == 0 ? 1 : 0); + break; + + case '$': // Pop + ip.StackStack.Pop(); + break; + + case ':': // Duplicate + { + var v = ip.StackStack.Pop(); + ip.StackStack.Push(v); + ip.StackStack.Push(v); + break; + } + + case '\\': // Swap + { + int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); + ip.StackStack.Push(b); + ip.StackStack.Push(a); + break; + } + + case 'n': // Clear Stack + ip.StackStack.ClearToss(); + break; + + // ── Arithmetic ─────────────────────────────────────────────────── + case '+': + { + int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); + ip.StackStack.Push(a + b); + break; + } + + case '-': + { + int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); + ip.StackStack.Push(a - b); + break; + } + + case '*': + { + int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); + ip.StackStack.Push(a * b); + break; + } + + case '/': + { + int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); + ip.StackStack.Push(b == 0 ? 0 : a / b); + break; + } + + case '%': // Remainder + { + int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); + ip.StackStack.Push(b == 0 ? 0 : a % b); + break; + } + + case '`': // Greater Than + { + int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); + ip.StackStack.Push(a > b ? 1 : 0); + break; + } + + // ── Digit/hex pushers ──────────────────────────────────────────── + case '0' or '1' or '2' or '3' or '4' + or '5' or '6' or '7' or '8' or '9': + ip.StackStack.Push(cell - '0'); + break; + + case 'a': ip.StackStack.Push(10); break; + case 'b': ip.StackStack.Push(11); break; + case 'c': ip.StackStack.Push(12); break; + case 'd': ip.StackStack.Push(13); break; + case 'e': ip.StackStack.Push(14); break; + case 'f': ip.StackStack.Push(15); break; + + // ── Direction ─────────────────────────────────────────────────── + case '>': ip.Delta = FungeVector.East; break; + case '<': ip.Delta = FungeVector.West; break; + case '^': ip.Delta = FungeVector.North; break; + case 'v': ip.Delta = FungeVector.South; break; + + case '?': // Go Away: random cardinal direction + ip.Delta = _random.Next(6) switch + { + 0 => FungeVector.East, + 1 => FungeVector.West, + 2 => FungeVector.North, + 3 => FungeVector.South, + 4 => FungeVector.High, + _ => FungeVector.Low, + }; + break; + + case 'h': // Go High (3-D) + ip.Delta = FungeVector.High; + break; + + case 'l': // Go Low (3-D) + ip.Delta = FungeVector.Low; + break; + + case 'm': // High-Low If (3-D) + ip.Delta = ip.StackStack.Pop() == 0 ? FungeVector.Low : FungeVector.High; + break; + + case '_': // East-West If + ip.Delta = ip.StackStack.Pop() == 0 ? FungeVector.East : FungeVector.West; + break; + + case '|': // North-South If + ip.Delta = ip.StackStack.Pop() == 0 ? FungeVector.South : FungeVector.North; + break; + + case '[': // Turn Left (CCW 90°) + ip.Delta = ip.Delta.RotateLeft(); + break; + + case ']': // Turn Right (CW 90°) + ip.Delta = ip.Delta.RotateRight(); + break; + + case 'r': // Reflect + ip.Delta = ip.Delta.Reflect(); + break; + + case 'x': // Absolute Delta + { + int dz = ip.StackStack.Pop(), dy = ip.StackStack.Pop(), dx = ip.StackStack.Pop(); + ip.Delta = new FungeVector(dx, dy, dz); + break; + } + + case 'w': // Compare + { + int b = ip.StackStack.Pop(), a = ip.StackStack.Pop(); + if (a > b) ip.Delta = ip.Delta.RotateRight(); + else if (a < b) ip.Delta = ip.Delta.RotateLeft(); + // a == b: no change (acts as 'z') + break; + } + + // ── Movement modifiers ─────────────────────────────────────────── + case '#': // Trampoline: skip next cell + ip.Position = _space.Advance(ip.Position, ip.Delta); + break; + + case 'j': // Jump Forward s cells (suppressAdvance: sets position directly) + { + var s = ip.StackStack.Pop(); + var dir = s >= 0 ? ip.Delta : ip.Delta.Reflect(); + for (var i = 0; i < Math.Abs(s); i++) + ip.Position = _space.Advance(ip.Position, dir); + suppressAdvance = true; + break; + } + + case ';': // Jump Over: skip until next ; + ip.Position = _space.Advance(ip.Position, ip.Delta); + while (_space[ip.Position] != ';') + ip.Position = _space.Advance(ip.Position, ip.Delta); + break; + + // ── Character fetch/store ──────────────────────────────────────── + case '\'': // Fetch Character: push value of next cell, skip it + ip.Position = _space.Advance(ip.Position, ip.Delta); + ip.StackStack.Push(_space[ip.Position]); + break; + + case 's': // Store Character: store to next cell, skip it + { + var val = ip.StackStack.Pop(); + ip.Position = _space.Advance(ip.Position, ip.Delta); + _space[ip.Position] = val; + break; + } + + // ── String mode ────────────────────────────────────────────────── + case '"': // Toggle Stringmode + ip.StringMode = true; + break; + + // ── FungeSpace get/put ─────────────────────────────────────────── + case 'g': // Get: read cell at (x+offset, y+offset, z+offset) + { + int z = ip.StackStack.Pop(), y = ip.StackStack.Pop(), x = ip.StackStack.Pop(); + ip.StackStack.Push(_space[new FungeVector(x + ip.Offset.X, y + ip.Offset.Y, z + ip.Offset.Z)]); + break; + } + + case 'p': // Put: write cell at (x+offset, y+offset, z+offset) + { + int z = ip.StackStack.Pop(), y = ip.StackStack.Pop(), x = ip.StackStack.Pop(); + var val = ip.StackStack.Pop(); + _space[new FungeVector(x + ip.Offset.X, y + ip.Offset.Y, z + ip.Offset.Z)] = val; + break; + } + + // ── I/O ────────────────────────────────────────────────────────── + case '.': // Output Integer + output.Write(ip.StackStack.Pop()); + output.Write(' '); + break; + + case ',': // Output Character + output.Write((char)ip.StackStack.Pop()); + break; + + case '&': // Input Integer + { + var line = input.ReadLine(); + if (line is null) { ip.Delta = ip.Delta.Reflect(); break; } + ip.StackStack.Push(int.TryParse(line.Trim(), out var v) ? v : 0); + break; + } + + case '~': // Input Character + { + var ch = input.Read(); + if (ch < 0) ip.Delta = ip.Delta.Reflect(); + else ip.StackStack.Push(ch); + break; + } + + case 'i': // Input File + { + if (!TryPopZeroTerminatedString(ip.StackStack, out var fileName)) + { + ip.Delta = ip.Delta.Reflect(); + break; + } + + var flags = ip.StackStack.Pop(); + var va = PopVector(ip.StackStack) + ip.Offset; + var binaryMode = (flags & 1) != 0; + + if (!TryInputFile(va, fileName, binaryMode, out var vb)) + { + ip.Delta = ip.Delta.Reflect(); + break; + } + + PushVector(ip.StackStack, va - ip.Offset); + PushVector(ip.StackStack, vb); + break; + } + + case 'o': // Output File + { + if (!TryPopZeroTerminatedString(ip.StackStack, out var fileName)) + { + ip.Delta = ip.Delta.Reflect(); + break; + } + + var flags = ip.StackStack.Pop(); + var vb = PopVector(ip.StackStack); + var va = PopVector(ip.StackStack) + ip.Offset; + var linearText = (flags & 1) != 0; + + if (!TryOutputFile(va, vb, fileName, linearText)) + ip.Delta = ip.Delta.Reflect(); + break; + } + + // ── Control flow ───────────────────────────────────────────────── + case '@': // Stop this IP + ip.IsStopped = true; + break; + + case 'q': // Quit program immediately + exitCode = ip.StackStack.Pop(); + quit = true; + break; + + case 'k': // Iterate: execute next instruction n times + { + var n = ip.StackStack.Pop(); + + // Advance past spaces AND semicolon-delimited sections to find the operand. + // Per spec, k skips spaces and ';'-enclosed regions just as normal execution would. + var instrPos = _space.Advance(ip.Position, ip.Delta); + while (true) + { + var c = _space[instrPos]; + if (c is ' ' or '\t' or '\f' or '\v') + { + instrPos = _space.Advance(instrPos, ip.Delta); + } + else if (c == ';') + { + // skip the semicolon section entirely (same as the ';' instruction) + instrPos = _space.Advance(instrPos, ip.Delta); + while (_space[instrPos] != ';') + instrPos = _space.Advance(instrPos, ip.Delta); + instrPos = _space.Advance(instrPos, ip.Delta); + } + else + { + break; + } + } + + if (n == 0) + { + // n=0: skip the operand. IP moves to instrPos, then normal advance passes it. + ip.Position = instrPos; + } + else + { + // n>0: execute the discovered operand n times, but execute it AT k. + // The operand is only searched for; its semantics apply at the current IP position. + // After k finishes, normal advancement continues from the IP's current position, + // so position-changing operands such as [ and # behave "from k". + var operand = _space[instrPos]; + for (var i = 0; i < n && !ip.IsStopped && !quit; i++) + { + var dummy = false; + ExecuteInstruction(ip, ips, ipNode, ref exitCode, ref quit, ref dummy, input, output, operand); + } + } + break; + } + + // ── Concurrency ────────────────────────────────────────────────── + case 't': // Split: create child IP with reflected delta + { + var child = ip.CreateChild(_nextIpId++); + ips.AddAfter(ipNode, child); + break; + } + + // ── Stack Stack operations ──────────────────────────────────────── + case '{': // Begin Block + { + var n = ip.StackStack.Pop(); + + // Collect n items from TOSS (top item first) + var items = new List(); + if (n > 0) + for (var i = 0; i < n; i++) items.Add(ip.StackStack.Pop()); + + // Push storage offset to current TOSS (will become SOSS) + ip.StackStack.Push(ip.Offset.X); + ip.StackStack.Push(ip.Offset.Y); + ip.StackStack.Push(ip.Offset.Z); + + // Push new empty stack (old TOSS becomes SOSS) + ip.StackStack.PushNewStack(); + + if (n > 0) + { + // Re-push items so original top is on top of new TOSS + for (var i = items.Count - 1; i >= 0; i--) + ip.StackStack.Push(items[i]); + } + else if (n < 0) + { + // Push |n| zeros to SOSS + var soss = ip.StackStack.SOSS!; + for (var i = 0; i < -n; i++) soss.Push(0); + } + + // Set storage offset to next cell position + ip.Offset = _space.Advance(ip.Position, ip.Delta); + break; + } + + case '}': // End Block + { + var n = ip.StackStack.Pop(); + if (!ip.StackStack.HasSOSS) + { + ip.Delta = ip.Delta.Reflect(); + break; + } + + // Collect items from TOSS + var items = new List(); + for (var i = 0; i < Math.Max(0, n); i++) items.Add(ip.StackStack.Pop()); + + // Pop current TOSS (discard remaining items) + ip.StackStack.PopCurrentStack(); + + // Restore storage offset (Z on top, then Y, then X) + var oz = ip.StackStack.Pop(); + var oy = ip.StackStack.Pop(); + var ox = ip.StackStack.Pop(); + ip.Offset = new FungeVector(ox, oy, oz); + + // If n < 0, discard |n| items from (now current) TOSS + if (n < 0) + for (var i = 0; i < -n; i++) ip.StackStack.Pop(); + + // Push collected items (original top on top) + for (var i = items.Count - 1; i >= 0; i--) + ip.StackStack.Push(items[i]); + break; + } + + case 'u': // Stack Under Stack + { + var n = ip.StackStack.Pop(); + if (!ip.StackStack.HasSOSS) + { + ip.Delta = ip.Delta.Reflect(); + break; + } + var soss = ip.StackStack.SOSS!; + if (n > 0) + for (var i = 0; i < n; i++) ip.StackStack.Push(soss.Count > 0 ? soss.Pop() : 0); + else if (n < 0) + for (var i = 0; i < -n; i++) soss.Push(ip.StackStack.Pop()); + break; + } + + // ── System info ────────────────────────────────────────────────── + case 'y': // Get SysInfo + { + var c = ip.StackStack.Pop(); + PushSysInfo(ip, ips.Count, c); + break; + } + + // ── Fingerprints (reflect – not implemented) ───────────────────── + case '(': // Load Semantics + { + var n = ip.StackStack.Pop(); + for (var i = 0; i < n; i++) ip.StackStack.Pop(); + ip.Delta = ip.Delta.Reflect(); + break; + } + + case ')': // Unload Semantics + { + var n = ip.StackStack.Pop(); + for (var i = 0; i < n; i++) ip.StackStack.Pop(); + ip.Delta = ip.Delta.Reflect(); + break; + } + + // ── Optional (reflect) ──────────────────────────────────────────── + case '=': // Execute (system exec) + { + if (!TryPopZeroTerminatedString(ip.StackStack, out var command)) + { + ip.Delta = ip.Delta.Reflect(); + break; + } + + ip.StackStack.Push(ExecuteSystemCommand(command)); + break; + } + + default: + // A-Z: fingerprint-defined; reflect if not loaded + if (cell is >= 'A' and <= 'Z') + ip.Delta = ip.Delta.Reflect(); + // All other characters: no-op + break; + } + } + + private static FungeVector PopVector(StackStack stack) + { + var z = stack.Pop(); + var y = stack.Pop(); + var x = stack.Pop(); + return new FungeVector(x, y, z); + } + + private static void PushVector(StackStack stack, FungeVector vector) + { + stack.Push(vector.X); + stack.Push(vector.Y); + stack.Push(vector.Z); + } + + private static bool TryPopZeroTerminatedString(StackStack stack, out string result) + { + var chars = new List(); + while (true) + { + var value = stack.Pop(); + if (value == 0) + { + result = new string([.. chars]); + return true; + } + + if (value is < char.MinValue or > char.MaxValue) + { + result = string.Empty; + return false; + } + + chars.Add((char)value); + } + } + + private bool TryInputFile(FungeVector leastPoint, string fileName, bool binaryMode, out FungeVector size) + { + size = new FungeVector(0, 0, 0); + + byte[] bytes; + try + { + bytes = File.ReadAllBytes(fileName); + } + catch + { + return false; + } + + var x = 0; + var y = 0; + var z = 0; + var wroteAny = false; + var maxX = 0; + var maxY = 0; + var maxZ = 0; + + foreach (var raw in bytes) + { + var cell = (int)raw; + + if (!binaryMode) + { + if (cell == '\r') + continue; + if (cell == '\n') + { + x = 0; + y++; + continue; + } + if (cell == '\f') + { + x = 0; + y = 0; + z++; + continue; + } + if (cell is '\t' or '\v') + cell = ' '; + } + + var pos = new FungeVector(leastPoint.X + x, leastPoint.Y + y, leastPoint.Z + z); + _space.EnsureBounds(pos); + + if (binaryMode || cell != ' ') + _space[pos] = cell; + + wroteAny = true; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + if (z > maxZ) maxZ = z; + x++; + } + + size = wroteAny ? new FungeVector(maxX, maxY, maxZ) : new FungeVector(0, 0, 0); + return true; + } + + private bool TryOutputFile(FungeVector leastPoint, FungeVector size, string fileName, bool linearText) + { + var sx = Math.Max(0, size.X); + var sy = Math.Max(0, size.Y); + var sz = Math.Max(0, size.Z); + + var rows = new List(); + for (var z = 0; z <= sz; z++) + { + for (var y = 0; y <= sy; y++) + { + var chars = new char[sx + 1]; + for (var x = 0; x <= sx; x++) + { + var c = _space[new FungeVector(leastPoint.X + x, leastPoint.Y + y, leastPoint.Z + z)]; + chars[x] = c is >= char.MinValue and <= char.MaxValue ? (char)c : ' '; + } + + var row = new string(chars); + rows.Add(linearText ? row.TrimEnd(' ') : row); + } + + if (z != sz) + rows.Add("\f"); + } + + if (linearText) + { + while (rows.Count > 0 && rows[^1].Length == 0) + rows.RemoveAt(rows.Count - 1); + } + + var text = string.Join("\n", rows); + var bytes = text.Select(static ch => (byte)(ch & 0xFF)).ToArray(); + + try + { + File.WriteAllBytes(fileName, bytes); + return true; + } + catch + { + return false; + } + } + + private static int ExecuteSystemCommand(string command) + { + try + { + var processStartInfo = new ProcessStartInfo + { + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + CreateNoWindow = true, + }; + + if (OperatingSystem.IsWindows()) + { + processStartInfo.FileName = "cmd.exe"; + processStartInfo.ArgumentList.Add("/c"); + processStartInfo.ArgumentList.Add(command); + } + else + { + processStartInfo.FileName = "/bin/sh"; + processStartInfo.ArgumentList.Add("-c"); + processStartInfo.ArgumentList.Add(command); + } + + using var process = Process.Start(processStartInfo); + if (process is null) + return -1; + + process.WaitForExit(); + return process.ExitCode; + } + catch + { + return -1; + } + } + + /// + /// Pushes system information onto the TOSS of the given IP. + /// If is greater than zero, only item + /// (1-indexed from top) is left on the stack. + /// + private void PushSysInfo(InstructionPointer ip, int _, int c) + { + // Build list of items in order: items[0] will be last-pushed (item 1 from top) + List items = []; + + // 1. Flags: /t + /i + /o + /= supported + items.Add(0x01 | 0x02 | 0x04 | 0x08); + // 2. Cell size in bytes + items.Add(4); + // 3. Interpreter handprint ("Fung" as big-endian int) + items.Add(unchecked((int)0x46756E67u)); + // 4. Version (Funge-98 = 9800) + items.Add(9800); + // 5. Operating paradigm (1 = equivalent to C system() behavior) + items.Add(1); + // 6. Path separator + items.Add(Path.DirectorySeparatorChar); + // 7. Number of dimensions (3 = Trefunge) + items.Add(3); + // 8. IP unique ID + items.Add(ip.Id); + // 9. IP team number + items.Add(0); + // 10-12. IP position (X, Y, Z; Z on top) + items.Add(ip.Position.X); + items.Add(ip.Position.Y); + items.Add(ip.Position.Z); + // 13-15. IP delta (dX, dY, dZ; dZ on top) + items.Add(ip.Delta.X); + items.Add(ip.Delta.Y); + items.Add(ip.Delta.Z); + // 16-18. Storage offset (oX, oY, oZ; oZ on top) + items.Add(ip.Offset.X); + items.Add(ip.Offset.Y); + items.Add(ip.Offset.Z); + // 19-21. Least point of LSAB (minX, minY, minZ; minZ on top) + items.Add(_space.MinX); + items.Add(_space.MinY); + items.Add(_space.MinZ); + // 22-24. Greatest point relative to least point (max-min) + items.Add(_space.MaxX - _space.MinX); + items.Add(_space.MaxY - _space.MinY); + items.Add(_space.MaxZ - _space.MinZ); + + var now = DateTime.Now; + // 20. Current date: (year-1900)*256*256 + month*256 + day + items.Add(((now.Year - 1900) * 256 * 256) + (now.Month * 256) + now.Day); + // 21. Current time: HH*256*256 + MM*256 + SS + items.Add((now.Hour * 256 * 256) + (now.Minute * 256) + now.Second); + // 22. Number of stacks in stack stack + items.Add(ip.StackStack.StackCount); + // 23+. Size of each stack (TOSS first) + foreach (var stack in ip.StackStack.AllStacks) + items.Add(stack.Count); + // Command-line args: null-terminated strings, series terminated by extra null. + foreach (var arg in _commandLineArguments) + { + foreach (var ch in arg) + items.Add(ch); + items.Add(0); + } + // Series terminator + items.Add(0); + + // Environment variables: null-terminated strings, series terminated by extra null. + foreach (var env in _environmentVariables) + { + foreach (var ch in env) + items.Add(ch); + items.Add(0); + } + // Series terminator + items.Add(0); + + // Push in reverse order so items[0] ends up on top (= item 1) + for (var i = items.Count - 1; i >= 0; i--) + ip.StackStack.Push(items[i]); + + if (c > 0) + { + // y with positive c: keep only the c-th item from the top of the full stack. + // This naturally allows "pick" behavior when c exceeds y's own payload length. + var snapshot = ip.StackStack.TOSS.ToArray(); // top-first + var picked = c <= snapshot.Length ? snapshot[c - 1] : 0; + + for (var i = 0; i < items.Count; i++) + ip.StackStack.Pop(); + + ip.StackStack.Push(picked); + } + } +} diff --git a/Processor/InstructionPointer.cs b/Processor/InstructionPointer.cs index 1908353..3dec861 100644 --- a/Processor/InstructionPointer.cs +++ b/Processor/InstructionPointer.cs @@ -1,54 +1,54 @@ -using Esolang.Funge.Parser; - -namespace Esolang.Funge.Processor; - -/// -/// Represents the execution state of a single Instruction Pointer (IP) in Funge-98. -/// -public sealed class InstructionPointer -{ - /// Gets the unique identifier for this IP. - public int Id { get; } - - /// Gets or sets the current position in FungeSpace. - public FungeVector Position { get; set; } - - /// Gets or sets the current movement delta (direction). - public FungeVector Delta { get; set; } = FungeVector.East; - - /// Gets or sets the storage offset used by g/p instructions. - public FungeVector Offset { get; set; } - - /// Gets the stack stack for this IP. - public StackStack StackStack { get; } - - /// Gets or sets whether this IP is in string mode. - public bool StringMode { get; set; } - - /// Gets or sets whether this IP has been stopped (by @). - public bool IsStopped { get; set; } - - /// Initializes an IP with the given ID and a new empty stack stack. - public InstructionPointer(int id) : this(id, new StackStack()) { } - - private InstructionPointer(int id, StackStack stackStack) - { - Id = id; - StackStack = stackStack; - } - - /// - /// Creates a child IP for the t (Split) instruction. - /// The child shares the same position, a deep copy of the stack stack, - /// and a reflected delta. - /// - /// Unique ID for the new child IP. - /// The new child IP. - public InstructionPointer CreateChild(int newId) => new(newId, StackStack.Clone()) - { - Position = Position, - Delta = Delta.Reflect(), - Offset = Offset, - StringMode = StringMode, - }; -} +using Esolang.Funge.Parser; + +namespace Esolang.Funge.Processor; + +/// +/// Represents the execution state of a single Instruction Pointer (IP) in Funge-98. +/// +public sealed class InstructionPointer +{ + /// Gets the unique identifier for this IP. + public int Id { get; } + + /// Gets or sets the current position in FungeSpace. + public FungeVector Position { get; set; } + + /// Gets or sets the current movement delta (direction). + public FungeVector Delta { get; set; } = FungeVector.East; + + /// Gets or sets the storage offset used by g/p instructions. + public FungeVector Offset { get; set; } + + /// Gets the stack stack for this IP. + public StackStack StackStack { get; } + + /// Gets or sets whether this IP is in string mode. + public bool StringMode { get; set; } + + /// Gets or sets whether this IP has been stopped (by @). + public bool IsStopped { get; set; } + + /// Initializes an IP with the given ID and a new empty stack stack. + public InstructionPointer(int id) : this(id, new StackStack()) { } + + private InstructionPointer(int id, StackStack stackStack) + { + Id = id; + StackStack = stackStack; + } + + /// + /// Creates a child IP for the t (Split) instruction. + /// The child shares the same position, a deep copy of the stack stack, + /// and a reflected delta. + /// + /// Unique ID for the new child IP. + /// The new child IP. + public InstructionPointer CreateChild(int newId) => new(newId, StackStack.Clone()) + { + Position = Position, + Delta = Delta.Reflect(), + Offset = Offset, + StringMode = StringMode, + }; +} diff --git a/Processor/README.md b/Processor/README.md index d9ca457..3addb62 100644 --- a/Processor/README.md +++ b/Processor/README.md @@ -14,7 +14,7 @@ It implements the full Funge-98 core instruction set including concurrent Instru | Stack | `0`–`9` `a`–`f` (push), `:` (dup), `$` (pop), `\` (swap), `n` (clear) | | Arithmetic | `+` `-` `*` `/` `%` | | Comparison | `` ` `` `!` | -| Direction | `>` `<` `^` `v` `?` `[` `]` `r` `x` | +| Direction | `>` `<` `^` `v` `h` `l` `?` `[` `]` `r` `x` `m` | | Movement | `#` (trampoline), `;` (jump over), `j` (jump forward) | | String mode | `"` | | Branching | `_` `|` `w` | @@ -24,11 +24,11 @@ It implements the full Funge-98 core instruction set including concurrent Instru | Concurrency | `t` (split IP) | | Stack stack | `{` `}` `u` | | Misc | `z` (no-op), `q` (quit) | -| Reflected | `(` `)` `i` `o` `h` `l` `m` (fingerprints / 3-D / file I/O not implemented) | +| Reflected | `(` `)` (fingerprints not implemented) | ## Funge-98 Compliance -Targets **Befunge-98** (2D). Trefunge-98 and fingerprint extensions are intentionally out of scope. +Targets **Funge-98** with 3D navigation (`h`/`l`/`m`). Fingerprint extensions are intentionally out of scope. | Category | Instructions | Status | |---|---|---| @@ -45,12 +45,12 @@ Targets **Befunge-98** (2D). Trefunge-98 and fingerprint extensions are intentio | I/O | `.` `,` `&` `~` | ✅ | | Concurrency | `t` | ✅ | | Stack stack | `{` `}` `u` | ✅ | -| System info | `y` | 🟡 env vars / command-line args are empty | +| System info | `y` | ✅ | | Misc | `z` `@` `q` | ✅ | -| File I/O | `i` `o` | ❌ reflects (not implemented) | -| System exec | `=` | ❌ reflects (not implemented) | +| File I/O | `i` `o` | ✅ | +| System exec | `=` | ✅ | | Fingerprints | `(` `)` `A`–`Z` | ❌ reflects (not implemented) | -| 3D (Trefunge) | `h` `l` `m` | ❌ reflects (2D only) | +| 3D (Trefunge) | `h` `l` `m` | ✅ | ## Installation diff --git a/Processor/StackStack.cs b/Processor/StackStack.cs index 5f67a20..d101cde 100644 --- a/Processor/StackStack.cs +++ b/Processor/StackStack.cs @@ -1,65 +1,65 @@ -namespace Esolang.Funge.Processor; - -/// -/// Implements the Funge-98 stack stack: a stack of stacks. -/// The topmost stack is the TOSS (Top Of Stack Stack). -/// The second stack (if present) is the SOSS (Second On Stack Stack). -/// -public sealed class StackStack -{ - private readonly LinkedList> _stacks = new(); - - /// Initializes a new stack stack with a single empty TOSS. - public StackStack() => _stacks.AddFirst(new Stack()); - - private StackStack(LinkedList> stacks) => _stacks = stacks; - - /// Gets the top-of-stack-stack (current active stack). - public Stack TOSS => _stacks.First!.Value; - - /// Gets the second-on-stack-stack, or if there is only one stack. - public Stack? SOSS => _stacks.Count >= 2 ? _stacks.First!.Next!.Value : null; - - /// Gets whether a SOSS exists. - public bool HasSOSS => _stacks.Count >= 2; - - /// Gets the total number of stacks in the stack stack. - public int StackCount => _stacks.Count; - - /// Pushes a value onto the TOSS. - public void Push(int value) => TOSS.Push(value); - - /// Pops a value from the TOSS. Returns 0 if the TOSS is empty. - public int Pop() => TOSS.Count > 0 ? TOSS.Pop() : 0; - - /// Peeks at the top of the TOSS without removing it. Returns 0 if empty. - public int Peek() => TOSS.Count > 0 ? TOSS.Peek() : 0; - - /// Pushes a new empty stack onto the stack stack, making it the new TOSS. - public void PushNewStack() => _stacks.AddFirst(new Stack()); - - /// - /// Pops the current TOSS from the stack stack (discarding its remaining contents), - /// making the previous SOSS the new TOSS. Does nothing if there is only one stack. - /// - public void PopCurrentStack() - { - if (_stacks.Count > 1) - _stacks.RemoveFirst(); - } - - /// Clears all items from the TOSS. - public void ClearToss() => TOSS.Clear(); - - /// Enumerates all stacks from TOSS downward. - public IEnumerable> AllStacks => _stacks; - - /// Creates a deep copy of this stack stack. - public StackStack Clone() - { - var newList = new LinkedList>(); - foreach (var stack in _stacks) - newList.AddLast(new Stack(stack.Reverse())); - return new StackStack(newList); - } -} +namespace Esolang.Funge.Processor; + +/// +/// Implements the Funge-98 stack stack: a stack of stacks. +/// The topmost stack is the TOSS (Top Of Stack Stack). +/// The second stack (if present) is the SOSS (Second On Stack Stack). +/// +public sealed class StackStack +{ + private readonly LinkedList> _stacks = new(); + + /// Initializes a new stack stack with a single empty TOSS. + public StackStack() => _stacks.AddFirst(new Stack()); + + private StackStack(LinkedList> stacks) => _stacks = stacks; + + /// Gets the top-of-stack-stack (current active stack). + public Stack TOSS => _stacks.First!.Value; + + /// Gets the second-on-stack-stack, or if there is only one stack. + public Stack? SOSS => _stacks.Count >= 2 ? _stacks.First!.Next!.Value : null; + + /// Gets whether a SOSS exists. + public bool HasSOSS => _stacks.Count >= 2; + + /// Gets the total number of stacks in the stack stack. + public int StackCount => _stacks.Count; + + /// Pushes a value onto the TOSS. + public void Push(int value) => TOSS.Push(value); + + /// Pops a value from the TOSS. Returns 0 if the TOSS is empty. + public int Pop() => TOSS.Count > 0 ? TOSS.Pop() : 0; + + /// Peeks at the top of the TOSS without removing it. Returns 0 if empty. + public int Peek() => TOSS.Count > 0 ? TOSS.Peek() : 0; + + /// Pushes a new empty stack onto the stack stack, making it the new TOSS. + public void PushNewStack() => _stacks.AddFirst(new Stack()); + + /// + /// Pops the current TOSS from the stack stack (discarding its remaining contents), + /// making the previous SOSS the new TOSS. Does nothing if there is only one stack. + /// + public void PopCurrentStack() + { + if (_stacks.Count > 1) + _stacks.RemoveFirst(); + } + + /// Clears all items from the TOSS. + public void ClearToss() => TOSS.Clear(); + + /// Enumerates all stacks from TOSS downward. + public IEnumerable> AllStacks => _stacks; + + /// Creates a deep copy of this stack stack. + public StackStack Clone() + { + var newList = new LinkedList>(); + foreach (var stack in _stacks) + newList.AddLast(new Stack(stack.Reverse())); + return new StackStack(newList); + } +} diff --git a/README.md b/README.md index 336c05a..ed30492 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,33 @@ For runnable examples covering all return types and inline source, see: - [UseConsole sample](./samples/Generator.UseConsole/README.md) +## Funge-98 Support Status + +Current implementation status across packages: + +| Area | Status | +|---|---| +| Core Funge-98 instructions | ✅ Implemented | +| Trefunge 3D navigation (`h` `l` `m`) | ✅ Implemented | +| Coordinates and storage space | ✅ 3D (`X`,`Y`,`Z`) | +| Fingerprints (`(` `)` / `A`-`Z`) | ❌ Not implemented (reflect) | +| File I/O (`i` `o`) | ✅ Implemented | +| System exec (`=`) | ✅ Implemented | + +Details: + +- Parser behavior and space model: [Parser README](./Parser/README.md) +- Runtime execution and instruction compliance: [Processor README](./Processor/README.md) +- Generated runtime subset and limitations: [Generator README](./Generator/README.md) +- CLI behavior and scope: [Interpreter README](./Interpreter/README.md) + ## Install ```bash dotnet add package Esolang.Funge.Generator dotnet add package Esolang.Funge.Parser dotnet add package Esolang.Funge.Processor -dotnet tool install -g dotnet-funge --prerelease +dotnet tool install -g dotnet-funge ``` ## Choose Package diff --git a/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.cs b/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.cs index 0289113..b6abc91 100644 --- a/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.cs +++ b/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.cs @@ -1,55 +1,62 @@ -using Esolang.Funge; -using System.Text; - -// string return (file-based) -Console.WriteLine($"{nameof(FungeSample.HelloWorld)}: {FungeSample.HelloWorld()}"); - -// Task return (file-based) -Console.WriteLine($"{nameof(FungeSample.HelloWorldAsync)}: {await FungeSample.HelloWorldAsync()}"); - -// void with TextWriter output (file-based) -using var textWriter = new StringWriter(); -FungeSample.HelloWorldWriter(textWriter); -Console.WriteLine($"{nameof(FungeSample.HelloWorldWriter)}: {textWriter}"); - -// IEnumerable (file-based) -Console.WriteLine($"{nameof(FungeSample.HelloWorldBytes)}: {Encoding.UTF8.GetString(FungeSample.HelloWorldBytes().ToArray())}"); - -// IAsyncEnumerable (file-based) -Console.WriteLine($"{nameof(FungeSample.HelloWorldBytesAsync)}: {Encoding.UTF8.GetString(await ToByteArrayAsync(FungeSample.HelloWorldBytesAsync()))}"); - -// Inline source — same "Hello, World!" program embedded as a string literal -Console.WriteLine($"{nameof(FungeSample.HelloWorldInline)}: {FungeSample.HelloWorldInline()}"); - -static async Task ToByteArrayAsync(IAsyncEnumerable source) -{ - var list = new List(); - await foreach (var b in source) - list.Add(b); - return [.. list]; -} - -namespace Esolang.Funge -{ - partial class FungeSample - { - [GenerateFungeMethod("Programs/hello.b98")] - public static partial string HelloWorld(); - - [GenerateFungeMethod("Programs/hello.b98")] - public static partial Task HelloWorldAsync(); - - [GenerateFungeMethod("Programs/hello.b98")] - public static partial void HelloWorldWriter(System.IO.TextWriter output); - - [GenerateFungeMethod("Programs/hello.b98")] - public static partial IEnumerable HelloWorldBytes(); - - [GenerateFungeMethod("Programs/hello.b98")] - public static partial IAsyncEnumerable HelloWorldBytesAsync(); - - // InlineSource: no .b98 file needed — Funge-98 code is embedded directly - [GenerateFungeMethod(InlineSource = "64+\"!dlroW ,olleH\">:#,_@")] - public static partial string HelloWorldInline(); - } -} +using Esolang.Funge; +using System.Text; + +// string return (file-based) +Console.WriteLine($"{nameof(FungeSample.HelloWorld)}: {FungeSample.HelloWorld()}"); + +// Task return (file-based) +Console.WriteLine($"{nameof(FungeSample.HelloWorldAsync)}: {await FungeSample.HelloWorldAsync()}"); + +// void with TextWriter output (file-based) +using var textWriter = new StringWriter(); +FungeSample.HelloWorldWriter(textWriter); +Console.WriteLine($"{nameof(FungeSample.HelloWorldWriter)}: {textWriter}"); + +// IEnumerable (file-based) +Console.WriteLine($"{nameof(FungeSample.HelloWorldBytes)}: {Encoding.UTF8.GetString(FungeSample.HelloWorldBytes().ToArray())}"); + +// IAsyncEnumerable (file-based) +Console.WriteLine($"{nameof(FungeSample.HelloWorldBytesAsync)}: {Encoding.UTF8.GetString(await ToByteArrayAsync(FungeSample.HelloWorldBytesAsync()))}"); + +// Inline source — same "Hello, World!" program embedded as a string literal +Console.WriteLine($"{nameof(FungeSample.HelloWorldInline)}: {FungeSample.HelloWorldInline()}"); + +// 3D (Trefunge) — layer Z=0 uses 'l' to jump into layer Z=1 where Hello World runs +Console.WriteLine($"{nameof(FungeSample.HelloWorld3D)}: {FungeSample.HelloWorld3D()}"); + +static async Task ToByteArrayAsync(IAsyncEnumerable source) +{ + var list = new List(); + await foreach (var b in source) + list.Add(b); + return [.. list]; +} + +namespace Esolang.Funge +{ + partial class FungeSample + { + [GenerateFungeMethod("Programs/hello.b98")] + public static partial string HelloWorld(); + + [GenerateFungeMethod("Programs/hello.b98")] + public static partial Task HelloWorldAsync(); + + [GenerateFungeMethod("Programs/hello.b98")] + public static partial void HelloWorldWriter(TextWriter output); + + [GenerateFungeMethod("Programs/hello.b98")] + public static partial IEnumerable HelloWorldBytes(); + + [GenerateFungeMethod("Programs/hello.b98")] + public static partial IAsyncEnumerable HelloWorldBytesAsync(); + + // InlineSource: no .b98 file needed — Funge-98 code is embedded directly + [GenerateFungeMethod(InlineSource = "64+\"!dlroW ,olleH\">:#,_@")] + public static partial string HelloWorldInline(); + + // 3D (Trefunge): layer Z=0 executes 'l' (go low) to enter layer Z=1 where Hello World runs + [GenerateFungeMethod("Programs/hello3d.b98")] + public static partial string HelloWorld3D(); + } +} diff --git a/samples/Generator.UseConsole/Programs/hello3d.b98 b/samples/Generator.UseConsole/Programs/hello3d.b98 new file mode 100644 index 0000000..70a552f --- /dev/null +++ b/samples/Generator.UseConsole/Programs/hello3d.b98 @@ -0,0 +1 @@ +l >64+"!dlroW ,olleH">:#,_@ \ No newline at end of file diff --git a/samples/Generator.UseConsole/README.md b/samples/Generator.UseConsole/README.md index 37099dc..f8df6a8 100644 --- a/samples/Generator.UseConsole/README.md +++ b/samples/Generator.UseConsole/README.md @@ -6,17 +6,18 @@ ## プロジェクト構成 -``` +```text samples/Generator.UseConsole/ ├── Programs/ -│ └── hello.b98 # Funge-98 ソースファイル +│ ├── hello.b98 # 2D Hello World +│ └── hello3d.b98 # 3D (Trefunge) Hello World ├── Esolang.Funge.Generator.UseConsole.cs # サンプルコード(top-level statements) └── Esolang.Funge.Generator.UseConsole.csproj ``` ### hello.b98 -``` +```.b98 64+"!dlroW ,olleH">:#,_@ ``` @@ -72,13 +73,14 @@ namespace Esolang.Funge このサンプルでは以下のすべての戻り型を示しています。 | メソッド | 宣言 | 説明 | -|---|---|---| +| --- | --- | --- | | `HelloWorld` | `partial string HelloWorld()` | 出力を文字列として返す | | `HelloWorldAsync` | `partial Task HelloWorldAsync()` | 非同期で出力を文字列として返す | | `HelloWorldWriter` | `partial void HelloWorldWriter(TextWriter output)` | `TextWriter` に出力を書き込む | | `HelloWorldBytes` | `partial IEnumerable HelloWorldBytes()` | 出力バイトを同期で列挙する | | `HelloWorldBytesAsync` | `partial IAsyncEnumerable HelloWorldBytesAsync()` | 出力バイトを非同期で列挙する | | `HelloWorldInline` | `partial string HelloWorldInline()` | インラインソース(後述) | +| `HelloWorld3D` | `partial string HelloWorld3D()` | 3Dソース(`\f`でZレイヤー分割) | ## インラインソース @@ -98,17 +100,18 @@ public static partial string HelloWorldInline(); ## 実行 -``` +```bash dotnet run --framework net10.0 ``` 期待される出力: -``` +```text HelloWorld: Hello, World! HelloWorldAsync: Hello, World! HelloWorldWriter: Hello, World! HelloWorldBytes: Hello, World! HelloWorldBytesAsync: Hello, World! HelloWorldInline: Hello, World! +HelloWorld3D: Hello, World! ```