diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..60ca27f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,242 @@
+# Remove the next line to inherit .editorconfig settings from parent directories.
+root = true
+
+# C# files
+[*.cs]
+
+#### Core EditorConfig options ####
+
+# Indentation and spacing
+indent_size = 4
+indent_style = space
+tab_width = 4
+
+# Newline settings
+end_of_line = crlf
+insert_final_newline = true
+
+#### .NET coding conventions ####
+
+# using directive organization
+dotnet_separate_import_directive_groups = false
+dotnet_sort_system_directives_first = false
+file_header_template = unset
+
+# this./Me. qualification settings
+dotnet_style_qualification_for_event = false:warning
+dotnet_style_qualification_for_field = false
+dotnet_style_qualification_for_method = false:warning
+dotnet_style_qualification_for_property = false:warning
+
+# Language keyword and BCL type preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:warning
+dotnet_style_predefined_type_for_member_access = true:warning
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning
+
+# Accessibility modifier settings
+dotnet_style_require_accessibility_modifiers = for_non_interface_members
+
+# Expression-level preferences
+dotnet_style_coalesce_expression = true:warning
+dotnet_style_collection_initializer = true:warning
+dotnet_style_explicit_tuple_names = true:warning
+dotnet_style_namespace_match_folder = true
+dotnet_style_null_propagation = true:warning
+dotnet_style_object_initializer = true:warning
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+dotnet_style_prefer_auto_properties = true
+dotnet_style_prefer_compound_assignment = true:warning
+dotnet_style_prefer_conditional_expression_over_assignment = true
+dotnet_style_prefer_conditional_expression_over_return = true
+dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning
+dotnet_style_prefer_inferred_tuple_names = true:warning
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
+dotnet_style_prefer_simplified_boolean_expressions = true:warning
+dotnet_style_prefer_simplified_interpolation = true
+
+# Field preferences
+dotnet_style_readonly_field = true:warning
+
+# Parameter settings
+dotnet_code_quality_unused_parameters = all:warning
+
+# Suppression settings
+dotnet_remove_unnecessary_suppression_exclusions = none
+
+# Newline settings
+dotnet_style_allow_multiple_blank_lines_experimental = true
+dotnet_style_allow_statement_immediately_after_block_experimental = true
+
+#### C# coding conventions ####
+
+# Prefer var
+csharp_style_var_elsewhere = true:warning
+csharp_style_var_for_built_in_types = true:warning
+csharp_style_var_when_type_is_apparent = true:warning
+
+# Expression-bodied member preferences
+csharp_style_expression_bodied_accessors = true:warning
+csharp_style_expression_bodied_constructors = true:warning
+csharp_style_expression_bodied_indexers = true:warning
+csharp_style_expression_bodied_lambdas = true:warning
+csharp_style_expression_bodied_local_functions = true:warning
+csharp_style_expression_bodied_methods = true:warning
+csharp_style_expression_bodied_operators = true:warning
+csharp_style_expression_bodied_properties = true:warning
+
+# Pattern matching settings
+csharp_style_pattern_matching_over_as_with_null_check = true:warning
+csharp_style_pattern_matching_over_is_with_cast_check = true:warning
+csharp_style_prefer_extended_property_pattern = true
+csharp_style_prefer_not_pattern = true:warning
+csharp_style_prefer_pattern_matching = true:warning
+csharp_style_prefer_switch_expression = true:warning
+
+# Null-check settings
+csharp_style_conditional_delegate_call = true:warning
+
+# Modifier settings
+csharp_prefer_static_local_function = true:warning
+csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
+csharp_style_prefer_readonly_struct = true
+csharp_style_prefer_readonly_struct_member = true
+
+# Code block settings
+csharp_prefer_braces = true:silent
+csharp_prefer_simple_using_statement = true:warning
+csharp_style_namespace_declarations = file_scoped:warning
+csharp_style_prefer_method_group_conversion = true:silent
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_style_prefer_top_level_statements = true:silent
+
+# Expression-level settings
+csharp_prefer_simple_default_expression = true:warning
+csharp_style_deconstructed_variable_declaration = true:warning
+csharp_style_implicit_object_creation_when_type_is_apparent = true:warning
+csharp_style_inlined_variable_declaration = true:warning
+csharp_style_prefer_index_operator = true:warning
+csharp_style_prefer_local_over_anonymous_function = true:warning
+csharp_style_prefer_null_check_over_type_check = true:warning
+csharp_style_prefer_range_operator = true:warning
+csharp_style_prefer_tuple_swap = true
+csharp_style_prefer_utf8_string_literals = true
+csharp_style_throw_expression = true:warning
+csharp_style_unused_value_assignment_preference = discard_variable
+csharp_style_unused_value_expression_statement_preference = discard_variable
+
+# Basic using directive settings
+csharp_using_directive_placement = outside_namespace:warning
+
+# Newline settings
+csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
+csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
+csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
+csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true
+csharp_style_allow_embedded_statements_on_same_line_experimental = true
+
+#### C# formatting rules ####
+
+# Newline settings
+csharp_new_line_before_catch = true
+csharp_new_line_before_else = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_open_brace = all
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation settings
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_labels = one_less_than_current
+csharp_indent_switch_labels = true
+
+# Spacing settings
+csharp_space_after_cast = false
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_after_comma = true
+csharp_space_after_dot = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_around_declaration_statements = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_before_comma = false
+csharp_space_before_dot = false
+csharp_space_before_open_square_brackets = false
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+csharp_space_between_square_brackets = false
+
+# Wrapping settings
+csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = true
+
+#### Naming styles ####
+
+# Naming rules
+
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.severity = warning
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+# Symbol specifications
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+# Naming style definitions
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+[*.{cs,vb}]
+tab_width = 4
+indent_size = 4
+dotnet_style_qualification_for_field = false:silent
+dotnet_style_qualification_for_property = false:warning
+dotnet_style_qualification_for_method = false:warning
+dotnet_style_qualification_for_event = false:warning
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000..5796056
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,84 @@
+name: build and test
+
+on:
+ push:
+ pull_request:
+ branches: [ master ]
+ paths:
+ - '**.cs'
+ - '**.csproj'
+ - '**.slnx'
+ - '.github/workflows/**'
+
+env:
+ DOTNET_VERSION: '10.0.x'
+ NUGET_SOURCE: 'https://api.nuget.org/v3/index.json'
+
+jobs:
+ build-and-test:
+
+ name: build-and-test-${{matrix.os}}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macOS-latest]
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Install dependencies
+ run: dotnet restore --source "${{ env.NUGET_SOURCE }}"
+
+ - name: Build
+ run: dotnet build --no-restore
+
+ - name: Test
+ run: dotnet test --no-restore --verbosity normal
+
+ - name: Tool E2E (pack/install/run)
+ if: ${{ matrix.os == 'ubuntu-latest' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ rm -rf .artifacts/tool-e2e
+ mkdir -p .artifacts/tool-e2e
+
+ dotnet pack Interpreter/Esolang.Funge.Interpreter.csproj -o .artifacts/tool-e2e
+
+ nupkg_path=$(find .artifacts/tool-e2e -maxdepth 1 -type f -name 'dotnet-funge.*.nupkg' | head -n 1)
+ if [ -z "$nupkg_path" ]; then
+ echo "Failed to find dotnet-funge nupkg in .artifacts/tool-e2e"
+ exit 1
+ fi
+ nupkg_name=$(basename "$nupkg_path")
+ tool_version=${nupkg_name#dotnet-funge.}
+ tool_version=${tool_version%.nupkg}
+ echo "Detected tool version: $tool_version"
+
+ dotnet tool install dotnet-funge \
+ --tool-path .artifacts/tool-e2e/path \
+ --add-source .artifacts/tool-e2e \
+ --version "$tool_version"
+
+ ./.artifacts/tool-e2e/path/dotnet-funge --help
+
+ output=$(./.artifacts/tool-e2e/path/dotnet-funge "samples/Generator.UseConsole/Programs/hello.b98")
+ echo "$output"
+ test "$output" = "Hello, World!"
+
+ dotnet tool uninstall dotnet-funge --tool-path .artifacts/tool-e2e/path
+
+ - name: Pack
+ if: ${{ matrix.os == 'ubuntu-latest' }}
+ run: |
+ dotnet pack -o artifacts/
+
+ - uses: actions/upload-artifact@v4
+ if: ${{ matrix.os == 'ubuntu-latest' }}
+ with:
+ name: artifacts
+ path: artifacts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..829dbe1
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,116 @@
+name: release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Existing git tag to publish
+ required: true
+ type: string
+
+permissions:
+ contents: write
+ packages: write
+
+env:
+ DOTNET_VERSION: '10.0.x'
+ NUGET_SOURCE: 'https://api.nuget.org/v3/index.json'
+
+jobs:
+ publish:
+ name: publish-packages-and-release
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
+ fetch-depth: 0
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+ - name: Restore
+ run: dotnet restore --source "${{ env.NUGET_SOURCE }}"
+ - name: Build
+ run: dotnet build --configuration Release --no-restore
+ - name: Test
+ run: dotnet test --configuration Release --no-build --verbosity normal
+ - name: Pack
+ run: |
+ dotnet pack Generator/Esolang.Funge.Generator.csproj -c Release -o artifacts/nuget
+ dotnet pack Parser/Esolang.Funge.Parser.csproj -c Release -o artifacts/nuget
+ dotnet pack Processor/Esolang.Funge.Processor.csproj -c Release -o artifacts/nuget
+ dotnet pack Interpreter/Esolang.Funge.Interpreter.csproj -c Release -o artifacts/nuget
+ - name: Publish to NuGet.org
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ if [ -z "${NUGET_API_KEY:-}" ]; then
+ echo "NUGET_API_KEY secret is not configured."
+ exit 1
+ fi
+ shopt -s nullglob
+ for pkg in artifacts/nuget/*.nupkg; do
+ case "$pkg" in
+ *.snupkg)
+ continue
+ ;;
+ esac
+ dotnet nuget push "$pkg" \
+ --api-key "$NUGET_API_KEY" \
+ --source https://api.nuget.org/v3/index.json \
+ --skip-duplicate
+ done
+ for symbol_pkg in artifacts/nuget/*.snupkg; do
+ dotnet nuget push "$symbol_pkg" \
+ --api-key "$NUGET_API_KEY" \
+ --source https://api.nuget.org/v3/index.json \
+ --skip-duplicate
+ done
+ - name: Publish to GitHub Packages
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ for pkg in artifacts/nuget/*.nupkg; do
+ case "$pkg" in
+ *.snupkg)
+ continue
+ ;;
+ esac
+ dotnet nuget push "$pkg" \
+ --api-key "$GITHUB_TOKEN" \
+ --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \
+ --skip-duplicate
+ done
+ - name: Create GitHub Release and Upload Assets
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ tag="${{ github.event.inputs.tag || github.ref_name }}"
+ shopt -s nullglob
+ assets=(artifacts/nuget/*.nupkg artifacts/nuget/*.snupkg)
+ prerelease_flag=""
+ if [[ "$tag" == *-* ]]; then
+ prerelease_flag="--prerelease"
+ fi
+ if gh release view "$tag" >/dev/null 2>&1; then
+ if [ ${#assets[@]} -gt 0 ]; then
+ gh release upload "$tag" "${assets[@]}" --clobber
+ fi
+ else
+ gh release create "$tag" \
+ "${assets[@]}" \
+ --title "$tag" \
+ --generate-notes \
+ $prerelease_flag
+ fi
diff --git a/.gitignore b/.gitignore
index d5a18de..0808c4a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,10 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
-## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
# User-specific files
*.rsuser
@@ -9,7 +12,6 @@
*.user
*.userosscache
*.sln.docstates
-*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
@@ -22,37 +24,17 @@ mono_crash.*
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
-
-[Dd]ebug/x64/
-[Dd]ebugPublic/x64/
-[Rr]elease/x64/
-[Rr]eleases/x64/
-bin/x64/
-obj/x64/
-
-[Dd]ebug/x86/
-[Dd]ebugPublic/x86/
-[Rr]elease/x86/
-[Rr]eleases/x86/
-bin/x86/
-obj/x86/
-
+x64/
+x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
-[Aa][Rr][Mm]64[Ee][Cc]/
bld/
+[Bb]in/
[Oo]bj/
-[Oo]ut/
[Ll]og/
[Ll]ogs/
-# Build results on 'Bin' directories
-**/[Bb]in/*
-# Uncomment if you have tasks that rely on *.refresh files to move binaries
-# (https://github.com/github/gitignore/pull/3736)
-#!**/[Bb]in/*.refresh
-
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
@@ -64,16 +46,12 @@ Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
-*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
-# Approval Tests result files
-*.received.*
-
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
@@ -82,11 +60,13 @@ dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
-# .NET Core
+# .NET
project.lock.json
project.fragment.lock.json
artifacts/
-.artifacts/
+
+# Tye
+.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
@@ -101,7 +81,6 @@ StyleCopReport.xml
*.ilk
*.meta
*.obj
-*.idb
*.iobj
*.pch
*.pdb
@@ -182,7 +161,6 @@ coverage*.info
# NCrunch
_NCrunch_*
-.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
@@ -324,6 +302,9 @@ node_modules/
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
@@ -341,22 +322,22 @@ node_modules/
_Pvt_Extensions
# Paket dependency manager
-**/.paket/paket.exe
+.paket/paket.exe
paket-files/
# FAKE - F# Make
-**/.fake/
+.fake/
# CodeRush personal settings
-**/.cr/personal
+.cr/personal
# Python Tools for Visual Studio (PTVS)
-**/__pycache__/
+__pycache__/
*.pyc
# Cake - Uncomment if you are using it
-#tools/**
-#!tools/packages.config
+# tools/**
+# !tools/packages.config
# Tabs Studio
*.tss
@@ -378,19 +359,15 @@ ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
-MSBuild_Logs/
-
-# AWS SAM Build and Temporary Artifacts folder
-.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
-**/.mfractor/
+.mfractor/
# Local History for Visual Studio
-**/.localhistory/
+.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
@@ -402,7 +379,7 @@ healthchecksdb
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
-**/.ionide/
+.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
@@ -413,17 +390,93 @@ FodyWeavers.xsd
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
-!.vscode/*.code-snippets
+*.code-workspace
# Local History for Visual Studio Code
.history/
-# Built Visual Studio Code Extensions
-*.vsix
-
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0bd9106
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,24 @@
+# Changelog
+
+All notable changes to this repository are documented in this file.
+
+The format is based on Keep a Changelog.
+
+## [Unreleased]
+
+## [1.0.0] - 2026-05-06
+
+### Added
+
+- Initial implementation of Funge-98 (Befunge-98) parser, processor and interpreter.
+- `Esolang.Funge.Parser`: FungeSpace (sparse infinite 2D grid), FungeVector, FungeParser.
+- `Esolang.Funge.Processor`: FungeProcessor supporting core Funge-98 instruction set, stack stack, concurrent IPs.
+- `dotnet-funge`: Command-line interpreter for `.b98` files.
+
+### Changed
+
+- Build/package baseline: incremented `AssemblyVersion` / `FileVersion` to `1.0.0.1`.
+- `dotnet-funge`: enabled trimming/AOT analyzer-related properties and marked tool package as AOT-compatible for `net8.0+`.
+- `dotnet-funge`: package metadata now includes `PackageReadmeFile` and packs `Interpreter/README.md`.
+- `Esolang.Funge.Generator`: `FG0008` / `FG0009` severity changed from Warning to Info.
+- `Esolang.Funge.Generator`: runtime now throws when input/output instructions are executed without a declared input/output interface.
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..ce7044d
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,46 @@
+
+
+ enable
+ enable
+ 14
+ 1.0.0.1
+ 1.0.0.1
+ 1.0.0
+ https://github.com/Esolang-NET/Funge/
+ https://github.com/Esolang-NET/Funge.git
+ true
+ false
+ true
+ true
+ true
+ $(NoWarn);NETSDK1213;CS9057
+ snupkg
+ True
+
+
+
+ true
+ /_/
+ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))
+ $(RepoRoot)=$(DeterministicSourceRoot)
+ true
+
+
+
+ true
+ LICENSE
+ icon.png
+
+
+
+
+
+
+
+ $(MSBuildProjectDirectory)\TestResults
+ false
+ true
+ false
+ $(NoWarn);RS1035
+
+
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 0000000..1a96e0f
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Esolang.Funge.code-workspace b/Esolang.Funge.code-workspace
new file mode 100644
index 0000000..f0bbe51
--- /dev/null
+++ b/Esolang.Funge.code-workspace
@@ -0,0 +1,66 @@
+{
+ "folders": [
+ {
+ "name": "root",
+ "path": "."
+ },
+ {
+ "name": "Esolang.Funge.Generator",
+ "path": "Generator"
+ },
+ {
+ "name": "Esolang.Funge.Generator.Tests",
+ "path": "Generator.Tests"
+ },
+ {
+ "name": "Esolang.Funge.Generator.UseConsole",
+ "path": "samples/Generator.UseConsole"
+ },
+ {
+ "name": "Esolang.Funge.Interpreter",
+ "path": "Interpreter"
+ },
+ {
+ "name": "Esolang.Funge.Interpreter.Tests",
+ "path": "Interpreter.Tests"
+ },
+ {
+ "name": "Esolang.Funge.Parser",
+ "path": "Parser"
+ },
+ {
+ "name": "Esolang.Funge.Parser.Tests",
+ "path": "Parser.Tests"
+ },
+ {
+ "name": "Esolang.Funge.Processor",
+ "path": "Processor"
+ },
+ {
+ "name": "Esolang.Funge.Processor.Tests",
+ "path": "Processor.Tests"
+ }
+ ],
+ "settings": {
+ "files.exclude": {
+ "**/.git": true,
+ "**/.svn": true,
+ "**/.hg": true,
+ "**/.DS_Store": true,
+ "**/Thumbs.db": true,
+ "Generator": true,
+ "Generator.Tests": true,
+ "Interpreter": true,
+ "Parser": true,
+ "Parser.Tests": true,
+ "Processor": true,
+ "Processor.Tests": true,
+ "samples": true
+ }
+ },
+ "extensions": {
+ "recommendations": [
+ "ms-dotnettools.csdevkit"
+ ]
+ }
+}
diff --git a/Esolang.Funge.slnx b/Esolang.Funge.slnx
new file mode 100644
index 0000000..250e35c
--- /dev/null
+++ b/Esolang.Funge.slnx
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Generator.Tests/Esolang.Funge.Generator.Tests.csproj b/Generator.Tests/Esolang.Funge.Generator.Tests.csproj
new file mode 100644
index 0000000..c3ef050
--- /dev/null
+++ b/Generator.Tests/Esolang.Funge.Generator.Tests.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net48;net8.0;net9.0;net10.0
+ net8.0;net9.0;net10.0
+ enable
+ enable
+
+ false
+ true
+ false
+ Esolang.Funge.Generator.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Generator.Tests/FungeMethodGeneratorTests.cs b/Generator.Tests/FungeMethodGeneratorTests.cs
new file mode 100644
index 0000000..da98929
--- /dev/null
+++ b/Generator.Tests/FungeMethodGeneratorTests.cs
@@ -0,0 +1,542 @@
+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);
+}
diff --git a/Generator/AnalyzerReleases.Shipped.md b/Generator/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000..dc7074a
--- /dev/null
+++ b/Generator/AnalyzerReleases.Shipped.md
@@ -0,0 +1,25 @@
+## Release 0.1.0.0
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|--------------------
+FG0001 | Funge | Error | Invalid source path parameter
+FG0002 | Funge | Error | Unsupported return type
+FG0003 | Funge | Error | Unsupported parameter type
+FG0004 | Funge | Error | Source file not found
+FG0005 | Funge | Warning | Language version too low
+FG0006 | Funge | Error | Duplicate parameter type
+FG0007 | Funge | Error | Return type and output parameter conflict
+FG0008 | Funge | Warning | Output interface required
+FG0009 | Funge | Warning | Input interface required
+FG0010 | Funge | Hidden | Unused input interface
+
+## Release 1.0.0.0
+
+### Changed Rules
+
+Rule ID | New Category | New Severity | Old Category | Old Severity | Notes
+--------|--------------|--------------|--------------|--------------|--------------------
+FG0008 | Funge | Info | Funge | Warning | Static best-effort diagnostic; runtime throws if reached without output interface
+FG0009 | Funge | Info | Funge | Warning | Static best-effort diagnostic; runtime throws if reached without input interface
diff --git a/Generator/AnalyzerReleases.Unshipped.md b/Generator/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000..6a2a74b
--- /dev/null
+++ b/Generator/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,4 @@
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|--------------------
diff --git a/Generator/DiagnosticDescriptors.cs b/Generator/DiagnosticDescriptors.cs
new file mode 100644
index 0000000..501bfde
--- /dev/null
+++ b/Generator/DiagnosticDescriptors.cs
@@ -0,0 +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);
+}
diff --git a/Generator/Esolang.Funge.Generator.csproj b/Generator/Esolang.Funge.Generator.csproj
new file mode 100644
index 0000000..44f9137
--- /dev/null
+++ b/Generator/Esolang.Funge.Generator.csproj
@@ -0,0 +1,55 @@
+
+
+
+ netstandard2.0
+ true
+ true
+ cs
+ false
+ false
+ false
+ Source generator for Funge-98 programs.
+ Esolang.Funge.Generator
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(GetTargetPathDependsOn);GetDependencyTargetPaths
+
+
+
+
+
+
+
+
+
diff --git a/Generator/MethodGenerator.Runtime.cs b/Generator/MethodGenerator.Runtime.cs
new file mode 100644
index 0000000..8155765
--- /dev/null
+++ b/Generator/MethodGenerator.Runtime.cs
@@ -0,0 +1,216 @@
+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);
+ }
+ }
+ }
+ }
+ """;
+}
diff --git a/Generator/MethodGenerator.cs b/Generator/MethodGenerator.cs
new file mode 100644
index 0000000..27e82d9
--- /dev/null
+++ b/Generator/MethodGenerator.cs
@@ -0,0 +1,615 @@
+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;
+ }
+}
diff --git a/Generator/README.md b/Generator/README.md
new file mode 100644
index 0000000..dd449cb
--- /dev/null
+++ b/Generator/README.md
@@ -0,0 +1,137 @@
+# Esolang.Funge.Generator
+
+Roslyn source generator that compiles [Funge-98](https://github.com/catseye/Funge-98/blob/master/doc/funge98.markdown) programs into C# partial methods at build time.
+
+## Overview
+
+Add `[GenerateFungeMethod]` to a `partial` method declaration.
+The generator reads the Funge-98 source (from a file or inline) and emits a complete C# implementation.
+
+### Supported return types
+
+| Return type | Description |
+|---|---|
+| `void` | Run to completion; discard output |
+| `string` | Collect all output and return as a string |
+| `Task` | Async run; discard output |
+| `Task` | Async run; return output string |
+| `ValueTask` | Async run; discard output |
+| `ValueTask` | Async run; return output string |
+| `IEnumerable` | Yield output bytes synchronously |
+| `IAsyncEnumerable` | Yield output bytes asynchronously |
+
+### Supported parameter types
+
+| Parameter type | Role |
+|---|---|
+| `string` | Input fed to the program (`&` / `~`) |
+| `System.IO.TextReader` | Input reader |
+| `System.IO.Pipelines.PipeReader` | Input as pipe |
+| `System.IO.TextWriter` | Output writer (`void` return only) |
+| `System.IO.Pipelines.PipeWriter` | Output as pipe (`void` return only) |
+| `CancellationToken` | Cancellation (async methods) |
+
+## Installation
+
+```
+dotnet add package Esolang.Funge.Generator
+```
+
+## Usage
+
+### File-based
+
+Add `.b98` files to your project using the `FungeSource` item group:
+
+```xml
+
+
+
+```
+
+Then declare partial methods:
+
+```csharp
+using Esolang.Funge;
+
+partial class MyPrograms
+{
+ // Returns the program output as a string
+ [GenerateFungeMethod("Programs/hello.b98")]
+ public static partial string HelloWorld();
+
+ // Async variant
+ [GenerateFungeMethod("Programs/hello.b98")]
+ public static partial Task HelloWorldAsync(CancellationToken cancellationToken = default);
+
+ // With explicit TextWriter output
+ [GenerateFungeMethod("Programs/hello.b98")]
+ public static partial void HelloWorldWriter(System.IO.TextWriter output);
+
+ // With string input
+ [GenerateFungeMethod("Programs/echo.b98")]
+ public static partial string Echo(string input);
+}
+```
+
+### Inline source
+
+Funge-98 code can be embedded directly as a string literal using `InlineSource`—no `.b98` file needed:
+
+```csharp
+[GenerateFungeMethod(InlineSource = "64+\"!dlroW ,olleH\">:#,_@")]
+public static partial string HelloWorldInline();
+```
+
+## Diagnostics
+
+| ID | Severity | Description |
+|---|---|---|
+| FG0001 | Error | `sourcePath` is empty and `InlineSource` is not set |
+| FG0002 | Error | Unsupported return type |
+| FG0003 | Error | Unsupported parameter type |
+| FG0004 | Error | Source file not found in `AdditionalFiles` |
+| FG0005 | Warning | C# language version is too low (requires ≥ C# 8) |
+| FG0006 | Error | Duplicate input/output parameter |
+| FG0007 | Error | Return type conflicts with explicit output parameter |
+| FG0008 | Info | Program appears to use output (`.`/`,`) but no output parameter or output return type is declared (static best-effort scan; runtime throws if reached) |
+| FG0009 | Info | Program appears to use input (`&`/`~`) but no input parameter is declared (static best-effort scan; runtime throws if reached) |
+| FG0010 | Hidden | Input parameter declared but program never reads input |
+
+## 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.
+
+| Category | Instructions | Status |
+|---|---|---|
+| Stack | `0`–`9` `a`–`f` `:` `$` `\` `n` | ✅ |
+| Arithmetic | `+` `-` `*` `/` `%` | ✅ |
+| Comparison | `` ` `` `!` | ✅ |
+| Direction | `>` `<` `^` `v` `?` `[` `]` `r` `x` `w` | ✅ |
+| Branching | `_` `\|` | ✅ |
+| Movement | `#` `;` `j` | ✅ |
+| String / char | `"` `'` `s` | ✅ (stringmode contiguous spaces are SGML-style) |
+| Storage (self-modifying) | `g` `p` | 🟡 storage offset not applied |
+| I/O | `.` `,` `&` `~` | ✅ |
+| Misc | `z` `@` | ✅ |
+| Exit code | `q` | 🟡 terminates normally but exit code is discarded |
+| 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 |
+| Fingerprints | `(` `)` `A`–`Z` | ❌ reflects (not implemented) |
+| 3D (Trefunge) | `h` `l` `m` | ❌ not implemented (2D only) |
+
+## References
+
+- [Funge-98 Specification](https://codeberg.org/catseye/Funge-98/src/branch/master/doc/funge98.markdown) — Chris Pressey, Cat's Eye Technologies
+- [Funge-98 — Esolangs Wiki](https://esolangs.org/wiki/Funge-98)
+- [Mycology — Funge-98 compliance test suite](https://github.com/Deewiant/Mycology)
+
+## Target Frameworks
+
+Generator: `netstandard2.0`
+Consumer projects: `net8.0` · `net9.0` · `net10.0`
diff --git a/Generator/buildTransitive/Esolang.Funge.Generator.targets b/Generator/buildTransitive/Esolang.Funge.Generator.targets
new file mode 100644
index 0000000..138fd6c
--- /dev/null
+++ b/Generator/buildTransitive/Esolang.Funge.Generator.targets
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ <_FungeGeneratedFile Include="@(FungeSource)">
+ $(IntermediateOutputPath)FungeGenerated\%(Filename)%(Extension).funge.txt
+ %(Filename)%(Extension)
+ %(FungeSource.FungeLogicalPath)
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Interpreter.Tests/Esolang.Funge.Interpreter.Tests.csproj b/Interpreter.Tests/Esolang.Funge.Interpreter.Tests.csproj
new file mode 100644
index 0000000..df533ec
--- /dev/null
+++ b/Interpreter.Tests/Esolang.Funge.Interpreter.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0;net9.0;net10.0
+ enable
+ enable
+ false
+ true
+ false
+ Funge.Interpreter.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Interpreter.Tests/ProgramTests.cs b/Interpreter.Tests/ProgramTests.cs
new file mode 100644
index 0000000..cdadf4f
--- /dev/null
+++ b/Interpreter.Tests/ProgramTests.cs
@@ -0,0 +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);
+ }
+ }
+}
diff --git a/Interpreter/Esolang.Funge.Interpreter.csproj b/Interpreter/Esolang.Funge.Interpreter.csproj
new file mode 100644
index 0000000..f0ac88d
--- /dev/null
+++ b/Interpreter/Esolang.Funge.Interpreter.csproj
@@ -0,0 +1,46 @@
+
+
+ Exe
+ net8.0;net9.0;net10.0
+ dotnet-funge
+ Funge-98 console interpreter.
+ true
+ true
+ true
+ dotnet-funge
+ README.md
+ true
+ Command-line interpreter for Funge-98 (Befunge-98) programs.
+ dotnet-funge
+
+
+
+ true
+ true
+ true
+ true
+
+
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Interpreter/FungeInterpreterExtensions.cs b/Interpreter/FungeInterpreterExtensions.cs
new file mode 100644
index 0000000..da1f46e
--- /dev/null
+++ b/Interpreter/FungeInterpreterExtensions.cs
@@ -0,0 +1,37 @@
+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;
+ }
+}
diff --git a/Interpreter/Program.cs b/Interpreter/Program.cs
new file mode 100644
index 0000000..b530114
--- /dev/null
+++ b/Interpreter/Program.cs
@@ -0,0 +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;
+ }
+ }
+}
diff --git a/Interpreter/README.md b/Interpreter/README.md
new file mode 100644
index 0000000..8c985e2
--- /dev/null
+++ b/Interpreter/README.md
@@ -0,0 +1,56 @@
+# dotnet-funge
+
+Command-line interpreter for [Funge-98](https://github.com/catseye/Funge-98/blob/master/doc/funge98.markdown) (Befunge-98) programs.
+
+## Installation
+
+```
+dotnet tool install -g dotnet-funge
+```
+
+## Usage
+
+```
+dotnet-funge
+```
+
+| Argument | Description |
+|---|---|
+| `` | Path to a Funge-98 source file (`.b98`) |
+
+### Example
+
+```
+dotnet-funge hello.b98
+```
+
+Standard input / output are connected to the running program (`~` / `&` for input, `,` / `.` for output).
+
+The process exit code reflects the value passed to `q`; it is `0` if the program ends without `q`.
+
+## Funge-98 Compliance
+
+Delegates execution to `Esolang.Funge.Processor`, which targets **Befunge-98** (2D).
+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 |
+| 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) |
+
+## References
+
+- [Funge-98 Specification](https://codeberg.org/catseye/Funge-98/src/branch/master/doc/funge98.markdown) — Chris Pressey, Cat's Eye Technologies
+- [Funge-98 — Esolangs Wiki](https://esolangs.org/wiki/Funge-98)
+- [Mycology — Funge-98 compliance test suite](https://github.com/Deewiant/Mycology)
+
+## Target Frameworks
+
+`net8.0` · `net9.0` · `net10.0`
diff --git a/Parser.Tests/Esolang.Funge.Parser.Tests.csproj b/Parser.Tests/Esolang.Funge.Parser.Tests.csproj
new file mode 100644
index 0000000..8dc94a5
--- /dev/null
+++ b/Parser.Tests/Esolang.Funge.Parser.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+ net8.0;net9.0;net10.0
+ Funge.Parser.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Parser.Tests/FungeParserTests.cs b/Parser.Tests/FungeParserTests.cs
new file mode 100644
index 0000000..7fade70
--- /dev/null
+++ b/Parser.Tests/FungeParserTests.cs
@@ -0,0 +1,144 @@
+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);
+ }
+}
diff --git a/Parser/Esolang.Funge.Parser.csproj b/Parser/Esolang.Funge.Parser.csproj
new file mode 100644
index 0000000..cdf5399
--- /dev/null
+++ b/Parser/Esolang.Funge.Parser.csproj
@@ -0,0 +1,30 @@
+
+
+ net8.0;net9.0;net10.0;netstandard2.0
+ false
+ true
+ Core parsing primitives for Funge-98 programs.
+ Esolang.Funge.Parser
+
+
+
+ true
+ true
+ true
+ true
+
+
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Parser/FungeParser.cs b/Parser/FungeParser.cs
new file mode 100644
index 0000000..ddc9f20
--- /dev/null
+++ b/Parser/FungeParser.cs
@@ -0,0 +1,45 @@
+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));
+}
diff --git a/Parser/FungeSpace.cs b/Parser/FungeSpace.cs
new file mode 100644
index 0000000..bec74b4
--- /dev/null
+++ b/Parser/FungeSpace.cs
@@ -0,0 +1,99 @@
+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);
+ }
+}
diff --git a/Parser/FungeVector.cs b/Parser/FungeVector.cs
new file mode 100644
index 0000000..982ad40
--- /dev/null
+++ b/Parser/FungeVector.cs
@@ -0,0 +1,73 @@
+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);
+}
diff --git a/Parser/README.md b/Parser/README.md
new file mode 100644
index 0000000..4cf754b
--- /dev/null
+++ b/Parser/README.md
@@ -0,0 +1,41 @@
+# Esolang.Funge.Parser
+
+Core parsing primitives for [Funge-98](https://github.com/catseye/Funge-98/blob/master/doc/funge98.markdown) programs.
+
+## Overview
+
+This library provides the fundamental data structures for representing and manipulating a Funge-98 program space.
+
+| Type | Description |
+|---|---|
+| `FungeVector` | Immutable 2D coordinate `(X, Y)` |
+| `FungeSpace` | Sparse infinite 2D grid of integer cells (space = 32) |
+| `FungeParser` | Parses Funge-98 source text into a `FungeSpace` |
+
+## Installation
+
+```
+dotnet add package Esolang.Funge.Parser
+```
+
+## Usage
+
+```csharp
+using Esolang.Funge.Parser;
+
+// Parse from a string
+FungeSpace space = FungeParser.Parse("64+\"!dlroW ,olleH\">:#,_@");
+
+// Parse from a file
+FungeSpace space = FungeParser.ParseFile("hello.b98");
+
+// Read / write cells
+int value = space[new FungeVector(0, 0)];
+space[new FungeVector(0, 0)] = 'A';
+```
+
+## Target Frameworks
+
+`netstandard2.0` · `net8.0` · `net9.0` · `net10.0`
+
+AOT / trimming compatible.
diff --git a/Parser/Shared/HashCode.cs b/Parser/Shared/HashCode.cs
new file mode 100644
index 0000000..82827ce
--- /dev/null
+++ b/Parser/Shared/HashCode.cs
@@ -0,0 +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
diff --git a/Parser/Shared/IsExternalInit.cs b/Parser/Shared/IsExternalInit.cs
new file mode 100644
index 0000000..bf98ee8
--- /dev/null
+++ b/Parser/Shared/IsExternalInit.cs
@@ -0,0 +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
diff --git a/Processor.Tests/Esolang.Funge.Processor.Tests.csproj b/Processor.Tests/Esolang.Funge.Processor.Tests.csproj
new file mode 100644
index 0000000..bce24c6
--- /dev/null
+++ b/Processor.Tests/Esolang.Funge.Processor.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net8.0;net9.0;net10.0
+ Funge.Processor.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Processor.Tests/FungeProcessorTests.cs b/Processor.Tests/FungeProcessorTests.cs
new file mode 100644
index 0000000..ff52784
--- /dev/null
+++ b/Processor.Tests/FungeProcessorTests.cs
@@ -0,0 +1,226 @@
+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"));
+}
diff --git a/Processor/Esolang.Funge.Processor.csproj b/Processor/Esolang.Funge.Processor.csproj
new file mode 100644
index 0000000..23bd02f
--- /dev/null
+++ b/Processor/Esolang.Funge.Processor.csproj
@@ -0,0 +1,34 @@
+
+
+ net8.0;net9.0;net10.0
+ false
+ true
+ Execution engine for Funge-98 programs.
+ Esolang.Funge.Processor
+
+
+
+ true
+ true
+ true
+ true
+
+
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Processor/FungeProcessor.cs b/Processor/FungeProcessor.cs
new file mode 100644
index 0000000..bddd335
--- /dev/null
+++ b/Processor/FungeProcessor.cs
@@ -0,0 +1,618 @@
+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);
+ }
+ }
+}
diff --git a/Processor/InstructionPointer.cs b/Processor/InstructionPointer.cs
new file mode 100644
index 0000000..1908353
--- /dev/null
+++ b/Processor/InstructionPointer.cs
@@ -0,0 +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,
+ };
+}
diff --git a/Processor/README.md b/Processor/README.md
new file mode 100644
index 0000000..d9ca457
--- /dev/null
+++ b/Processor/README.md
@@ -0,0 +1,85 @@
+# Esolang.Funge.Processor
+
+Execution engine for [Funge-98](https://github.com/catseye/Funge-98/blob/master/doc/funge98.markdown) programs.
+
+## Overview
+
+`FungeProcessor` executes a `FungeSpace` loaded by `Esolang.Funge.Parser`.
+It implements the full Funge-98 core instruction set including concurrent Instruction Pointers.
+
+### Supported instructions
+
+| Category | Instructions |
+|---|---|
+| Stack | `0`–`9` `a`–`f` (push), `:` (dup), `$` (pop), `\` (swap), `n` (clear) |
+| Arithmetic | `+` `-` `*` `/` `%` |
+| Comparison | `` ` `` `!` |
+| Direction | `>` `<` `^` `v` `?` `[` `]` `r` `x` |
+| Movement | `#` (trampoline), `;` (jump over), `j` (jump forward) |
+| String mode | `"` |
+| Branching | `_` `|` `w` |
+| I/O | `.` `,` `&` `~` |
+| Storage | `p` (put), `g` (get) |
+| Reflection | `k` (iterate) |
+| 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) |
+
+## Funge-98 Compliance
+
+Targets **Befunge-98** (2D). Trefunge-98 and fingerprint extensions are intentionally out of scope.
+
+| Category | Instructions | Status |
+|---|---|---|
+| Stack | `0`–`9` `a`–`f` `:` `$` `\` `n` | ✅ |
+| Arithmetic | `+` `-` `*` `/` `%` | ✅ |
+| Comparison | `` ` `` `!` | ✅ |
+| Direction (cardinal) | `>` `<` `^` `v` `?` | ✅ |
+| Direction (Funge-98) | `[` `]` `r` `x` `w` | ✅ |
+| Branching | `_` `\|` | ✅ |
+| Movement | `#` `;` `j` | ✅ |
+| Iteration | `k` | ✅ |
+| String / char | `"` `'` `s` | ✅ |
+| Storage (self-modifying) | `g` `p` (with storage offset) | ✅ |
+| I/O | `.` `,` `&` `~` | ✅ |
+| Concurrency | `t` | ✅ |
+| Stack stack | `{` `}` `u` | ✅ |
+| System info | `y` | 🟡 env vars / command-line args are empty |
+| Misc | `z` `@` `q` | ✅ |
+| File I/O | `i` `o` | ❌ reflects (not implemented) |
+| System exec | `=` | ❌ reflects (not implemented) |
+| Fingerprints | `(` `)` `A`–`Z` | ❌ reflects (not implemented) |
+| 3D (Trefunge) | `h` `l` `m` | ❌ reflects (2D only) |
+
+## Installation
+
+```
+dotnet add package Esolang.Funge.Processor
+```
+
+## Usage
+
+```csharp
+using Esolang.Funge.Parser;
+using Esolang.Funge.Processor;
+
+var space = FungeParser.ParseFile("hello.b98");
+var proc = new FungeProcessor(space, Console.Out, Console.In);
+int exitCode = proc.Run();
+```
+
+`FungeProcessor` accepts optional `TextWriter` (output) and `TextReader` (input) arguments, defaulting to `Console.Out` / `Console.In`.
+`Run()` accepts an optional `CancellationToken` and returns the exit code set by `q` (0 if not used).
+
+## References
+
+- [Funge-98 Specification](https://codeberg.org/catseye/Funge-98/src/branch/master/doc/funge98.markdown) — Chris Pressey, Cat's Eye Technologies
+- [Funge-98 — Esolangs Wiki](https://esolangs.org/wiki/Funge-98)
+- [Mycology — Funge-98 compliance test suite](https://github.com/Deewiant/Mycology)
+
+## Target Frameworks
+
+`net8.0` · `net9.0` · `net10.0`
+
+AOT / trimming compatible.
diff --git a/Processor/StackStack.cs b/Processor/StackStack.cs
new file mode 100644
index 0000000..5f67a20
--- /dev/null
+++ b/Processor/StackStack.cs
@@ -0,0 +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);
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..336c05a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,83 @@
+# Esolang.Funge
+
+[](https://github.com/Esolang-NET/Funge/actions/workflows/dotnet.yml)
+
+## Quick Start (Generator)
+
+Write Funge-98 once, call it as a C# method.
+
+```csharp
+using Esolang.Funge;
+
+Console.WriteLine(FungeSample.HelloWorld());
+
+partial class FungeSample
+{
+ // File-based
+ [GenerateFungeMethod("Programs/hello.b98")]
+ public static partial string HelloWorld();
+
+ // Or inline — no .b98 file needed
+ [GenerateFungeMethod(InlineSource = "64+\"!dlroW ,olleH\">:#,_@")]
+ public static partial string HelloWorldInline();
+}
+
+// output:
+// Hello, World!
+// Hello, World!
+```
+
+## Generator Guide
+
+For detailed Generator signatures and patterns (`string`, `TextReader`, `PipeReader`, `TextWriter`, `PipeWriter`, sync/async returns, byte-sequence returns, inline source), see:
+
+- [Generator README](./Generator/README.md)
+
+For runnable examples covering all return types and inline source, see:
+
+- [UseConsole sample](./samples/Generator.UseConsole/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
+```
+
+## Choose Package
+
+| Want to do | Package |
+|---|---|
+| Generate C# methods from Funge-98 at compile time | Esolang.Funge.Generator |
+| Parse source into a `FungeSpace` | Esolang.Funge.Parser |
+| Execute Funge-98 in-process | Esolang.Funge.Processor |
+| Run Funge-98 from CLI | dotnet-funge |
+
+## NuGet
+
+| Project | NuGet | Summary |
+|---|---|---|
+| [dotnet-funge](./Interpreter/README.md) | [](https://www.nuget.org/packages/dotnet-funge/) | Funge-98 command-line interpreter. |
+| [Esolang.Funge.Generator](./Generator/README.md) | [](https://www.nuget.org/packages/Esolang.Funge.Generator/) | Funge-98 source generator. |
+| [Esolang.Funge.Parser](./Parser/README.md) | [](https://www.nuget.org/packages/Esolang.Funge.Parser/) | Funge-98 source parser. |
+| [Esolang.Funge.Processor](./Processor/README.md) | [](https://www.nuget.org/packages/Esolang.Funge.Processor/) | Funge-98 execution engine. |
+
+## Framework Support
+
+| Project | Target frameworks |
+|---|---|
+| Esolang.Funge.Generator | netstandard2.0 |
+| Esolang.Funge.Parser | net8.0, net9.0, net10.0, netstandard2.0 |
+| Esolang.Funge.Processor | net8.0, net9.0, net10.0 |
+| dotnet-funge | net8.0, net9.0, net10.0 |
+
+## Changelog
+
+- [CHANGELOG](./CHANGELOG.md)
+
+## See also
+
+- [Funge-98 specification](https://github.com/catseye/Funge-98/blob/master/doc/funge98.markdown)
+- [Befunge-93 / Befunge-98 on Esolangs wiki](https://esolangs.org/wiki/Befunge)
diff --git a/coverlet.collect.runsettings b/coverlet.collect.runsettings
new file mode 100644
index 0000000..3c39003
--- /dev/null
+++ b/coverlet.collect.runsettings
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ cobertura
+
+
+
+
+
diff --git a/dotnet-tools.json b/dotnet-tools.json
new file mode 100644
index 0000000..c967c89
--- /dev/null
+++ b/dotnet-tools.json
@@ -0,0 +1,5 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {}
+}
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..a8e58b5
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "rollForward": "latestMinor",
+ "version": "10.0.0"
+ }
+}
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..1281878
Binary files /dev/null and b/icon.png differ
diff --git a/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.cs b/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.cs
new file mode 100644
index 0000000..0289113
--- /dev/null
+++ b/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.cs
@@ -0,0 +1,55 @@
+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();
+ }
+}
diff --git a/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.csproj b/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.csproj
new file mode 100644
index 0000000..7af6468
--- /dev/null
+++ b/samples/Generator.UseConsole/Esolang.Funge.Generator.UseConsole.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0;net9.0;net10.0
+ Exe
+ false
+ false
+ false
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Generator.UseConsole/Programs/hello.b98 b/samples/Generator.UseConsole/Programs/hello.b98
new file mode 100644
index 0000000..83822c0
--- /dev/null
+++ b/samples/Generator.UseConsole/Programs/hello.b98
@@ -0,0 +1 @@
+64+"!dlroW ,olleH">:#,_@
diff --git a/samples/Generator.UseConsole/README.md b/samples/Generator.UseConsole/README.md
new file mode 100644
index 0000000..37099dc
--- /dev/null
+++ b/samples/Generator.UseConsole/README.md
@@ -0,0 +1,114 @@
+# Esolang.Funge.Generator.UseConsole
+
+`Esolang.Funge.Generator` の使い方を示すサンプルプロジェクトです。
+"Hello, World!" を出力する Funge-98 プログラム (`Programs/hello.b98`) を題材に、
+ジェネレーターがサポートするすべての戻り型パターンとインラインソースを実演します。
+
+## プロジェクト構成
+
+```
+samples/Generator.UseConsole/
+├── Programs/
+│ └── hello.b98 # Funge-98 ソースファイル
+├── Esolang.Funge.Generator.UseConsole.cs # サンプルコード(top-level statements)
+└── Esolang.Funge.Generator.UseConsole.csproj
+```
+
+### hello.b98
+
+```
+64+"!dlroW ,olleH">:#,_@
+```
+
+文字列 `"Hello, World!"` をスタックに積み、1 文字ずつ出力する Funge-98 プログラムです。
+
+## ジェネレーターのセットアップ方法
+
+### 1. csproj に Generator を参照する
+
+`OutputItemType="Analyzer"` / `ReferenceOutputAssembly="false"` でアナライザーとして参照します。
+
+```xml
+
+
+
+```
+
+### 2. `.b98` ファイルを `FungeSource` に追加する
+
+`FungeSource` アイテムグループに追加すると、ビルド時に自動で `AdditionalFiles` へ変換されます。
+
+```xml
+
+
+
+```
+
+ビルドターゲットを有効にするため `.targets` を `Import` します(NuGet パッケージ版では自動)。
+
+```xml
+
+```
+
+### 3. `partial` メソッドに属性を付ける
+
+クラスは `partial` 宣言が必要です。
+
+```csharp
+namespace Esolang.Funge
+{
+ partial class FungeSample
+ {
+ [GenerateFungeMethod("Programs/hello.b98")]
+ public static partial string HelloWorld();
+ }
+}
+```
+
+## 戻り型パターンの一覧
+
+このサンプルでは以下のすべての戻り型を示しています。
+
+| メソッド | 宣言 | 説明 |
+|---|---|---|
+| `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()` | インラインソース(後述) |
+
+## インラインソース
+
+`.b98` ファイルを用意しなくても、`InlineSource` プロパティで Funge-98 コードを文字列リテラルとして直接埋め込めます。
+
+```csharp
+// ファイルベース
+[GenerateFungeMethod("Programs/hello.b98")]
+public static partial string HelloWorld();
+
+// インライン — .b98 ファイル不要
+[GenerateFungeMethod(InlineSource = "64+\"!dlroW ,olleH\">:#,_@")]
+public static partial string HelloWorldInline();
+```
+
+`InlineSource` が設定されている場合、`sourcePath` 引数は無視されます。
+
+## 実行
+
+```
+dotnet run --framework net10.0
+```
+
+期待される出力:
+
+```
+HelloWorld: Hello, World!
+HelloWorldAsync: Hello, World!
+HelloWorldWriter: Hello, World!
+HelloWorldBytes: Hello, World!
+HelloWorldBytesAsync: Hello, World!
+HelloWorldInline: Hello, World!
+```