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 + +[![.NET](https://github.com/Esolang-NET/Funge/actions/workflows/dotnet.yml/badge.svg)](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) | [![NuGet: dotnet-funge](https://img.shields.io/nuget/v/dotnet-funge?logo=nuget)](https://www.nuget.org/packages/dotnet-funge/) | Funge-98 command-line interpreter. | +| [Esolang.Funge.Generator](./Generator/README.md) | [![NuGet: Esolang.Funge.Generator](https://img.shields.io/nuget/v/Esolang.Funge.Generator?logo=nuget)](https://www.nuget.org/packages/Esolang.Funge.Generator/) | Funge-98 source generator. | +| [Esolang.Funge.Parser](./Parser/README.md) | [![NuGet: Esolang.Funge.Parser](https://img.shields.io/nuget/v/Esolang.Funge.Parser?logo=nuget)](https://www.nuget.org/packages/Esolang.Funge.Parser/) | Funge-98 source parser. | +| [Esolang.Funge.Processor](./Processor/README.md) | [![NuGet: Esolang.Funge.Processor](https://img.shields.io/nuget/v/Esolang.Funge.Processor?logo=nuget)](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! +```