This is page 13 of 18. Use http://codebase.md/justinpbarnett/unity-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Security.Cryptography; 5 | using System.Text; 6 | using System.Net.Sockets; 7 | using System.Net; 8 | using System.IO; 9 | using System.Linq; 10 | using System.Runtime.InteropServices; 11 | using Newtonsoft.Json; 12 | using Newtonsoft.Json.Linq; 13 | using UnityEditor; 14 | using UnityEngine; 15 | using MCPForUnity.Editor.Data; 16 | using MCPForUnity.Editor.Helpers; 17 | using MCPForUnity.Editor.Models; 18 | 19 | namespace MCPForUnity.Editor.Windows 20 | { 21 | public class MCPForUnityEditorWindow : EditorWindow 22 | { 23 | private bool isUnityBridgeRunning = false; 24 | private Vector2 scrollPosition; 25 | private string pythonServerInstallationStatus = "Not Installed"; 26 | private Color pythonServerInstallationStatusColor = Color.red; 27 | private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server) 28 | private readonly McpClients mcpClients = new(); 29 | private bool autoRegisterEnabled; 30 | private bool lastClientRegisteredOk; 31 | private bool lastBridgeVerifiedOk; 32 | private string pythonDirOverride = null; 33 | private bool debugLogsEnabled; 34 | 35 | // Script validation settings 36 | private int validationLevelIndex = 1; // Default to Standard 37 | private readonly string[] validationLevelOptions = new string[] 38 | { 39 | "Basic - Only syntax checks", 40 | "Standard - Syntax + Unity practices", 41 | "Comprehensive - All checks + semantic analysis", 42 | "Strict - Full semantic validation (requires Roslyn)" 43 | }; 44 | 45 | // UI state 46 | private int selectedClientIndex = 0; 47 | 48 | public static void ShowWindow() 49 | { 50 | GetWindow<MCPForUnityEditorWindow>("MCP For Unity"); 51 | } 52 | 53 | private void OnEnable() 54 | { 55 | UpdatePythonServerInstallationStatus(); 56 | 57 | // Refresh bridge status 58 | isUnityBridgeRunning = MCPForUnityBridge.IsRunning; 59 | autoRegisterEnabled = EditorPrefs.GetBool("MCPForUnity.AutoRegisterEnabled", true); 60 | debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); 61 | if (debugLogsEnabled) 62 | { 63 | LogDebugPrefsState(); 64 | } 65 | foreach (McpClient mcpClient in mcpClients.clients) 66 | { 67 | CheckMcpConfiguration(mcpClient); 68 | } 69 | 70 | // Load validation level setting 71 | LoadValidationLevelSetting(); 72 | 73 | // First-run auto-setup only if Claude CLI is available 74 | if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) 75 | { 76 | AutoFirstRunSetup(); 77 | } 78 | } 79 | 80 | private void OnFocus() 81 | { 82 | // Refresh bridge running state on focus in case initialization completed after domain reload 83 | isUnityBridgeRunning = MCPForUnityBridge.IsRunning; 84 | if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) 85 | { 86 | McpClient selectedClient = mcpClients.clients[selectedClientIndex]; 87 | CheckMcpConfiguration(selectedClient); 88 | } 89 | Repaint(); 90 | } 91 | 92 | private Color GetStatusColor(McpStatus status) 93 | { 94 | // Return appropriate color based on the status enum 95 | return status switch 96 | { 97 | McpStatus.Configured => Color.green, 98 | McpStatus.Running => Color.green, 99 | McpStatus.Connected => Color.green, 100 | McpStatus.IncorrectPath => Color.yellow, 101 | McpStatus.CommunicationError => Color.yellow, 102 | McpStatus.NoResponse => Color.yellow, 103 | _ => Color.red, // Default to red for error states or not configured 104 | }; 105 | } 106 | 107 | private void UpdatePythonServerInstallationStatus() 108 | { 109 | try 110 | { 111 | string installedPath = ServerInstaller.GetServerPath(); 112 | bool installedOk = !string.IsNullOrEmpty(installedPath) && File.Exists(Path.Combine(installedPath, "server.py")); 113 | if (installedOk) 114 | { 115 | pythonServerInstallationStatus = "Installed"; 116 | pythonServerInstallationStatusColor = Color.green; 117 | return; 118 | } 119 | 120 | // Fall back to embedded/dev source via our existing resolution logic 121 | string embeddedPath = FindPackagePythonDirectory(); 122 | bool embeddedOk = !string.IsNullOrEmpty(embeddedPath) && File.Exists(Path.Combine(embeddedPath, "server.py")); 123 | if (embeddedOk) 124 | { 125 | pythonServerInstallationStatus = "Installed (Embedded)"; 126 | pythonServerInstallationStatusColor = Color.green; 127 | } 128 | else 129 | { 130 | pythonServerInstallationStatus = "Not Installed"; 131 | pythonServerInstallationStatusColor = Color.red; 132 | } 133 | } 134 | catch 135 | { 136 | pythonServerInstallationStatus = "Not Installed"; 137 | pythonServerInstallationStatusColor = Color.red; 138 | } 139 | } 140 | 141 | 142 | private void DrawStatusDot(Rect statusRect, Color statusColor, float size = 12) 143 | { 144 | float offsetX = (statusRect.width - size) / 2; 145 | float offsetY = (statusRect.height - size) / 2; 146 | Rect dotRect = new(statusRect.x + offsetX, statusRect.y + offsetY, size, size); 147 | Vector3 center = new( 148 | dotRect.x + (dotRect.width / 2), 149 | dotRect.y + (dotRect.height / 2), 150 | 0 151 | ); 152 | float radius = size / 2; 153 | 154 | // Draw the main dot 155 | Handles.color = statusColor; 156 | Handles.DrawSolidDisc(center, Vector3.forward, radius); 157 | 158 | // Draw the border 159 | Color borderColor = new( 160 | statusColor.r * 0.7f, 161 | statusColor.g * 0.7f, 162 | statusColor.b * 0.7f 163 | ); 164 | Handles.color = borderColor; 165 | Handles.DrawWireDisc(center, Vector3.forward, radius); 166 | } 167 | 168 | private void OnGUI() 169 | { 170 | scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); 171 | 172 | // Header 173 | DrawHeader(); 174 | 175 | // Compute equal column widths for uniform layout 176 | float horizontalSpacing = 2f; 177 | float outerPadding = 20f; // approximate padding 178 | // Make columns a bit less wide for a tighter layout 179 | float computed = (position.width - outerPadding - horizontalSpacing) / 2f; 180 | float colWidth = Mathf.Clamp(computed, 220f, 340f); 181 | // Use fixed heights per row so paired panels match exactly 182 | float topPanelHeight = 190f; 183 | float bottomPanelHeight = 230f; 184 | 185 | // Top row: Server Status (left) and Unity Bridge (right) 186 | EditorGUILayout.BeginHorizontal(); 187 | { 188 | EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); 189 | DrawServerStatusSection(); 190 | EditorGUILayout.EndVertical(); 191 | 192 | EditorGUILayout.Space(horizontalSpacing); 193 | 194 | EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); 195 | DrawBridgeSection(); 196 | EditorGUILayout.EndVertical(); 197 | } 198 | EditorGUILayout.EndHorizontal(); 199 | 200 | EditorGUILayout.Space(10); 201 | 202 | // Second row: MCP Client Configuration (left) and Script Validation (right) 203 | EditorGUILayout.BeginHorizontal(); 204 | { 205 | EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); 206 | DrawUnifiedClientConfiguration(); 207 | EditorGUILayout.EndVertical(); 208 | 209 | EditorGUILayout.Space(horizontalSpacing); 210 | 211 | EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); 212 | DrawValidationSection(); 213 | EditorGUILayout.EndVertical(); 214 | } 215 | EditorGUILayout.EndHorizontal(); 216 | 217 | // Minimal bottom padding 218 | EditorGUILayout.Space(2); 219 | 220 | EditorGUILayout.EndScrollView(); 221 | } 222 | 223 | private void DrawHeader() 224 | { 225 | EditorGUILayout.Space(15); 226 | Rect titleRect = EditorGUILayout.GetControlRect(false, 40); 227 | EditorGUI.DrawRect(titleRect, new Color(0.2f, 0.2f, 0.2f, 0.1f)); 228 | 229 | GUIStyle titleStyle = new GUIStyle(EditorStyles.boldLabel) 230 | { 231 | fontSize = 16, 232 | alignment = TextAnchor.MiddleLeft 233 | }; 234 | 235 | GUI.Label( 236 | new Rect(titleRect.x + 15, titleRect.y + 8, titleRect.width - 30, titleRect.height), 237 | "MCP For Unity", 238 | titleStyle 239 | ); 240 | 241 | // Place the Show Debug Logs toggle on the same header row, right-aligned 242 | float toggleWidth = 160f; 243 | Rect toggleRect = new Rect(titleRect.xMax - toggleWidth - 12f, titleRect.y + 10f, toggleWidth, 20f); 244 | bool newDebug = GUI.Toggle(toggleRect, debugLogsEnabled, "Show Debug Logs"); 245 | if (newDebug != debugLogsEnabled) 246 | { 247 | debugLogsEnabled = newDebug; 248 | EditorPrefs.SetBool("MCPForUnity.DebugLogs", debugLogsEnabled); 249 | if (debugLogsEnabled) 250 | { 251 | LogDebugPrefsState(); 252 | } 253 | } 254 | EditorGUILayout.Space(15); 255 | } 256 | 257 | private void LogDebugPrefsState() 258 | { 259 | try 260 | { 261 | string pythonDirOverridePref = SafeGetPrefString("MCPForUnity.PythonDirOverride"); 262 | string uvPathPref = SafeGetPrefString("MCPForUnity.UvPath"); 263 | string serverSrcPref = SafeGetPrefString("MCPForUnity.ServerSrc"); 264 | bool useEmbedded = SafeGetPrefBool("MCPForUnity.UseEmbeddedServer"); 265 | 266 | // Version-scoped detection key 267 | string embeddedVer = ReadEmbeddedVersionOrFallback(); 268 | string detectKey = $"MCPForUnity.LegacyDetectLogged:{embeddedVer}"; 269 | bool detectLogged = SafeGetPrefBool(detectKey); 270 | 271 | // Project-scoped auto-register key 272 | string projectPath = Application.dataPath ?? string.Empty; 273 | string autoKey = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; 274 | bool autoRegistered = SafeGetPrefBool(autoKey); 275 | 276 | MCPForUnity.Editor.Helpers.McpLog.Info( 277 | "MCP Debug Prefs:\n" + 278 | $" DebugLogs: {debugLogsEnabled}\n" + 279 | $" PythonDirOverride: '{pythonDirOverridePref}'\n" + 280 | $" UvPath: '{uvPathPref}'\n" + 281 | $" ServerSrc: '{serverSrcPref}'\n" + 282 | $" UseEmbeddedServer: {useEmbedded}\n" + 283 | $" DetectOnceKey: '{detectKey}' => {detectLogged}\n" + 284 | $" AutoRegisteredKey: '{autoKey}' => {autoRegistered}", 285 | always: false 286 | ); 287 | } 288 | catch (Exception ex) 289 | { 290 | UnityEngine.Debug.LogWarning($"MCP Debug Prefs logging failed: {ex.Message}"); 291 | } 292 | } 293 | 294 | private static string SafeGetPrefString(string key) 295 | { 296 | try { return EditorPrefs.GetString(key, string.Empty) ?? string.Empty; } catch { return string.Empty; } 297 | } 298 | 299 | private static bool SafeGetPrefBool(string key) 300 | { 301 | try { return EditorPrefs.GetBool(key, false); } catch { return false; } 302 | } 303 | 304 | private static string ReadEmbeddedVersionOrFallback() 305 | { 306 | try 307 | { 308 | if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) 309 | { 310 | var p = Path.Combine(embeddedSrc, "server_version.txt"); 311 | if (File.Exists(p)) 312 | { 313 | var s = File.ReadAllText(p)?.Trim(); 314 | if (!string.IsNullOrEmpty(s)) return s; 315 | } 316 | } 317 | } 318 | catch { } 319 | return "unknown"; 320 | } 321 | 322 | private void DrawServerStatusSection() 323 | { 324 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 325 | 326 | GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) 327 | { 328 | fontSize = 14 329 | }; 330 | EditorGUILayout.LabelField("Server Status", sectionTitleStyle); 331 | EditorGUILayout.Space(8); 332 | 333 | EditorGUILayout.BeginHorizontal(); 334 | Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); 335 | DrawStatusDot(statusRect, pythonServerInstallationStatusColor, 16); 336 | 337 | GUIStyle statusStyle = new GUIStyle(EditorStyles.label) 338 | { 339 | fontSize = 12, 340 | fontStyle = FontStyle.Bold 341 | }; 342 | EditorGUILayout.LabelField(pythonServerInstallationStatus, statusStyle, GUILayout.Height(28)); 343 | EditorGUILayout.EndHorizontal(); 344 | 345 | EditorGUILayout.Space(5); 346 | 347 | EditorGUILayout.BeginHorizontal(); 348 | bool isAutoMode = MCPForUnityBridge.IsAutoConnectMode(); 349 | GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; 350 | EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); 351 | GUILayout.FlexibleSpace(); 352 | EditorGUILayout.EndHorizontal(); 353 | 354 | int currentUnityPort = MCPForUnityBridge.GetCurrentPort(); 355 | GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) 356 | { 357 | fontSize = 11 358 | }; 359 | EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); 360 | EditorGUILayout.Space(5); 361 | 362 | /// Auto-Setup button below ports 363 | string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup"; 364 | if (GUILayout.Button(setupButtonText, GUILayout.Height(24))) 365 | { 366 | RunSetupNow(); 367 | } 368 | EditorGUILayout.Space(4); 369 | 370 | // Rebuild MCP Server button with tooltip tag 371 | using (new EditorGUILayout.HorizontalScope()) 372 | { 373 | GUILayout.FlexibleSpace(); 374 | GUIContent repairLabel = new GUIContent( 375 | "Rebuild MCP Server", 376 | "Deletes the installed server and re-copies it from the package. Use this to update the server after making source code changes or if the installation is corrupted." 377 | ); 378 | if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22))) 379 | { 380 | bool ok = global::MCPForUnity.Editor.Helpers.ServerInstaller.RebuildMcpServer(); 381 | if (ok) 382 | { 383 | EditorUtility.DisplayDialog("MCP For Unity", "Server rebuilt successfully.", "OK"); 384 | UpdatePythonServerInstallationStatus(); 385 | } 386 | else 387 | { 388 | EditorUtility.DisplayDialog("MCP For Unity", "Rebuild failed. Please check Console for details.", "OK"); 389 | } 390 | } 391 | } 392 | // (Removed descriptive tool tag under the Repair button) 393 | 394 | // (Show Debug Logs toggle moved to header) 395 | EditorGUILayout.Space(2); 396 | 397 | // Python detection warning with link 398 | if (!IsPythonDetected()) 399 | { 400 | GUIStyle warnStyle = new GUIStyle(EditorStyles.label) { richText = true, wordWrap = true }; 401 | EditorGUILayout.LabelField("<color=#cc3333><b>Warning:</b></color> No Python installation found.", warnStyle); 402 | using (new EditorGUILayout.HorizontalScope()) 403 | { 404 | if (GUILayout.Button("Open Install Instructions", GUILayout.Width(200))) 405 | { 406 | Application.OpenURL("https://www.python.org/downloads/"); 407 | } 408 | } 409 | EditorGUILayout.Space(4); 410 | } 411 | 412 | // Troubleshooting helpers 413 | if (pythonServerInstallationStatusColor != Color.green) 414 | { 415 | using (new EditorGUILayout.HorizontalScope()) 416 | { 417 | if (GUILayout.Button("Select server folder…", GUILayout.Width(160))) 418 | { 419 | string picked = EditorUtility.OpenFolderPanel("Select UnityMcpServer/src", Application.dataPath, ""); 420 | if (!string.IsNullOrEmpty(picked) && File.Exists(Path.Combine(picked, "server.py"))) 421 | { 422 | pythonDirOverride = picked; 423 | EditorPrefs.SetString("MCPForUnity.PythonDirOverride", pythonDirOverride); 424 | UpdatePythonServerInstallationStatus(); 425 | } 426 | else if (!string.IsNullOrEmpty(picked)) 427 | { 428 | EditorUtility.DisplayDialog("Invalid Selection", "The selected folder does not contain server.py", "OK"); 429 | } 430 | } 431 | if (GUILayout.Button("Verify again", GUILayout.Width(120))) 432 | { 433 | UpdatePythonServerInstallationStatus(); 434 | } 435 | } 436 | } 437 | EditorGUILayout.EndVertical(); 438 | } 439 | 440 | private void DrawBridgeSection() 441 | { 442 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 443 | 444 | // Always reflect the live state each repaint to avoid stale UI after recompiles 445 | isUnityBridgeRunning = MCPForUnityBridge.IsRunning; 446 | 447 | GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) 448 | { 449 | fontSize = 14 450 | }; 451 | EditorGUILayout.LabelField("Unity Bridge", sectionTitleStyle); 452 | EditorGUILayout.Space(8); 453 | 454 | EditorGUILayout.BeginHorizontal(); 455 | Color bridgeColor = isUnityBridgeRunning ? Color.green : Color.red; 456 | Rect bridgeStatusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); 457 | DrawStatusDot(bridgeStatusRect, bridgeColor, 16); 458 | 459 | GUIStyle bridgeStatusStyle = new GUIStyle(EditorStyles.label) 460 | { 461 | fontSize = 12, 462 | fontStyle = FontStyle.Bold 463 | }; 464 | EditorGUILayout.LabelField(isUnityBridgeRunning ? "Running" : "Stopped", bridgeStatusStyle, GUILayout.Height(28)); 465 | EditorGUILayout.EndHorizontal(); 466 | 467 | EditorGUILayout.Space(8); 468 | if (GUILayout.Button(isUnityBridgeRunning ? "Stop Bridge" : "Start Bridge", GUILayout.Height(32))) 469 | { 470 | ToggleUnityBridge(); 471 | } 472 | EditorGUILayout.Space(5); 473 | EditorGUILayout.EndVertical(); 474 | } 475 | 476 | private void DrawValidationSection() 477 | { 478 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 479 | 480 | GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) 481 | { 482 | fontSize = 14 483 | }; 484 | EditorGUILayout.LabelField("Script Validation", sectionTitleStyle); 485 | EditorGUILayout.Space(8); 486 | 487 | EditorGUI.BeginChangeCheck(); 488 | validationLevelIndex = EditorGUILayout.Popup("Validation Level", validationLevelIndex, validationLevelOptions, GUILayout.Height(20)); 489 | if (EditorGUI.EndChangeCheck()) 490 | { 491 | SaveValidationLevelSetting(); 492 | } 493 | 494 | EditorGUILayout.Space(8); 495 | string description = GetValidationLevelDescription(validationLevelIndex); 496 | EditorGUILayout.HelpBox(description, MessageType.Info); 497 | EditorGUILayout.Space(4); 498 | // (Show Debug Logs toggle moved to header) 499 | EditorGUILayout.Space(2); 500 | EditorGUILayout.EndVertical(); 501 | } 502 | 503 | private void DrawUnifiedClientConfiguration() 504 | { 505 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 506 | 507 | GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) 508 | { 509 | fontSize = 14 510 | }; 511 | EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); 512 | EditorGUILayout.Space(10); 513 | 514 | // (Auto-connect toggle removed per design) 515 | 516 | // Client selector 517 | string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); 518 | EditorGUI.BeginChangeCheck(); 519 | selectedClientIndex = EditorGUILayout.Popup("Select Client", selectedClientIndex, clientNames, GUILayout.Height(20)); 520 | if (EditorGUI.EndChangeCheck()) 521 | { 522 | selectedClientIndex = Mathf.Clamp(selectedClientIndex, 0, mcpClients.clients.Count - 1); 523 | } 524 | 525 | EditorGUILayout.Space(10); 526 | 527 | if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) 528 | { 529 | McpClient selectedClient = mcpClients.clients[selectedClientIndex]; 530 | DrawClientConfigurationCompact(selectedClient); 531 | } 532 | 533 | EditorGUILayout.Space(5); 534 | EditorGUILayout.EndVertical(); 535 | } 536 | 537 | private void AutoFirstRunSetup() 538 | { 539 | try 540 | { 541 | // Project-scoped one-time flag 542 | string projectPath = Application.dataPath ?? string.Empty; 543 | string key = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; 544 | if (EditorPrefs.GetBool(key, false)) 545 | { 546 | return; 547 | } 548 | 549 | // Attempt client registration using discovered Python server dir 550 | pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); 551 | string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); 552 | if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) 553 | { 554 | bool anyRegistered = false; 555 | foreach (McpClient client in mcpClients.clients) 556 | { 557 | try 558 | { 559 | if (client.mcpType == McpTypes.ClaudeCode) 560 | { 561 | // Only attempt if Claude CLI is present 562 | if (!IsClaudeConfigured() && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) 563 | { 564 | RegisterWithClaudeCode(pythonDir); 565 | anyRegistered = true; 566 | } 567 | } 568 | else 569 | { 570 | CheckMcpConfiguration(client); 571 | bool alreadyConfigured = client.status == McpStatus.Configured; 572 | if (!alreadyConfigured) 573 | { 574 | ConfigureMcpClient(client); 575 | anyRegistered = true; 576 | } 577 | } 578 | } 579 | catch (Exception ex) 580 | { 581 | MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); 582 | } 583 | } 584 | lastClientRegisteredOk = anyRegistered 585 | || IsCursorConfigured(pythonDir) 586 | || CodexConfigHelper.IsCodexConfigured(pythonDir) 587 | || IsClaudeConfigured(); 588 | } 589 | 590 | // Ensure the bridge is listening and has a fresh saved port 591 | if (!MCPForUnityBridge.IsRunning) 592 | { 593 | try 594 | { 595 | MCPForUnityBridge.StartAutoConnect(); 596 | isUnityBridgeRunning = MCPForUnityBridge.IsRunning; 597 | Repaint(); 598 | } 599 | catch (Exception ex) 600 | { 601 | MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup StartAutoConnect failed: {ex.Message}"); 602 | } 603 | } 604 | 605 | // Verify bridge with a quick ping 606 | lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); 607 | 608 | EditorPrefs.SetBool(key, true); 609 | } 610 | catch (Exception e) 611 | { 612 | MCPForUnity.Editor.Helpers.McpLog.Warn($"MCP for Unity auto-setup skipped: {e.Message}"); 613 | } 614 | } 615 | 616 | private static string ComputeSha1(string input) 617 | { 618 | try 619 | { 620 | using SHA1 sha1 = SHA1.Create(); 621 | byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); 622 | byte[] hash = sha1.ComputeHash(bytes); 623 | StringBuilder sb = new StringBuilder(hash.Length * 2); 624 | foreach (byte b in hash) 625 | { 626 | sb.Append(b.ToString("x2")); 627 | } 628 | return sb.ToString(); 629 | } 630 | catch 631 | { 632 | return ""; 633 | } 634 | } 635 | 636 | private void RunSetupNow() 637 | { 638 | // Force a one-shot setup regardless of first-run flag 639 | try 640 | { 641 | pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); 642 | string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); 643 | if (string.IsNullOrEmpty(pythonDir) || !File.Exists(Path.Combine(pythonDir, "server.py"))) 644 | { 645 | EditorUtility.DisplayDialog("Setup", "Python server not found. Please select UnityMcpServer/src.", "OK"); 646 | return; 647 | } 648 | 649 | bool anyRegistered = false; 650 | foreach (McpClient client in mcpClients.clients) 651 | { 652 | try 653 | { 654 | if (client.mcpType == McpTypes.ClaudeCode) 655 | { 656 | if (!IsClaudeConfigured()) 657 | { 658 | RegisterWithClaudeCode(pythonDir); 659 | anyRegistered = true; 660 | } 661 | } 662 | else 663 | { 664 | CheckMcpConfiguration(client); 665 | bool alreadyConfigured = client.status == McpStatus.Configured; 666 | if (!alreadyConfigured) 667 | { 668 | ConfigureMcpClient(client); 669 | anyRegistered = true; 670 | } 671 | } 672 | } 673 | catch (Exception ex) 674 | { 675 | UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); 676 | } 677 | } 678 | lastClientRegisteredOk = anyRegistered 679 | || IsCursorConfigured(pythonDir) 680 | || CodexConfigHelper.IsCodexConfigured(pythonDir) 681 | || IsClaudeConfigured(); 682 | 683 | // Restart/ensure bridge 684 | MCPForUnityBridge.StartAutoConnect(); 685 | isUnityBridgeRunning = MCPForUnityBridge.IsRunning; 686 | 687 | // Verify 688 | lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); 689 | Repaint(); 690 | } 691 | catch (Exception e) 692 | { 693 | EditorUtility.DisplayDialog("Setup Failed", e.Message, "OK"); 694 | } 695 | } 696 | 697 | private static bool IsCursorConfigured(string pythonDir) 698 | { 699 | try 700 | { 701 | string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 702 | ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 703 | ".cursor", "mcp.json") 704 | : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 705 | ".cursor", "mcp.json"); 706 | if (!File.Exists(configPath)) return false; 707 | string json = File.ReadAllText(configPath); 708 | dynamic cfg = JsonConvert.DeserializeObject(json); 709 | var servers = cfg?.mcpServers; 710 | if (servers == null) return false; 711 | var unity = servers.unityMCP ?? servers.UnityMCP; 712 | if (unity == null) return false; 713 | var args = unity.args; 714 | if (args == null) return false; 715 | // Prefer exact extraction of the --directory value and compare normalized paths 716 | string[] strArgs = ((System.Collections.Generic.IEnumerable<object>)args) 717 | .Select(x => x?.ToString() ?? string.Empty) 718 | .ToArray(); 719 | string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs); 720 | if (string.IsNullOrEmpty(dir)) return false; 721 | return McpConfigFileHelper.PathsEqual(dir, pythonDir); 722 | } 723 | catch { return false; } 724 | } 725 | 726 | private static bool IsClaudeConfigured() 727 | { 728 | try 729 | { 730 | string claudePath = ExecPath.ResolveClaude(); 731 | if (string.IsNullOrEmpty(claudePath)) return false; 732 | 733 | // Only prepend PATH on Unix 734 | string pathPrepend = null; 735 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 736 | { 737 | pathPrepend = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 738 | ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" 739 | : "/usr/local/bin:/usr/bin:/bin"; 740 | } 741 | 742 | if (!ExecPath.TryRun(claudePath, "mcp list", workingDir: null, out var stdout, out var stderr, 5000, pathPrepend)) 743 | { 744 | return false; 745 | } 746 | return (stdout ?? string.Empty).IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; 747 | } 748 | catch { return false; } 749 | } 750 | 751 | private static bool VerifyBridgePing(int port) 752 | { 753 | // Use strict framed protocol to match bridge (FRAMING=1) 754 | const int ConnectTimeoutMs = 1000; 755 | const int FrameTimeoutMs = 30000; // match bridge frame I/O timeout 756 | 757 | try 758 | { 759 | using TcpClient client = new TcpClient(); 760 | var connectTask = client.ConnectAsync(IPAddress.Loopback, port); 761 | if (!connectTask.Wait(ConnectTimeoutMs)) return false; 762 | 763 | using NetworkStream stream = client.GetStream(); 764 | try { client.NoDelay = true; } catch { } 765 | 766 | // 1) Read handshake line (ASCII, newline-terminated) 767 | string handshake = ReadLineAscii(stream, 2000); 768 | if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) 769 | { 770 | UnityEngine.Debug.LogWarning("MCP for Unity: Bridge handshake missing FRAMING=1"); 771 | return false; 772 | } 773 | 774 | // 2) Send framed "ping" 775 | byte[] payload = Encoding.UTF8.GetBytes("ping"); 776 | WriteFrame(stream, payload, FrameTimeoutMs); 777 | 778 | // 3) Read framed response and check for pong 779 | string response = ReadFrameUtf8(stream, FrameTimeoutMs); 780 | bool ok = !string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0; 781 | if (!ok) 782 | { 783 | UnityEngine.Debug.LogWarning($"MCP for Unity: Framed ping failed; response='{response}'"); 784 | } 785 | return ok; 786 | } 787 | catch (Exception ex) 788 | { 789 | UnityEngine.Debug.LogWarning($"MCP for Unity: VerifyBridgePing error: {ex.Message}"); 790 | return false; 791 | } 792 | } 793 | 794 | // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts 795 | private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) 796 | { 797 | if (payload == null) throw new ArgumentNullException(nameof(payload)); 798 | if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); 799 | byte[] header = new byte[8]; 800 | ulong len = (ulong)payload.LongLength; 801 | header[0] = (byte)(len >> 56); 802 | header[1] = (byte)(len >> 48); 803 | header[2] = (byte)(len >> 40); 804 | header[3] = (byte)(len >> 32); 805 | header[4] = (byte)(len >> 24); 806 | header[5] = (byte)(len >> 16); 807 | header[6] = (byte)(len >> 8); 808 | header[7] = (byte)(len); 809 | 810 | stream.WriteTimeout = timeoutMs; 811 | stream.Write(header, 0, header.Length); 812 | stream.Write(payload, 0, payload.Length); 813 | } 814 | 815 | private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) 816 | { 817 | byte[] header = ReadExact(stream, 8, timeoutMs); 818 | ulong len = ((ulong)header[0] << 56) 819 | | ((ulong)header[1] << 48) 820 | | ((ulong)header[2] << 40) 821 | | ((ulong)header[3] << 32) 822 | | ((ulong)header[4] << 24) 823 | | ((ulong)header[5] << 16) 824 | | ((ulong)header[6] << 8) 825 | | header[7]; 826 | if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); 827 | if (len > int.MaxValue) throw new IOException("Frame too large"); 828 | byte[] payload = ReadExact(stream, (int)len, timeoutMs); 829 | return Encoding.UTF8.GetString(payload); 830 | } 831 | 832 | private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) 833 | { 834 | byte[] buffer = new byte[count]; 835 | int offset = 0; 836 | stream.ReadTimeout = timeoutMs; 837 | while (offset < count) 838 | { 839 | int read = stream.Read(buffer, offset, count - offset); 840 | if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); 841 | offset += read; 842 | } 843 | return buffer; 844 | } 845 | 846 | private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) 847 | { 848 | stream.ReadTimeout = timeoutMs; 849 | using var ms = new MemoryStream(); 850 | byte[] one = new byte[1]; 851 | while (ms.Length < maxLen) 852 | { 853 | int n = stream.Read(one, 0, 1); 854 | if (n <= 0) break; 855 | if (one[0] == (byte)'\n') break; 856 | ms.WriteByte(one[0]); 857 | } 858 | return Encoding.ASCII.GetString(ms.ToArray()); 859 | } 860 | 861 | private void DrawClientConfigurationCompact(McpClient mcpClient) 862 | { 863 | // Special pre-check for Claude Code: if CLI missing, reflect in status UI 864 | if (mcpClient.mcpType == McpTypes.ClaudeCode) 865 | { 866 | string claudeCheck = ExecPath.ResolveClaude(); 867 | if (string.IsNullOrEmpty(claudeCheck)) 868 | { 869 | mcpClient.configStatus = "Claude Not Found"; 870 | mcpClient.status = McpStatus.NotConfigured; 871 | } 872 | } 873 | 874 | // Pre-check for clients that require uv (all except Claude Code) 875 | bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode; 876 | bool uvMissingEarly = false; 877 | if (uvRequired) 878 | { 879 | string uvPathEarly = FindUvPath(); 880 | if (string.IsNullOrEmpty(uvPathEarly)) 881 | { 882 | uvMissingEarly = true; 883 | mcpClient.configStatus = "uv Not Found"; 884 | mcpClient.status = McpStatus.NotConfigured; 885 | } 886 | } 887 | 888 | // Status display 889 | EditorGUILayout.BeginHorizontal(); 890 | Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); 891 | Color statusColor = GetStatusColor(mcpClient.status); 892 | DrawStatusDot(statusRect, statusColor, 16); 893 | 894 | GUIStyle clientStatusStyle = new GUIStyle(EditorStyles.label) 895 | { 896 | fontSize = 12, 897 | fontStyle = FontStyle.Bold 898 | }; 899 | EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28)); 900 | EditorGUILayout.EndHorizontal(); 901 | // When Claude CLI is missing, show a clear install hint directly below status 902 | if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) 903 | { 904 | GUIStyle installHintStyle = new GUIStyle(clientStatusStyle); 905 | installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange 906 | EditorGUILayout.BeginHorizontal(); 907 | GUIContent installText = new GUIContent("Make sure Claude Code is installed!"); 908 | Vector2 textSize = installHintStyle.CalcSize(installText); 909 | EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false)); 910 | GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; 911 | GUILayout.Space(6); 912 | if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) 913 | { 914 | Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code"); 915 | } 916 | EditorGUILayout.EndHorizontal(); 917 | } 918 | 919 | EditorGUILayout.Space(10); 920 | 921 | // If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls 922 | if (uvRequired && uvMissingEarly) 923 | { 924 | GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label) 925 | { 926 | fontSize = 12, 927 | fontStyle = FontStyle.Bold, 928 | wordWrap = false 929 | }; 930 | installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f); 931 | EditorGUILayout.BeginHorizontal(); 932 | GUIContent installText2 = new GUIContent("Make sure uv is installed!"); 933 | Vector2 sz = installHintStyle2.CalcSize(installText2); 934 | EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false)); 935 | GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; 936 | GUILayout.Space(6); 937 | if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) 938 | { 939 | Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf"); 940 | } 941 | EditorGUILayout.EndHorizontal(); 942 | 943 | EditorGUILayout.Space(8); 944 | EditorGUILayout.BeginHorizontal(); 945 | if (GUILayout.Button("Choose uv Install Location", GUILayout.Width(260), GUILayout.Height(22))) 946 | { 947 | string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); 948 | string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, ""); 949 | if (!string.IsNullOrEmpty(picked)) 950 | { 951 | EditorPrefs.SetString("MCPForUnity.UvPath", picked); 952 | ConfigureMcpClient(mcpClient); 953 | Repaint(); 954 | } 955 | } 956 | EditorGUILayout.EndHorizontal(); 957 | return; 958 | } 959 | 960 | // Action buttons in horizontal layout 961 | EditorGUILayout.BeginHorizontal(); 962 | 963 | if (mcpClient.mcpType == McpTypes.VSCode) 964 | { 965 | if (GUILayout.Button("Auto Configure", GUILayout.Height(32))) 966 | { 967 | ConfigureMcpClient(mcpClient); 968 | } 969 | } 970 | else if (mcpClient.mcpType == McpTypes.ClaudeCode) 971 | { 972 | bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); 973 | if (claudeAvailable) 974 | { 975 | bool isConfigured = mcpClient.status == McpStatus.Configured; 976 | string buttonText = isConfigured ? "Unregister MCP for Unity with Claude Code" : "Register with Claude Code"; 977 | if (GUILayout.Button(buttonText, GUILayout.Height(32))) 978 | { 979 | if (isConfigured) 980 | { 981 | UnregisterWithClaudeCode(); 982 | } 983 | else 984 | { 985 | string pythonDir = FindPackagePythonDirectory(); 986 | RegisterWithClaudeCode(pythonDir); 987 | } 988 | } 989 | // Hide the picker once a valid binary is available 990 | EditorGUILayout.EndHorizontal(); 991 | EditorGUILayout.BeginHorizontal(); 992 | GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true }; 993 | string resolvedClaude = ExecPath.ResolveClaude(); 994 | EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle); 995 | EditorGUILayout.EndHorizontal(); 996 | EditorGUILayout.BeginHorizontal(); 997 | } 998 | // CLI picker row (only when not found) 999 | EditorGUILayout.EndHorizontal(); 1000 | EditorGUILayout.BeginHorizontal(); 1001 | if (!claudeAvailable) 1002 | { 1003 | // Only show the picker button in not-found state (no redundant "not found" label) 1004 | if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22))) 1005 | { 1006 | string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); 1007 | string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, ""); 1008 | if (!string.IsNullOrEmpty(picked)) 1009 | { 1010 | ExecPath.SetClaudeCliPath(picked); 1011 | // Auto-register after setting a valid path 1012 | string pythonDir = FindPackagePythonDirectory(); 1013 | RegisterWithClaudeCode(pythonDir); 1014 | Repaint(); 1015 | } 1016 | } 1017 | } 1018 | EditorGUILayout.EndHorizontal(); 1019 | EditorGUILayout.BeginHorizontal(); 1020 | } 1021 | else 1022 | { 1023 | if (GUILayout.Button($"Auto Configure", GUILayout.Height(32))) 1024 | { 1025 | ConfigureMcpClient(mcpClient); 1026 | } 1027 | } 1028 | 1029 | if (mcpClient.mcpType != McpTypes.ClaudeCode) 1030 | { 1031 | if (GUILayout.Button("Manual Setup", GUILayout.Height(32))) 1032 | { 1033 | string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 1034 | ? mcpClient.windowsConfigPath 1035 | : mcpClient.linuxConfigPath; 1036 | 1037 | if (mcpClient.mcpType == McpTypes.VSCode) 1038 | { 1039 | string pythonDir = FindPackagePythonDirectory(); 1040 | string uvPath = FindUvPath(); 1041 | if (uvPath == null) 1042 | { 1043 | UnityEngine.Debug.LogError("UV package manager not found. Cannot configure VSCode."); 1044 | return; 1045 | } 1046 | // VSCode now reads from mcp.json with a top-level "servers" block 1047 | var vscodeConfig = new 1048 | { 1049 | servers = new 1050 | { 1051 | unityMCP = new 1052 | { 1053 | command = uvPath, 1054 | args = new[] { "run", "--directory", pythonDir, "server.py" } 1055 | } 1056 | } 1057 | }; 1058 | JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; 1059 | string manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); 1060 | VSCodeManualSetupWindow.ShowWindow(configPath, manualConfigJson); 1061 | } 1062 | else 1063 | { 1064 | ShowManualInstructionsWindow(configPath, mcpClient); 1065 | } 1066 | } 1067 | } 1068 | 1069 | EditorGUILayout.EndHorizontal(); 1070 | 1071 | EditorGUILayout.Space(8); 1072 | // Quick info (hide when Claude is not found to avoid confusion) 1073 | bool hideConfigInfo = 1074 | (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) 1075 | || ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath())); 1076 | if (!hideConfigInfo) 1077 | { 1078 | GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) 1079 | { 1080 | fontSize = 10 1081 | }; 1082 | EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); 1083 | } 1084 | } 1085 | 1086 | private void ToggleUnityBridge() 1087 | { 1088 | if (isUnityBridgeRunning) 1089 | { 1090 | MCPForUnityBridge.Stop(); 1091 | } 1092 | else 1093 | { 1094 | MCPForUnityBridge.Start(); 1095 | } 1096 | // Reflect the actual state post-operation (avoid optimistic toggle) 1097 | isUnityBridgeRunning = MCPForUnityBridge.IsRunning; 1098 | Repaint(); 1099 | } 1100 | 1101 | // New method to show manual instructions without changing status 1102 | private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) 1103 | { 1104 | // Get the Python directory path using Package Manager API 1105 | string pythonDir = FindPackagePythonDirectory(); 1106 | // Build manual JSON centrally using the shared builder 1107 | string uvPathForManual = FindUvPath(); 1108 | if (uvPathForManual == null) 1109 | { 1110 | UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); 1111 | return; 1112 | } 1113 | 1114 | string manualConfig = mcpClient?.mcpType == McpTypes.Codex 1115 | ? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine 1116 | : ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); 1117 | ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient); 1118 | } 1119 | 1120 | private string FindPackagePythonDirectory() 1121 | { 1122 | // Use shared helper for consistent path resolution across both windows 1123 | return McpPathResolver.FindPackagePythonDirectory(debugLogsEnabled); 1124 | } 1125 | 1126 | private string ConfigureMcpClient(McpClient mcpClient) 1127 | { 1128 | try 1129 | { 1130 | // Use shared helper for consistent config path resolution 1131 | string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); 1132 | 1133 | // Create directory if it doesn't exist 1134 | McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); 1135 | 1136 | // Find the server.py file location using shared helper 1137 | string pythonDir = FindPackagePythonDirectory(); 1138 | 1139 | if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) 1140 | { 1141 | ShowManualInstructionsWindow(configPath, mcpClient); 1142 | return "Manual Configuration Required"; 1143 | } 1144 | 1145 | string result = mcpClient.mcpType == McpTypes.Codex 1146 | ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) 1147 | : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); 1148 | 1149 | // Update the client status after successful configuration 1150 | if (result == "Configured successfully") 1151 | { 1152 | mcpClient.SetStatus(McpStatus.Configured); 1153 | } 1154 | 1155 | return result; 1156 | } 1157 | catch (Exception e) 1158 | { 1159 | // Determine the config file path based on OS for error message 1160 | string configPath = ""; 1161 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 1162 | { 1163 | configPath = mcpClient.windowsConfigPath; 1164 | } 1165 | else if ( 1166 | RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 1167 | ) 1168 | { 1169 | configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) 1170 | ? mcpClient.linuxConfigPath 1171 | : mcpClient.macConfigPath; 1172 | } 1173 | else if ( 1174 | RuntimeInformation.IsOSPlatform(OSPlatform.Linux) 1175 | ) 1176 | { 1177 | configPath = mcpClient.linuxConfigPath; 1178 | } 1179 | 1180 | ShowManualInstructionsWindow(configPath, mcpClient); 1181 | UnityEngine.Debug.LogError( 1182 | $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" 1183 | ); 1184 | return $"Failed to configure {mcpClient.name}"; 1185 | } 1186 | } 1187 | 1188 | private void LoadValidationLevelSetting() 1189 | { 1190 | string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); 1191 | validationLevelIndex = savedLevel.ToLower() switch 1192 | { 1193 | "basic" => 0, 1194 | "standard" => 1, 1195 | "comprehensive" => 2, 1196 | "strict" => 3, 1197 | _ => 1 // Default to Standard 1198 | }; 1199 | } 1200 | 1201 | private void SaveValidationLevelSetting() 1202 | { 1203 | string levelString = validationLevelIndex switch 1204 | { 1205 | 0 => "basic", 1206 | 1 => "standard", 1207 | 2 => "comprehensive", 1208 | 3 => "strict", 1209 | _ => "standard" 1210 | }; 1211 | EditorPrefs.SetString("MCPForUnity_ScriptValidationLevel", levelString); 1212 | } 1213 | 1214 | private string GetValidationLevelDescription(int index) 1215 | { 1216 | return index switch 1217 | { 1218 | 0 => "Only basic syntax checks (braces, quotes, comments)", 1219 | 1 => "Syntax checks + Unity best practices and warnings", 1220 | 2 => "All checks + semantic analysis and performance warnings", 1221 | 3 => "Full semantic validation with namespace/type resolution (requires Roslyn)", 1222 | _ => "Standard validation" 1223 | }; 1224 | } 1225 | 1226 | private void CheckMcpConfiguration(McpClient mcpClient) 1227 | { 1228 | try 1229 | { 1230 | // Special handling for Claude Code 1231 | if (mcpClient.mcpType == McpTypes.ClaudeCode) 1232 | { 1233 | CheckClaudeCodeConfiguration(mcpClient); 1234 | return; 1235 | } 1236 | 1237 | // Use shared helper for consistent config path resolution 1238 | string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); 1239 | 1240 | if (!File.Exists(configPath)) 1241 | { 1242 | mcpClient.SetStatus(McpStatus.NotConfigured); 1243 | return; 1244 | } 1245 | 1246 | string configJson = File.ReadAllText(configPath); 1247 | // Use the same path resolution as configuration to avoid false "Incorrect Path" in dev mode 1248 | string pythonDir = FindPackagePythonDirectory(); 1249 | 1250 | // Use switch statement to handle different client types, extracting common logic 1251 | string[] args = null; 1252 | bool configExists = false; 1253 | 1254 | switch (mcpClient.mcpType) 1255 | { 1256 | case McpTypes.VSCode: 1257 | dynamic config = JsonConvert.DeserializeObject(configJson); 1258 | 1259 | // New schema: top-level servers 1260 | if (config?.servers?.unityMCP != null) 1261 | { 1262 | args = config.servers.unityMCP.args.ToObject<string[]>(); 1263 | configExists = true; 1264 | } 1265 | // Back-compat: legacy mcp.servers 1266 | else if (config?.mcp?.servers?.unityMCP != null) 1267 | { 1268 | args = config.mcp.servers.unityMCP.args.ToObject<string[]>(); 1269 | configExists = true; 1270 | } 1271 | break; 1272 | 1273 | case McpTypes.Codex: 1274 | if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) 1275 | { 1276 | args = codexArgs; 1277 | configExists = true; 1278 | } 1279 | break; 1280 | 1281 | default: 1282 | // Standard MCP configuration check for Claude Desktop, Cursor, etc. 1283 | McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson); 1284 | 1285 | if (standardConfig?.mcpServers?.unityMCP != null) 1286 | { 1287 | args = standardConfig.mcpServers.unityMCP.args; 1288 | configExists = true; 1289 | } 1290 | break; 1291 | } 1292 | 1293 | // Common logic for checking configuration status 1294 | if (configExists) 1295 | { 1296 | string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args); 1297 | bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir); 1298 | if (matches) 1299 | { 1300 | mcpClient.SetStatus(McpStatus.Configured); 1301 | } 1302 | else 1303 | { 1304 | // Attempt auto-rewrite once if the package path changed 1305 | try 1306 | { 1307 | string rewriteResult = mcpClient.mcpType == McpTypes.Codex 1308 | ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) 1309 | : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); 1310 | if (rewriteResult == "Configured successfully") 1311 | { 1312 | if (debugLogsEnabled) 1313 | { 1314 | MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false); 1315 | } 1316 | mcpClient.SetStatus(McpStatus.Configured); 1317 | } 1318 | else 1319 | { 1320 | mcpClient.SetStatus(McpStatus.IncorrectPath); 1321 | } 1322 | } 1323 | catch (Exception ex) 1324 | { 1325 | mcpClient.SetStatus(McpStatus.IncorrectPath); 1326 | if (debugLogsEnabled) 1327 | { 1328 | UnityEngine.Debug.LogWarning($"MCP for Unity: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); 1329 | } 1330 | } 1331 | } 1332 | } 1333 | else 1334 | { 1335 | mcpClient.SetStatus(McpStatus.MissingConfig); 1336 | } 1337 | } 1338 | catch (Exception e) 1339 | { 1340 | mcpClient.SetStatus(McpStatus.Error, e.Message); 1341 | } 1342 | } 1343 | 1344 | private void RegisterWithClaudeCode(string pythonDir) 1345 | { 1346 | // Resolve claude and uv; then run register command 1347 | string claudePath = ExecPath.ResolveClaude(); 1348 | if (string.IsNullOrEmpty(claudePath)) 1349 | { 1350 | UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); 1351 | return; 1352 | } 1353 | string uvPath = ExecPath.ResolveUv() ?? "uv"; 1354 | 1355 | // Prefer embedded/dev path when available 1356 | string srcDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); 1357 | if (string.IsNullOrEmpty(srcDir)) srcDir = pythonDir; 1358 | 1359 | string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{srcDir}\" server.py"; 1360 | 1361 | string projectDir = Path.GetDirectoryName(Application.dataPath); 1362 | // Ensure PATH includes common locations on Unix; on Windows leave PATH as-is 1363 | string pathPrepend = null; 1364 | if (Application.platform == RuntimePlatform.OSXEditor || Application.platform == RuntimePlatform.LinuxEditor) 1365 | { 1366 | pathPrepend = Application.platform == RuntimePlatform.OSXEditor 1367 | ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" 1368 | : "/usr/local/bin:/usr/bin:/bin"; 1369 | } 1370 | if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) 1371 | { 1372 | string combined = ($"{stdout}\n{stderr}") ?? string.Empty; 1373 | if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) 1374 | { 1375 | // Treat as success if Claude reports existing registration 1376 | var existingClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); 1377 | if (existingClient != null) CheckClaudeCodeConfiguration(existingClient); 1378 | Repaint(); 1379 | UnityEngine.Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCP for Unity already registered with Claude Code."); 1380 | } 1381 | else 1382 | { 1383 | UnityEngine.Debug.LogError($"MCP for Unity: Failed to start Claude CLI.\n{stderr}\n{stdout}"); 1384 | } 1385 | return; 1386 | } 1387 | 1388 | // Update status 1389 | var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); 1390 | if (claudeClient != null) CheckClaudeCodeConfiguration(claudeClient); 1391 | Repaint(); 1392 | UnityEngine.Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Registered with Claude Code."); 1393 | } 1394 | 1395 | private void UnregisterWithClaudeCode() 1396 | { 1397 | string claudePath = ExecPath.ResolveClaude(); 1398 | if (string.IsNullOrEmpty(claudePath)) 1399 | { 1400 | UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); 1401 | return; 1402 | } 1403 | 1404 | string projectDir = Path.GetDirectoryName(Application.dataPath); 1405 | string pathPrepend = Application.platform == RuntimePlatform.OSXEditor 1406 | ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" 1407 | : null; // On Windows, don't modify PATH - use system PATH as-is 1408 | 1409 | // Determine if Claude has a "UnityMCP" server registered by using exit codes from `claude mcp get <name>` 1410 | string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; 1411 | List<string> existingNames = new List<string>(); 1412 | foreach (var candidate in candidateNamesForGet) 1413 | { 1414 | if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) 1415 | { 1416 | // Success exit code indicates the server exists 1417 | existingNames.Add(candidate); 1418 | } 1419 | } 1420 | 1421 | if (existingNames.Count == 0) 1422 | { 1423 | // Nothing to unregister – set status and bail early 1424 | var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); 1425 | if (claudeClient != null) 1426 | { 1427 | claudeClient.SetStatus(McpStatus.NotConfigured); 1428 | UnityEngine.Debug.Log("Claude CLI reports no MCP for Unity server via 'mcp get' - setting status to NotConfigured and aborting unregister."); 1429 | Repaint(); 1430 | } 1431 | return; 1432 | } 1433 | 1434 | // Try different possible server names 1435 | string[] possibleNames = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; 1436 | bool success = false; 1437 | 1438 | foreach (string serverName in possibleNames) 1439 | { 1440 | if (ExecPath.TryRun(claudePath, $"mcp remove {serverName}", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) 1441 | { 1442 | success = true; 1443 | UnityEngine.Debug.Log($"MCP for Unity: Successfully removed MCP server: {serverName}"); 1444 | break; 1445 | } 1446 | else if (!string.IsNullOrEmpty(stderr) && 1447 | !stderr.Contains("No MCP server found", StringComparison.OrdinalIgnoreCase)) 1448 | { 1449 | // If it's not a "not found" error, log it and stop trying 1450 | UnityEngine.Debug.LogWarning($"Error removing {serverName}: {stderr}"); 1451 | break; 1452 | } 1453 | } 1454 | 1455 | if (success) 1456 | { 1457 | var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); 1458 | if (claudeClient != null) 1459 | { 1460 | // Optimistically flip to NotConfigured; then verify 1461 | claudeClient.SetStatus(McpStatus.NotConfigured); 1462 | CheckClaudeCodeConfiguration(claudeClient); 1463 | } 1464 | Repaint(); 1465 | UnityEngine.Debug.Log("MCP for Unity: MCP server successfully unregistered from Claude Code."); 1466 | } 1467 | else 1468 | { 1469 | // If no servers were found to remove, they're already unregistered 1470 | // Force status to NotConfigured and update the UI 1471 | UnityEngine.Debug.Log("No MCP servers found to unregister - already unregistered."); 1472 | var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); 1473 | if (claudeClient != null) 1474 | { 1475 | claudeClient.SetStatus(McpStatus.NotConfigured); 1476 | CheckClaudeCodeConfiguration(claudeClient); 1477 | } 1478 | Repaint(); 1479 | } 1480 | } 1481 | 1482 | // Removed unused ParseTextOutput 1483 | 1484 | private string FindUvPath() 1485 | { 1486 | try { return MCPForUnity.Editor.Helpers.ServerInstaller.FindUvPath(); } catch { return null; } 1487 | } 1488 | 1489 | // Validation and platform-specific scanning are handled by ServerInstaller.FindUvPath() 1490 | 1491 | // Windows-specific discovery removed; use ServerInstaller.FindUvPath() instead 1492 | 1493 | // Removed unused FindClaudeCommand 1494 | 1495 | private void CheckClaudeCodeConfiguration(McpClient mcpClient) 1496 | { 1497 | try 1498 | { 1499 | // Get the Unity project directory to check project-specific config 1500 | string unityProjectDir = Application.dataPath; 1501 | string projectDir = Path.GetDirectoryName(unityProjectDir); 1502 | 1503 | // Read the global Claude config file (honor macConfigPath on macOS) 1504 | string configPath; 1505 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 1506 | configPath = mcpClient.windowsConfigPath; 1507 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 1508 | configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; 1509 | else 1510 | configPath = mcpClient.linuxConfigPath; 1511 | 1512 | if (debugLogsEnabled) 1513 | { 1514 | MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false); 1515 | } 1516 | 1517 | if (!File.Exists(configPath)) 1518 | { 1519 | UnityEngine.Debug.LogWarning($"Claude config file not found at: {configPath}"); 1520 | mcpClient.SetStatus(McpStatus.NotConfigured); 1521 | return; 1522 | } 1523 | 1524 | string configJson = File.ReadAllText(configPath); 1525 | dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); 1526 | 1527 | // Check for "UnityMCP" server in the mcpServers section (current format) 1528 | if (claudeConfig?.mcpServers != null) 1529 | { 1530 | var servers = claudeConfig.mcpServers; 1531 | if (servers.UnityMCP != null || servers.unityMCP != null) 1532 | { 1533 | // Found MCP for Unity configured 1534 | mcpClient.SetStatus(McpStatus.Configured); 1535 | return; 1536 | } 1537 | } 1538 | 1539 | // Also check if there's a project-specific configuration for this Unity project (legacy format) 1540 | if (claudeConfig?.projects != null) 1541 | { 1542 | // Look for the project path in the config 1543 | foreach (var project in claudeConfig.projects) 1544 | { 1545 | string projectPath = project.Name; 1546 | 1547 | // Normalize paths for comparison (handle forward/back slash differences) 1548 | string normalizedProjectPath = Path.GetFullPath(projectPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); 1549 | string normalizedProjectDir = Path.GetFullPath(projectDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); 1550 | 1551 | if (string.Equals(normalizedProjectPath, normalizedProjectDir, StringComparison.OrdinalIgnoreCase) && project.Value?.mcpServers != null) 1552 | { 1553 | // Check for "UnityMCP" (case variations) 1554 | var servers = project.Value.mcpServers; 1555 | if (servers.UnityMCP != null || servers.unityMCP != null) 1556 | { 1557 | // Found MCP for Unity configured for this project 1558 | mcpClient.SetStatus(McpStatus.Configured); 1559 | return; 1560 | } 1561 | } 1562 | } 1563 | } 1564 | 1565 | // No configuration found for this project 1566 | mcpClient.SetStatus(McpStatus.NotConfigured); 1567 | } 1568 | catch (Exception e) 1569 | { 1570 | UnityEngine.Debug.LogWarning($"Error checking Claude Code config: {e.Message}"); 1571 | mcpClient.SetStatus(McpStatus.Error, e.Message); 1572 | } 1573 | } 1574 | 1575 | private bool IsPythonDetected() 1576 | { 1577 | try 1578 | { 1579 | // Windows-specific Python detection 1580 | if (Application.platform == RuntimePlatform.WindowsEditor) 1581 | { 1582 | // Common Windows Python installation paths 1583 | string[] windowsCandidates = 1584 | { 1585 | @"C:\Python313\python.exe", 1586 | @"C:\Python312\python.exe", 1587 | @"C:\Python311\python.exe", 1588 | @"C:\Python310\python.exe", 1589 | @"C:\Python39\python.exe", 1590 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), 1591 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), 1592 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), 1593 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), 1594 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), 1595 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), 1596 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), 1597 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), 1598 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), 1599 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), 1600 | }; 1601 | 1602 | foreach (string c in windowsCandidates) 1603 | { 1604 | if (File.Exists(c)) return true; 1605 | } 1606 | 1607 | // Try 'where python' command (Windows equivalent of 'which') 1608 | var psi = new ProcessStartInfo 1609 | { 1610 | FileName = "where", 1611 | Arguments = "python", 1612 | UseShellExecute = false, 1613 | RedirectStandardOutput = true, 1614 | RedirectStandardError = true, 1615 | CreateNoWindow = true 1616 | }; 1617 | using var p = Process.Start(psi); 1618 | string outp = p.StandardOutput.ReadToEnd().Trim(); 1619 | p.WaitForExit(2000); 1620 | if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) 1621 | { 1622 | string[] lines = outp.Split('\n'); 1623 | foreach (string line in lines) 1624 | { 1625 | string trimmed = line.Trim(); 1626 | if (File.Exists(trimmed)) return true; 1627 | } 1628 | } 1629 | } 1630 | else 1631 | { 1632 | // macOS/Linux detection (existing code) 1633 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 1634 | string[] candidates = 1635 | { 1636 | "/opt/homebrew/bin/python3", 1637 | "/usr/local/bin/python3", 1638 | "/usr/bin/python3", 1639 | "/opt/local/bin/python3", 1640 | Path.Combine(home, ".local", "bin", "python3"), 1641 | "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", 1642 | "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", 1643 | }; 1644 | foreach (string c in candidates) 1645 | { 1646 | if (File.Exists(c)) return true; 1647 | } 1648 | 1649 | // Try 'which python3' 1650 | var psi = new ProcessStartInfo 1651 | { 1652 | FileName = "/usr/bin/which", 1653 | Arguments = "python3", 1654 | UseShellExecute = false, 1655 | RedirectStandardOutput = true, 1656 | RedirectStandardError = true, 1657 | CreateNoWindow = true 1658 | }; 1659 | using var p = Process.Start(psi); 1660 | string outp = p.StandardOutput.ReadToEnd().Trim(); 1661 | p.WaitForExit(2000); 1662 | if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; 1663 | } 1664 | } 1665 | catch { } 1666 | return false; 1667 | } 1668 | } 1669 | } 1670 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/External/Tommy.cs: -------------------------------------------------------------------------------- ```csharp 1 | #region LICENSE 2 | 3 | /* 4 | * MIT License 5 | * 6 | * Copyright (c) 2020 Denis Zhidkikh 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy 9 | * of this software and associated documentation files (the "Software"), to deal 10 | * in the Software without restriction, including without limitation the rights 11 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | * copies of the Software, and to permit persons to whom the Software is 13 | * furnished to do so, subject to the following conditions: 14 | * 15 | * The above copyright notice and this permission notice shall be included in all 16 | * copies or substantial portions of the Software. 17 | * 18 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | * SOFTWARE. 25 | */ 26 | 27 | #endregion 28 | 29 | using System; 30 | using System.Collections; 31 | using System.Collections.Generic; 32 | using System.Globalization; 33 | using System.IO; 34 | using System.Linq; 35 | using System.Text; 36 | using System.Text.RegularExpressions; 37 | 38 | namespace MCPForUnity.External.Tommy 39 | { 40 | #region TOML Nodes 41 | 42 | public abstract class TomlNode : IEnumerable 43 | { 44 | public virtual bool HasValue { get; } = false; 45 | public virtual bool IsArray { get; } = false; 46 | public virtual bool IsTable { get; } = false; 47 | public virtual bool IsString { get; } = false; 48 | public virtual bool IsInteger { get; } = false; 49 | public virtual bool IsFloat { get; } = false; 50 | public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset; 51 | public virtual bool IsDateTimeLocal { get; } = false; 52 | public virtual bool IsDateTimeOffset { get; } = false; 53 | public virtual bool IsBoolean { get; } = false; 54 | public virtual string Comment { get; set; } 55 | public virtual int CollapseLevel { get; set; } 56 | 57 | public virtual TomlTable AsTable => this as TomlTable; 58 | public virtual TomlString AsString => this as TomlString; 59 | public virtual TomlInteger AsInteger => this as TomlInteger; 60 | public virtual TomlFloat AsFloat => this as TomlFloat; 61 | public virtual TomlBoolean AsBoolean => this as TomlBoolean; 62 | public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal; 63 | public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset; 64 | public virtual TomlDateTime AsDateTime => this as TomlDateTime; 65 | public virtual TomlArray AsArray => this as TomlArray; 66 | 67 | public virtual int ChildrenCount => 0; 68 | 69 | public virtual TomlNode this[string key] 70 | { 71 | get => null; 72 | set { } 73 | } 74 | 75 | public virtual TomlNode this[int index] 76 | { 77 | get => null; 78 | set { } 79 | } 80 | 81 | public virtual IEnumerable<TomlNode> Children 82 | { 83 | get { yield break; } 84 | } 85 | 86 | public virtual IEnumerable<string> Keys 87 | { 88 | get { yield break; } 89 | } 90 | 91 | public IEnumerator GetEnumerator() => Children.GetEnumerator(); 92 | 93 | public virtual bool TryGetNode(string key, out TomlNode node) 94 | { 95 | node = null; 96 | return false; 97 | } 98 | 99 | public virtual bool HasKey(string key) => false; 100 | 101 | public virtual bool HasItemAt(int index) => false; 102 | 103 | public virtual void Add(string key, TomlNode node) { } 104 | 105 | public virtual void Add(TomlNode node) { } 106 | 107 | public virtual void Delete(TomlNode node) { } 108 | 109 | public virtual void Delete(string key) { } 110 | 111 | public virtual void Delete(int index) { } 112 | 113 | public virtual void AddRange(IEnumerable<TomlNode> nodes) 114 | { 115 | foreach (var tomlNode in nodes) Add(tomlNode); 116 | } 117 | 118 | public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml()); 119 | 120 | public virtual string ToInlineToml() => ToString(); 121 | 122 | #region Native type to TOML cast 123 | 124 | public static implicit operator TomlNode(string value) => new TomlString { Value = value }; 125 | 126 | public static implicit operator TomlNode(bool value) => new TomlBoolean { Value = value }; 127 | 128 | public static implicit operator TomlNode(long value) => new TomlInteger { Value = value }; 129 | 130 | public static implicit operator TomlNode(float value) => new TomlFloat { Value = value }; 131 | 132 | public static implicit operator TomlNode(double value) => new TomlFloat { Value = value }; 133 | 134 | public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal { Value = value }; 135 | 136 | public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset { Value = value }; 137 | 138 | public static implicit operator TomlNode(TomlNode[] nodes) 139 | { 140 | var result = new TomlArray(); 141 | result.AddRange(nodes); 142 | return result; 143 | } 144 | 145 | #endregion 146 | 147 | #region TOML to native type cast 148 | 149 | public static implicit operator string(TomlNode value) => value.ToString(); 150 | 151 | public static implicit operator int(TomlNode value) => (int)value.AsInteger.Value; 152 | 153 | public static implicit operator long(TomlNode value) => value.AsInteger.Value; 154 | 155 | public static implicit operator float(TomlNode value) => (float)value.AsFloat.Value; 156 | 157 | public static implicit operator double(TomlNode value) => value.AsFloat.Value; 158 | 159 | public static implicit operator bool(TomlNode value) => value.AsBoolean.Value; 160 | 161 | public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value; 162 | 163 | public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value; 164 | 165 | #endregion 166 | } 167 | 168 | public class TomlString : TomlNode 169 | { 170 | public override bool HasValue { get; } = true; 171 | public override bool IsString { get; } = true; 172 | public bool IsMultiline { get; set; } 173 | public bool MultilineTrimFirstLine { get; set; } 174 | public bool PreferLiteral { get; set; } 175 | 176 | public string Value { get; set; } 177 | 178 | public override string ToString() => Value; 179 | 180 | public override string ToInlineToml() 181 | { 182 | // Automatically convert literal to non-literal if there are too many literal string symbols 183 | if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false; 184 | var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL, 185 | IsMultiline ? 3 : 1); 186 | var result = PreferLiteral ? Value : Value.Escape(!IsMultiline); 187 | if (IsMultiline) 188 | result = result.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); 189 | if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(Environment.NewLine))) 190 | result = $"{Environment.NewLine}{result}"; 191 | return $"{quotes}{result}{quotes}"; 192 | } 193 | } 194 | 195 | public class TomlInteger : TomlNode 196 | { 197 | public enum Base 198 | { 199 | Binary = 2, 200 | Octal = 8, 201 | Decimal = 10, 202 | Hexadecimal = 16 203 | } 204 | 205 | public override bool IsInteger { get; } = true; 206 | public override bool HasValue { get; } = true; 207 | public Base IntegerBase { get; set; } = Base.Decimal; 208 | 209 | public long Value { get; set; } 210 | 211 | public override string ToString() => Value.ToString(); 212 | 213 | public override string ToInlineToml() => 214 | IntegerBase != Base.Decimal 215 | ? $"0{TomlSyntax.BaseIdentifiers[(int)IntegerBase]}{Convert.ToString(Value, (int)IntegerBase)}" 216 | : Value.ToString(CultureInfo.InvariantCulture); 217 | } 218 | 219 | public class TomlFloat : TomlNode, IFormattable 220 | { 221 | public override bool IsFloat { get; } = true; 222 | public override bool HasValue { get; } = true; 223 | 224 | public double Value { get; set; } 225 | 226 | public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); 227 | 228 | public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); 229 | 230 | public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); 231 | 232 | public override string ToInlineToml() => 233 | Value switch 234 | { 235 | var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, 236 | var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, 237 | var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, 238 | var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() 239 | }; 240 | } 241 | 242 | public class TomlBoolean : TomlNode 243 | { 244 | public override bool IsBoolean { get; } = true; 245 | public override bool HasValue { get; } = true; 246 | 247 | public bool Value { get; set; } 248 | 249 | public override string ToString() => Value.ToString(); 250 | 251 | public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE; 252 | } 253 | 254 | public class TomlDateTime : TomlNode, IFormattable 255 | { 256 | public int SecondsPrecision { get; set; } 257 | public override bool HasValue { get; } = true; 258 | public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty; 259 | public virtual string ToString(IFormatProvider formatProvider) => string.Empty; 260 | protected virtual string ToInlineTomlInternal() => string.Empty; 261 | 262 | public override string ToInlineToml() => ToInlineTomlInternal() 263 | .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator) 264 | .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone); 265 | } 266 | 267 | public class TomlDateTimeOffset : TomlDateTime 268 | { 269 | public override bool IsDateTimeOffset { get; } = true; 270 | public DateTimeOffset Value { get; set; } 271 | 272 | public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); 273 | public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); 274 | 275 | public override string ToString(string format, IFormatProvider formatProvider) => 276 | Value.ToString(format, formatProvider); 277 | 278 | protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]); 279 | } 280 | 281 | public class TomlDateTimeLocal : TomlDateTime 282 | { 283 | public enum DateTimeStyle 284 | { 285 | Date, 286 | Time, 287 | DateTime 288 | } 289 | 290 | public override bool IsDateTimeLocal { get; } = true; 291 | public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; 292 | public DateTime Value { get; set; } 293 | 294 | public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); 295 | 296 | public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); 297 | 298 | public override string ToString(string format, IFormatProvider formatProvider) => 299 | Value.ToString(format, formatProvider); 300 | 301 | public override string ToInlineToml() => 302 | Style switch 303 | { 304 | DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), 305 | DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), 306 | var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) 307 | }; 308 | } 309 | 310 | public class TomlArray : TomlNode 311 | { 312 | private List<TomlNode> values; 313 | 314 | public override bool HasValue { get; } = true; 315 | public override bool IsArray { get; } = true; 316 | public bool IsMultiline { get; set; } 317 | public bool IsTableArray { get; set; } 318 | public List<TomlNode> RawArray => values ??= new List<TomlNode>(); 319 | 320 | public override TomlNode this[int index] 321 | { 322 | get 323 | { 324 | if (index < RawArray.Count) return RawArray[index]; 325 | var lazy = new TomlLazy(this); 326 | this[index] = lazy; 327 | return lazy; 328 | } 329 | set 330 | { 331 | if (index == RawArray.Count) 332 | RawArray.Add(value); 333 | else 334 | RawArray[index] = value; 335 | } 336 | } 337 | 338 | public override int ChildrenCount => RawArray.Count; 339 | 340 | public override IEnumerable<TomlNode> Children => RawArray.AsEnumerable(); 341 | 342 | public override void Add(TomlNode node) => RawArray.Add(node); 343 | 344 | public override void AddRange(IEnumerable<TomlNode> nodes) => RawArray.AddRange(nodes); 345 | 346 | public override void Delete(TomlNode node) => RawArray.Remove(node); 347 | 348 | public override void Delete(int index) => RawArray.RemoveAt(index); 349 | 350 | public override string ToString() => ToString(false); 351 | 352 | public string ToString(bool multiline) 353 | { 354 | var sb = new StringBuilder(); 355 | sb.Append(TomlSyntax.ARRAY_START_SYMBOL); 356 | if (ChildrenCount != 0) 357 | { 358 | var arrayStart = multiline ? $"{Environment.NewLine} " : " "; 359 | var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{Environment.NewLine} " : $"{TomlSyntax.ITEM_SEPARATOR} "; 360 | var arrayEnd = multiline ? Environment.NewLine : " "; 361 | sb.Append(arrayStart) 362 | .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml()))) 363 | .Append(arrayEnd); 364 | } 365 | sb.Append(TomlSyntax.ARRAY_END_SYMBOL); 366 | return sb.ToString(); 367 | } 368 | 369 | public override void WriteTo(TextWriter tw, string name = null) 370 | { 371 | // If it's a normal array, write it as usual 372 | if (!IsTableArray) 373 | { 374 | tw.WriteLine(ToString(IsMultiline)); 375 | return; 376 | } 377 | 378 | if (!(Comment is null)) 379 | { 380 | tw.WriteLine(); 381 | Comment.AsComment(tw); 382 | } 383 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL); 384 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL); 385 | tw.Write(name); 386 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL); 387 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL); 388 | tw.WriteLine(); 389 | 390 | var first = true; 391 | 392 | foreach (var tomlNode in RawArray) 393 | { 394 | if (!(tomlNode is TomlTable tbl)) 395 | throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); 396 | 397 | // Ensure it's parsed as a section 398 | tbl.IsInline = false; 399 | 400 | if (!first) 401 | { 402 | tw.WriteLine(); 403 | 404 | Comment?.AsComment(tw); 405 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL); 406 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL); 407 | tw.Write(name); 408 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL); 409 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL); 410 | tw.WriteLine(); 411 | } 412 | 413 | first = false; 414 | 415 | // Don't write section since it's already written here 416 | tbl.WriteTo(tw, name, false); 417 | } 418 | } 419 | } 420 | 421 | public class TomlTable : TomlNode 422 | { 423 | private Dictionary<string, TomlNode> children; 424 | internal bool isImplicit; 425 | 426 | public override bool HasValue { get; } = false; 427 | public override bool IsTable { get; } = true; 428 | public bool IsInline { get; set; } 429 | public Dictionary<string, TomlNode> RawTable => children ??= new Dictionary<string, TomlNode>(); 430 | 431 | public override TomlNode this[string key] 432 | { 433 | get 434 | { 435 | if (RawTable.TryGetValue(key, out var result)) return result; 436 | var lazy = new TomlLazy(this); 437 | RawTable[key] = lazy; 438 | return lazy; 439 | } 440 | set => RawTable[key] = value; 441 | } 442 | 443 | public override int ChildrenCount => RawTable.Count; 444 | public override IEnumerable<TomlNode> Children => RawTable.Select(kv => kv.Value); 445 | public override IEnumerable<string> Keys => RawTable.Select(kv => kv.Key); 446 | public override bool HasKey(string key) => RawTable.ContainsKey(key); 447 | public override void Add(string key, TomlNode node) => RawTable.Add(key, node); 448 | public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node); 449 | public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key); 450 | public override void Delete(string key) => RawTable.Remove(key); 451 | 452 | public override string ToString() 453 | { 454 | var sb = new StringBuilder(); 455 | sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL); 456 | 457 | if (ChildrenCount != 0) 458 | { 459 | var collapsed = CollectCollapsedItems(normalizeOrder: false); 460 | 461 | if (collapsed.Count != 0) 462 | sb.Append(' ') 463 | .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n => 464 | $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}"))); 465 | sb.Append(' '); 466 | } 467 | 468 | sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL); 469 | return sb.ToString(); 470 | } 471 | 472 | private LinkedList<KeyValuePair<string, TomlNode>> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true) 473 | { 474 | var nodes = new LinkedList<KeyValuePair<string, TomlNode>>(); 475 | var postNodes = normalizeOrder ? new LinkedList<KeyValuePair<string, TomlNode>>() : nodes; 476 | 477 | foreach (var keyValuePair in RawTable) 478 | { 479 | var node = keyValuePair.Value; 480 | var key = keyValuePair.Key.AsKey(); 481 | 482 | if (node is TomlTable tbl) 483 | { 484 | var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); 485 | // Write main table first before writing collapsed items 486 | if (subnodes.Count == 0 && node.CollapseLevel == level) 487 | { 488 | postNodes.AddLast(new KeyValuePair<string, TomlNode>($"{prefix}{key}", node)); 489 | } 490 | foreach (var kv in subnodes) 491 | postNodes.AddLast(kv); 492 | } 493 | else if (node.CollapseLevel == level) 494 | nodes.AddLast(new KeyValuePair<string, TomlNode>($"{prefix}{key}", node)); 495 | } 496 | 497 | if (normalizeOrder) 498 | foreach (var kv in postNodes) 499 | nodes.AddLast(kv); 500 | 501 | return nodes; 502 | } 503 | 504 | public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true); 505 | 506 | internal void WriteTo(TextWriter tw, string name, bool writeSectionName) 507 | { 508 | // The table is inline table 509 | if (IsInline && name != null) 510 | { 511 | tw.WriteLine(ToInlineToml()); 512 | return; 513 | } 514 | 515 | var collapsedItems = CollectCollapsedItems(); 516 | 517 | if (collapsedItems.Count == 0) 518 | return; 519 | 520 | var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable { IsInline: false } or TomlArray { IsTableArray: true }); 521 | 522 | Comment?.AsComment(tw); 523 | 524 | if (name != null && (hasRealValues || Comment != null) && writeSectionName) 525 | { 526 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL); 527 | tw.Write(name); 528 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL); 529 | tw.WriteLine(); 530 | } 531 | else if (Comment != null) // Add some spacing between the first node and the comment 532 | { 533 | tw.WriteLine(); 534 | } 535 | 536 | var namePrefix = name == null ? "" : $"{name}."; 537 | var first = true; 538 | 539 | foreach (var collapsedItem in collapsedItems) 540 | { 541 | var key = collapsedItem.Key; 542 | if (collapsedItem.Value is TomlArray { IsTableArray: true } or TomlTable { IsInline: false }) 543 | { 544 | if (!first) tw.WriteLine(); 545 | first = false; 546 | collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); 547 | continue; 548 | } 549 | first = false; 550 | 551 | collapsedItem.Value.Comment?.AsComment(tw); 552 | tw.Write(key); 553 | tw.Write(' '); 554 | tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); 555 | tw.Write(' '); 556 | 557 | collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); 558 | } 559 | } 560 | } 561 | 562 | internal class TomlLazy : TomlNode 563 | { 564 | private readonly TomlNode parent; 565 | private TomlNode replacement; 566 | 567 | public TomlLazy(TomlNode parent) => this.parent = parent; 568 | 569 | public override TomlNode this[int index] 570 | { 571 | get => Set<TomlArray>()[index]; 572 | set => Set<TomlArray>()[index] = value; 573 | } 574 | 575 | public override TomlNode this[string key] 576 | { 577 | get => Set<TomlTable>()[key]; 578 | set => Set<TomlTable>()[key] = value; 579 | } 580 | 581 | public override void Add(TomlNode node) => Set<TomlArray>().Add(node); 582 | 583 | public override void Add(string key, TomlNode node) => Set<TomlTable>().Add(key, node); 584 | 585 | public override void AddRange(IEnumerable<TomlNode> nodes) => Set<TomlArray>().AddRange(nodes); 586 | 587 | private TomlNode Set<T>() where T : TomlNode, new() 588 | { 589 | if (replacement != null) return replacement; 590 | 591 | var newNode = new T 592 | { 593 | Comment = Comment 594 | }; 595 | 596 | if (parent.IsTable) 597 | { 598 | var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this)); 599 | if (key == null) return default(T); 600 | 601 | parent[key] = newNode; 602 | } 603 | else if (parent.IsArray) 604 | { 605 | var index = parent.Children.TakeWhile(child => child != this).Count(); 606 | if (index == parent.ChildrenCount) return default(T); 607 | parent[index] = newNode; 608 | } 609 | else 610 | { 611 | return default(T); 612 | } 613 | 614 | replacement = newNode; 615 | return newNode; 616 | } 617 | } 618 | 619 | #endregion 620 | 621 | #region Parser 622 | 623 | public class TOMLParser : IDisposable 624 | { 625 | public enum ParseState 626 | { 627 | None, 628 | KeyValuePair, 629 | SkipToNextLine, 630 | Table 631 | } 632 | 633 | private readonly TextReader reader; 634 | private ParseState currentState; 635 | private int line, col; 636 | private List<TomlSyntaxException> syntaxErrors; 637 | 638 | public TOMLParser(TextReader reader) 639 | { 640 | this.reader = reader; 641 | line = col = 0; 642 | } 643 | 644 | public bool ForceASCII { get; set; } 645 | 646 | public void Dispose() => reader?.Dispose(); 647 | 648 | public TomlTable Parse() 649 | { 650 | syntaxErrors = new List<TomlSyntaxException>(); 651 | line = col = 1; 652 | var rootNode = new TomlTable(); 653 | var currentNode = rootNode; 654 | currentState = ParseState.None; 655 | var keyParts = new List<string>(); 656 | var arrayTable = false; 657 | StringBuilder latestComment = null; 658 | var firstComment = true; 659 | 660 | int currentChar; 661 | while ((currentChar = reader.Peek()) >= 0) 662 | { 663 | var c = (char)currentChar; 664 | 665 | if (currentState == ParseState.None) 666 | { 667 | // Skip white space 668 | if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; 669 | 670 | if (TomlSyntax.IsNewLine(c)) 671 | { 672 | // Check if there are any comments and so far no items being declared 673 | if (latestComment != null && firstComment) 674 | { 675 | rootNode.Comment = latestComment.ToString().TrimEnd(); 676 | latestComment = null; 677 | firstComment = false; 678 | } 679 | 680 | if (TomlSyntax.IsLineBreak(c)) 681 | AdvanceLine(); 682 | 683 | goto consume_character; 684 | } 685 | 686 | // Start of a comment; ignore until newline 687 | if (c == TomlSyntax.COMMENT_SYMBOL) 688 | { 689 | latestComment ??= new StringBuilder(); 690 | latestComment.AppendLine(ParseComment()); 691 | AdvanceLine(1); 692 | continue; 693 | } 694 | 695 | // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)! 696 | firstComment = false; 697 | 698 | if (c == TomlSyntax.TABLE_START_SYMBOL) 699 | { 700 | currentState = ParseState.Table; 701 | goto consume_character; 702 | } 703 | 704 | if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c)) 705 | { 706 | currentState = ParseState.KeyValuePair; 707 | } 708 | else 709 | { 710 | AddError($"Unexpected character \"{c}\""); 711 | continue; 712 | } 713 | } 714 | 715 | if (currentState == ParseState.KeyValuePair) 716 | { 717 | var keyValuePair = ReadKeyValuePair(keyParts); 718 | 719 | if (keyValuePair == null) 720 | { 721 | latestComment = null; 722 | keyParts.Clear(); 723 | 724 | if (currentState != ParseState.None) 725 | AddError("Failed to parse key-value pair!"); 726 | continue; 727 | } 728 | 729 | keyValuePair.Comment = latestComment?.ToString()?.TrimEnd(); 730 | var inserted = InsertNode(keyValuePair, currentNode, keyParts); 731 | latestComment = null; 732 | keyParts.Clear(); 733 | if (inserted) 734 | currentState = ParseState.SkipToNextLine; 735 | continue; 736 | } 737 | 738 | if (currentState == ParseState.Table) 739 | { 740 | if (keyParts.Count == 0) 741 | { 742 | // We have array table 743 | if (c == TomlSyntax.TABLE_START_SYMBOL) 744 | { 745 | // Consume the character 746 | ConsumeChar(); 747 | arrayTable = true; 748 | } 749 | 750 | if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL)) 751 | { 752 | keyParts.Clear(); 753 | continue; 754 | } 755 | 756 | if (keyParts.Count == 0) 757 | { 758 | AddError("Table name is emtpy."); 759 | arrayTable = false; 760 | latestComment = null; 761 | keyParts.Clear(); 762 | } 763 | 764 | continue; 765 | } 766 | 767 | if (c == TomlSyntax.TABLE_END_SYMBOL) 768 | { 769 | if (arrayTable) 770 | { 771 | // Consume the ending bracket so we can peek the next character 772 | ConsumeChar(); 773 | var nextChar = reader.Peek(); 774 | if (nextChar < 0 || (char)nextChar != TomlSyntax.TABLE_END_SYMBOL) 775 | { 776 | AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); 777 | keyParts.Clear(); 778 | arrayTable = false; 779 | latestComment = null; 780 | continue; 781 | } 782 | } 783 | 784 | currentNode = CreateTable(rootNode, keyParts, arrayTable); 785 | if (currentNode != null) 786 | { 787 | currentNode.IsInline = false; 788 | currentNode.Comment = latestComment?.ToString()?.TrimEnd(); 789 | } 790 | 791 | keyParts.Clear(); 792 | arrayTable = false; 793 | latestComment = null; 794 | 795 | if (currentNode == null) 796 | { 797 | if (currentState != ParseState.None) 798 | AddError("Error creating table array!"); 799 | // Reset a node to root in order to try and continue parsing 800 | currentNode = rootNode; 801 | continue; 802 | } 803 | 804 | currentState = ParseState.SkipToNextLine; 805 | goto consume_character; 806 | } 807 | 808 | if (keyParts.Count != 0) 809 | { 810 | AddError($"Unexpected character \"{c}\""); 811 | keyParts.Clear(); 812 | arrayTable = false; 813 | latestComment = null; 814 | } 815 | } 816 | 817 | if (currentState == ParseState.SkipToNextLine) 818 | { 819 | if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER) 820 | goto consume_character; 821 | 822 | if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER) 823 | { 824 | currentState = ParseState.None; 825 | AdvanceLine(); 826 | 827 | if (c == TomlSyntax.COMMENT_SYMBOL) 828 | { 829 | col++; 830 | ParseComment(); 831 | continue; 832 | } 833 | 834 | goto consume_character; 835 | } 836 | 837 | AddError($"Unexpected character \"{c}\" at the end of the line."); 838 | } 839 | 840 | consume_character: 841 | reader.Read(); 842 | col++; 843 | } 844 | 845 | if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine) 846 | AddError("Unexpected end of file!"); 847 | 848 | if (syntaxErrors.Count > 0) 849 | throw new TomlParseException(rootNode, syntaxErrors); 850 | 851 | return rootNode; 852 | } 853 | 854 | private bool AddError(string message, bool skipLine = true) 855 | { 856 | syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col)); 857 | // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that) 858 | if (skipLine) 859 | { 860 | reader.ReadLine(); 861 | AdvanceLine(1); 862 | } 863 | currentState = ParseState.None; 864 | return false; 865 | } 866 | 867 | private void AdvanceLine(int startCol = 0) 868 | { 869 | line++; 870 | col = startCol; 871 | } 872 | 873 | private int ConsumeChar() 874 | { 875 | col++; 876 | return reader.Read(); 877 | } 878 | 879 | #region Key-Value pair parsing 880 | 881 | /** 882 | * Reads a single key-value pair. 883 | * Assumes the cursor is at the first character that belong to the pair (including possible whitespace). 884 | * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end). 885 | * 886 | * Example: 887 | * foo = "bar" ==> foo = "bar" 888 | * ^ ^ 889 | */ 890 | private TomlNode ReadKeyValuePair(List<string> keyParts) 891 | { 892 | int cur; 893 | while ((cur = reader.Peek()) >= 0) 894 | { 895 | var c = (char)cur; 896 | 897 | if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) 898 | { 899 | if (keyParts.Count != 0) 900 | { 901 | AddError("Encountered extra characters in key definition!"); 902 | return null; 903 | } 904 | 905 | if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR)) 906 | return null; 907 | 908 | continue; 909 | } 910 | 911 | if (TomlSyntax.IsWhiteSpace(c)) 912 | { 913 | ConsumeChar(); 914 | continue; 915 | } 916 | 917 | if (c == TomlSyntax.KEY_VALUE_SEPARATOR) 918 | { 919 | ConsumeChar(); 920 | return ReadValue(); 921 | } 922 | 923 | AddError($"Unexpected character \"{c}\" in key name."); 924 | return null; 925 | } 926 | 927 | return null; 928 | } 929 | 930 | /** 931 | * Reads a single value. 932 | * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace). 933 | * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end). 934 | * 935 | * Example: 936 | * "test" ==> "test" 937 | * ^ ^ 938 | */ 939 | private TomlNode ReadValue(bool skipNewlines = false) 940 | { 941 | int cur; 942 | while ((cur = reader.Peek()) >= 0) 943 | { 944 | var c = (char)cur; 945 | 946 | if (TomlSyntax.IsWhiteSpace(c)) 947 | { 948 | ConsumeChar(); 949 | continue; 950 | } 951 | 952 | if (c == TomlSyntax.COMMENT_SYMBOL) 953 | { 954 | AddError("No value found!"); 955 | return null; 956 | } 957 | 958 | if (TomlSyntax.IsNewLine(c)) 959 | { 960 | if (skipNewlines) 961 | { 962 | reader.Read(); 963 | AdvanceLine(1); 964 | continue; 965 | } 966 | 967 | AddError("Encountered a newline when expecting a value!"); 968 | return null; 969 | } 970 | 971 | if (TomlSyntax.IsQuoted(c)) 972 | { 973 | var isMultiline = IsTripleQuote(c, out var excess); 974 | 975 | // Error occurred in triple quote parsing 976 | if (currentState == ParseState.None) 977 | return null; 978 | 979 | var value = isMultiline 980 | ? ReadQuotedValueMultiLine(c) 981 | : ReadQuotedValueSingleLine(c, excess); 982 | 983 | if (value is null) 984 | return null; 985 | 986 | return new TomlString 987 | { 988 | Value = value, 989 | IsMultiline = isMultiline, 990 | PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL 991 | }; 992 | } 993 | 994 | return c switch 995 | { 996 | TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), 997 | TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), 998 | var _ => ReadTomlValue() 999 | }; 1000 | } 1001 | 1002 | return null; 1003 | } 1004 | 1005 | /** 1006 | * Reads a single key name. 1007 | * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`). 1008 | * Consumes all the characters until the `until` character is met (but does not consume the character itself). 1009 | * 1010 | * Example 1: 1011 | * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`) 1012 | * ^ ^ 1013 | * 1014 | * Example 2: 1015 | * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`) 1016 | * ^ ^ 1017 | */ 1018 | private bool ReadKeyName(ref List<string> parts, char until) 1019 | { 1020 | var buffer = new StringBuilder(); 1021 | var quoted = false; 1022 | var prevWasSpace = false; 1023 | int cur; 1024 | while ((cur = reader.Peek()) >= 0) 1025 | { 1026 | var c = (char)cur; 1027 | 1028 | // Reached the final character 1029 | if (c == until) break; 1030 | 1031 | if (TomlSyntax.IsWhiteSpace(c)) 1032 | { 1033 | prevWasSpace = true; 1034 | goto consume_character; 1035 | } 1036 | 1037 | if (buffer.Length == 0) prevWasSpace = false; 1038 | 1039 | if (c == TomlSyntax.SUBKEY_SEPARATOR) 1040 | { 1041 | if (buffer.Length == 0 && !quoted) 1042 | return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); 1043 | 1044 | parts.Add(buffer.ToString()); 1045 | buffer.Length = 0; 1046 | quoted = false; 1047 | prevWasSpace = false; 1048 | goto consume_character; 1049 | } 1050 | 1051 | if (prevWasSpace) 1052 | return AddError("Invalid spacing in key name"); 1053 | 1054 | if (TomlSyntax.IsQuoted(c)) 1055 | { 1056 | if (quoted) 1057 | 1058 | return AddError("Expected a subkey separator but got extra data instead!"); 1059 | 1060 | if (buffer.Length != 0) 1061 | return AddError("Encountered a quote in the middle of subkey name!"); 1062 | 1063 | // Consume the quote character and read the key name 1064 | col++; 1065 | buffer.Append(ReadQuotedValueSingleLine((char)reader.Read())); 1066 | quoted = true; 1067 | continue; 1068 | } 1069 | 1070 | if (TomlSyntax.IsBareKey(c)) 1071 | { 1072 | buffer.Append(c); 1073 | goto consume_character; 1074 | } 1075 | 1076 | // If we see an invalid symbol, let the next parser handle it 1077 | break; 1078 | 1079 | consume_character: 1080 | reader.Read(); 1081 | col++; 1082 | } 1083 | 1084 | if (buffer.Length == 0 && !quoted) 1085 | return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); 1086 | 1087 | parts.Add(buffer.ToString()); 1088 | 1089 | return true; 1090 | } 1091 | 1092 | #endregion 1093 | 1094 | #region Non-string value parsing 1095 | 1096 | /** 1097 | * Reads the whole raw value until the first non-value character is encountered. 1098 | * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value. 1099 | * Example: 1100 | * 1101 | * 1_0_0_0 ==> 1_0_0_0 1102 | * ^ ^ 1103 | */ 1104 | private string ReadRawValue() 1105 | { 1106 | var result = new StringBuilder(); 1107 | int cur; 1108 | while ((cur = reader.Peek()) >= 0) 1109 | { 1110 | var c = (char)cur; 1111 | if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; 1112 | result.Append(c); 1113 | ConsumeChar(); 1114 | } 1115 | 1116 | // Replace trim with manual space counting? 1117 | return result.ToString().Trim(); 1118 | } 1119 | 1120 | /** 1121 | * Reads and parses a non-string, non-composite TOML value. 1122 | * Assumes the cursor at the first character that is related to the value (with possible spaces). 1123 | * Consumes all the characters that are related to the value. 1124 | * 1125 | * Example 1126 | * 1_0_0_0 # This is a comment 1127 | * <newline> 1128 | * ==> 1_0_0_0 # This is a comment 1129 | * ^ ^ 1130 | */ 1131 | private TomlNode ReadTomlValue() 1132 | { 1133 | var value = ReadRawValue(); 1134 | TomlNode node = value switch 1135 | { 1136 | var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), 1137 | var v when TomlSyntax.IsNaN(v) => double.NaN, 1138 | var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, 1139 | var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, 1140 | var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), 1141 | CultureInfo.InvariantCulture), 1142 | var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), 1143 | CultureInfo.InvariantCulture), 1144 | var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger 1145 | { 1146 | Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), 1147 | IntegerBase = (TomlInteger.Base)numberBase 1148 | }, 1149 | var _ => null 1150 | }; 1151 | if (node != null) return node; 1152 | 1153 | // Normalize by removing space separator 1154 | value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator); 1155 | if (StringUtils.TryParseDateTime<DateTime>(value, 1156 | TomlSyntax.RFC3339LocalDateTimeFormats, 1157 | DateTimeStyles.AssumeLocal, 1158 | DateTime.TryParseExact, 1159 | out var dateTimeResult, 1160 | out var precision)) 1161 | return new TomlDateTimeLocal 1162 | { 1163 | Value = dateTimeResult, 1164 | SecondsPrecision = precision 1165 | }; 1166 | 1167 | if (DateTime.TryParseExact(value, 1168 | TomlSyntax.LocalDateFormat, 1169 | CultureInfo.InvariantCulture, 1170 | DateTimeStyles.AssumeLocal, 1171 | out dateTimeResult)) 1172 | return new TomlDateTimeLocal 1173 | { 1174 | Value = dateTimeResult, 1175 | Style = TomlDateTimeLocal.DateTimeStyle.Date 1176 | }; 1177 | 1178 | if (StringUtils.TryParseDateTime(value, 1179 | TomlSyntax.RFC3339LocalTimeFormats, 1180 | DateTimeStyles.AssumeLocal, 1181 | DateTime.TryParseExact, 1182 | out dateTimeResult, 1183 | out precision)) 1184 | return new TomlDateTimeLocal 1185 | { 1186 | Value = dateTimeResult, 1187 | Style = TomlDateTimeLocal.DateTimeStyle.Time, 1188 | SecondsPrecision = precision 1189 | }; 1190 | 1191 | if (StringUtils.TryParseDateTime<DateTimeOffset>(value, 1192 | TomlSyntax.RFC3339Formats, 1193 | DateTimeStyles.None, 1194 | DateTimeOffset.TryParseExact, 1195 | out var dateTimeOffsetResult, 1196 | out precision)) 1197 | return new TomlDateTimeOffset 1198 | { 1199 | Value = dateTimeOffsetResult, 1200 | SecondsPrecision = precision 1201 | }; 1202 | 1203 | AddError($"Value \"{value}\" is not a valid TOML value!"); 1204 | return null; 1205 | } 1206 | 1207 | /** 1208 | * Reads an array value. 1209 | * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket. 1210 | * 1211 | * Example: 1212 | * [1, 2, 3] ==> [1, 2, 3] 1213 | * ^ ^ 1214 | */ 1215 | private TomlArray ReadArray() 1216 | { 1217 | // Consume the start of array character 1218 | ConsumeChar(); 1219 | var result = new TomlArray(); 1220 | TomlNode currentValue = null; 1221 | var expectValue = true; 1222 | 1223 | int cur; 1224 | while ((cur = reader.Peek()) >= 0) 1225 | { 1226 | var c = (char)cur; 1227 | 1228 | if (c == TomlSyntax.ARRAY_END_SYMBOL) 1229 | { 1230 | ConsumeChar(); 1231 | break; 1232 | } 1233 | 1234 | if (c == TomlSyntax.COMMENT_SYMBOL) 1235 | { 1236 | reader.ReadLine(); 1237 | AdvanceLine(1); 1238 | continue; 1239 | } 1240 | 1241 | if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c)) 1242 | { 1243 | if (TomlSyntax.IsLineBreak(c)) 1244 | AdvanceLine(); 1245 | goto consume_character; 1246 | } 1247 | 1248 | if (c == TomlSyntax.ITEM_SEPARATOR) 1249 | { 1250 | if (currentValue == null) 1251 | { 1252 | AddError("Encountered multiple value separators"); 1253 | return null; 1254 | } 1255 | 1256 | result.Add(currentValue); 1257 | currentValue = null; 1258 | expectValue = true; 1259 | goto consume_character; 1260 | } 1261 | 1262 | if (!expectValue) 1263 | { 1264 | AddError("Missing separator between values"); 1265 | return null; 1266 | } 1267 | currentValue = ReadValue(true); 1268 | if (currentValue == null) 1269 | { 1270 | if (currentState != ParseState.None) 1271 | AddError("Failed to determine and parse a value!"); 1272 | return null; 1273 | } 1274 | expectValue = false; 1275 | 1276 | continue; 1277 | consume_character: 1278 | ConsumeChar(); 1279 | } 1280 | 1281 | if (currentValue != null) result.Add(currentValue); 1282 | return result; 1283 | } 1284 | 1285 | /** 1286 | * Reads an inline table. 1287 | * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket. 1288 | * 1289 | * Example: 1290 | * { test = "foo", value = 1 } ==> { test = "foo", value = 1 } 1291 | * ^ ^ 1292 | */ 1293 | private TomlNode ReadInlineTable() 1294 | { 1295 | ConsumeChar(); 1296 | var result = new TomlTable { IsInline = true }; 1297 | TomlNode currentValue = null; 1298 | var separator = false; 1299 | var keyParts = new List<string>(); 1300 | int cur; 1301 | while ((cur = reader.Peek()) >= 0) 1302 | { 1303 | var c = (char)cur; 1304 | 1305 | if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) 1306 | { 1307 | ConsumeChar(); 1308 | break; 1309 | } 1310 | 1311 | if (c == TomlSyntax.COMMENT_SYMBOL) 1312 | { 1313 | AddError("Incomplete inline table definition!"); 1314 | return null; 1315 | } 1316 | 1317 | if (TomlSyntax.IsNewLine(c)) 1318 | { 1319 | AddError("Inline tables are only allowed to be on single line"); 1320 | return null; 1321 | } 1322 | 1323 | if (TomlSyntax.IsWhiteSpace(c)) 1324 | goto consume_character; 1325 | 1326 | if (c == TomlSyntax.ITEM_SEPARATOR) 1327 | { 1328 | if (currentValue == null) 1329 | { 1330 | AddError("Encountered multiple value separators in inline table!"); 1331 | return null; 1332 | } 1333 | 1334 | if (!InsertNode(currentValue, result, keyParts)) 1335 | return null; 1336 | keyParts.Clear(); 1337 | currentValue = null; 1338 | separator = true; 1339 | goto consume_character; 1340 | } 1341 | 1342 | separator = false; 1343 | currentValue = ReadKeyValuePair(keyParts); 1344 | continue; 1345 | 1346 | consume_character: 1347 | ConsumeChar(); 1348 | } 1349 | 1350 | if (separator) 1351 | { 1352 | AddError("Trailing commas are not allowed in inline tables."); 1353 | return null; 1354 | } 1355 | 1356 | if (currentValue != null && !InsertNode(currentValue, result, keyParts)) 1357 | return null; 1358 | 1359 | return result; 1360 | } 1361 | 1362 | #endregion 1363 | 1364 | #region String parsing 1365 | 1366 | /** 1367 | * Checks if the string value a multiline string (i.e. a triple quoted string). 1368 | * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline. 1369 | * 1370 | * If the result is false, returns the consumed character through the `excess` variable. 1371 | * 1372 | * Example 1: 1373 | * """test""" ==> """test""" 1374 | * ^ ^ 1375 | * 1376 | * Example 2: 1377 | * "test" ==> "test" (doesn't return the first quote) 1378 | * ^ ^ 1379 | * 1380 | * Example 3: 1381 | * "" ==> "" (returns the extra `"` through the `excess` variable) 1382 | * ^ ^ 1383 | */ 1384 | private bool IsTripleQuote(char quote, out char excess) 1385 | { 1386 | // Copypasta, but it's faster... 1387 | 1388 | int cur; 1389 | // Consume the first quote 1390 | ConsumeChar(); 1391 | if ((cur = reader.Peek()) < 0) 1392 | { 1393 | excess = '\0'; 1394 | return AddError("Unexpected end of file!"); 1395 | } 1396 | 1397 | if ((char)cur != quote) 1398 | { 1399 | excess = '\0'; 1400 | return false; 1401 | } 1402 | 1403 | // Consume the second quote 1404 | excess = (char)ConsumeChar(); 1405 | if ((cur = reader.Peek()) < 0 || (char)cur != quote) return false; 1406 | 1407 | // Consume the final quote 1408 | ConsumeChar(); 1409 | excess = '\0'; 1410 | return true; 1411 | } 1412 | 1413 | /** 1414 | * A convenience method to process a single character within a quote. 1415 | */ 1416 | private bool ProcessQuotedValueCharacter(char quote, 1417 | bool isNonLiteral, 1418 | char c, 1419 | StringBuilder sb, 1420 | ref bool escaped) 1421 | { 1422 | if (TomlSyntax.MustBeEscaped(c)) 1423 | return AddError($"The character U+{(int)c:X8} must be escaped in a string!"); 1424 | 1425 | if (escaped) 1426 | { 1427 | sb.Append(c); 1428 | escaped = false; 1429 | return false; 1430 | } 1431 | 1432 | if (c == quote) 1433 | { 1434 | if (!isNonLiteral && reader.Peek() == quote) 1435 | { 1436 | reader.Read(); 1437 | col++; 1438 | sb.Append(quote); 1439 | return false; 1440 | } 1441 | 1442 | return true; 1443 | } 1444 | if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) 1445 | escaped = true; 1446 | if (c == TomlSyntax.NEWLINE_CHARACTER) 1447 | return AddError("Encountered newline in single line string!"); 1448 | 1449 | sb.Append(c); 1450 | return false; 1451 | } 1452 | 1453 | /** 1454 | * Reads a single-line string. 1455 | * Assumes the cursor is at the first character that belongs to the string. 1456 | * Consumes all characters that belong to the string (including the closing quote). 1457 | * 1458 | * Example: 1459 | * "test" ==> "test" 1460 | * ^ ^ 1461 | */ 1462 | private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') 1463 | { 1464 | var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL; 1465 | var sb = new StringBuilder(); 1466 | var escaped = false; 1467 | 1468 | if (initialData != '\0') 1469 | { 1470 | var shouldReturn = 1471 | ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped); 1472 | if (currentState == ParseState.None) return null; 1473 | if (shouldReturn) 1474 | if (isNonLiteral) 1475 | { 1476 | if (sb.ToString().TryUnescape(out var res, out var ex)) return res; 1477 | AddError(ex.Message); 1478 | return null; 1479 | } 1480 | else 1481 | return sb.ToString(); 1482 | } 1483 | 1484 | int cur; 1485 | var readDone = false; 1486 | while ((cur = reader.Read()) >= 0) 1487 | { 1488 | // Consume the character 1489 | col++; 1490 | var c = (char)cur; 1491 | readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); 1492 | if (readDone) 1493 | { 1494 | if (currentState == ParseState.None) return null; 1495 | break; 1496 | } 1497 | } 1498 | 1499 | if (!readDone) 1500 | { 1501 | AddError("Unclosed string."); 1502 | return null; 1503 | } 1504 | 1505 | if (!isNonLiteral) return sb.ToString(); 1506 | if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped; 1507 | AddError(unescapedEx.Message); 1508 | return null; 1509 | } 1510 | 1511 | /** 1512 | * Reads a multiline string. 1513 | * Assumes the cursor is at the first character that belongs to the string. 1514 | * Consumes all characters that belong to the string and the three closing quotes. 1515 | * 1516 | * Example: 1517 | * """test""" ==> """test""" 1518 | * ^ ^ 1519 | */ 1520 | private string ReadQuotedValueMultiLine(char quote) 1521 | { 1522 | var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL; 1523 | var sb = new StringBuilder(); 1524 | var escaped = false; 1525 | var skipWhitespace = false; 1526 | var skipWhitespaceLineSkipped = false; 1527 | var quotesEncountered = 0; 1528 | var first = true; 1529 | int cur; 1530 | while ((cur = ConsumeChar()) >= 0) 1531 | { 1532 | var c = (char)cur; 1533 | if (TomlSyntax.MustBeEscaped(c, true)) 1534 | { 1535 | AddError($"The character U+{(int)c:X8} must be escaped!"); 1536 | return null; 1537 | } 1538 | // Trim the first newline 1539 | if (first && TomlSyntax.IsNewLine(c)) 1540 | { 1541 | if (TomlSyntax.IsLineBreak(c)) 1542 | first = false; 1543 | else 1544 | AdvanceLine(); 1545 | continue; 1546 | } 1547 | 1548 | first = false; 1549 | //TODO: Reuse ProcessQuotedValueCharacter 1550 | // Skip the current character if it is going to be escaped later 1551 | if (escaped) 1552 | { 1553 | sb.Append(c); 1554 | escaped = false; 1555 | continue; 1556 | } 1557 | 1558 | // If we are currently skipping empty spaces, skip 1559 | if (skipWhitespace) 1560 | { 1561 | if (TomlSyntax.IsEmptySpace(c)) 1562 | { 1563 | if (TomlSyntax.IsLineBreak(c)) 1564 | { 1565 | skipWhitespaceLineSkipped = true; 1566 | AdvanceLine(); 1567 | } 1568 | continue; 1569 | } 1570 | 1571 | if (!skipWhitespaceLineSkipped) 1572 | { 1573 | AddError("Non-whitespace character after trim marker."); 1574 | return null; 1575 | } 1576 | 1577 | skipWhitespaceLineSkipped = false; 1578 | skipWhitespace = false; 1579 | } 1580 | 1581 | // If we encounter an escape sequence... 1582 | if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) 1583 | { 1584 | var next = reader.Peek(); 1585 | var nc = (char)next; 1586 | if (next >= 0) 1587 | { 1588 | // ...and the next char is empty space, we must skip all whitespaces 1589 | if (TomlSyntax.IsEmptySpace(nc)) 1590 | { 1591 | skipWhitespace = true; 1592 | continue; 1593 | } 1594 | 1595 | // ...and we have \" or \, skip the character 1596 | if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true; 1597 | } 1598 | } 1599 | 1600 | // Count the consecutive quotes 1601 | if (c == quote) 1602 | quotesEncountered++; 1603 | else 1604 | quotesEncountered = 0; 1605 | 1606 | // If the are three quotes, count them as closing quotes 1607 | if (quotesEncountered == 3) break; 1608 | 1609 | sb.Append(c); 1610 | } 1611 | 1612 | // TOML actually allows to have five ending quotes like 1613 | // """"" => "" belong to the string + """ is the actual ending 1614 | quotesEncountered = 0; 1615 | while ((cur = reader.Peek()) >= 0) 1616 | { 1617 | var c = (char)cur; 1618 | if (c == quote && ++quotesEncountered < 3) 1619 | { 1620 | sb.Append(c); 1621 | ConsumeChar(); 1622 | } 1623 | else break; 1624 | } 1625 | 1626 | // Remove last two quotes (third one wasn't included by default) 1627 | sb.Length -= 2; 1628 | if (!isBasic) return sb.ToString(); 1629 | if (sb.ToString().TryUnescape(out var res, out var ex)) return res; 1630 | AddError(ex.Message); 1631 | return null; 1632 | } 1633 | 1634 | #endregion 1635 | 1636 | #region Node creation 1637 | 1638 | private bool InsertNode(TomlNode node, TomlNode root, IList<string> path) 1639 | { 1640 | var latestNode = root; 1641 | if (path.Count > 1) 1642 | for (var index = 0; index < path.Count - 1; index++) 1643 | { 1644 | var subkey = path[index]; 1645 | if (latestNode.TryGetNode(subkey, out var currentNode)) 1646 | { 1647 | if (currentNode.HasValue) 1648 | return AddError($"The key {".".Join(path)} already has a value assigned to it!"); 1649 | } 1650 | else 1651 | { 1652 | currentNode = new TomlTable(); 1653 | latestNode[subkey] = currentNode; 1654 | } 1655 | 1656 | latestNode = currentNode; 1657 | if (latestNode is TomlTable { IsInline: true }) 1658 | return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table."); 1659 | } 1660 | 1661 | if (latestNode.HasKey(path[path.Count - 1])) 1662 | return AddError($"The key {".".Join(path)} is already defined!"); 1663 | latestNode[path[path.Count - 1]] = node; 1664 | node.CollapseLevel = path.Count - 1; 1665 | return true; 1666 | } 1667 | 1668 | private TomlTable CreateTable(TomlNode root, IList<string> path, bool arrayTable) 1669 | { 1670 | if (path.Count == 0) return null; 1671 | var latestNode = root; 1672 | for (var index = 0; index < path.Count; index++) 1673 | { 1674 | var subkey = path[index]; 1675 | 1676 | if (latestNode.TryGetNode(subkey, out var node)) 1677 | { 1678 | if (node.IsArray && arrayTable) 1679 | { 1680 | var arr = (TomlArray)node; 1681 | 1682 | if (!arr.IsTableArray) 1683 | { 1684 | AddError($"The array {".".Join(path)} cannot be redefined as an array table!"); 1685 | return null; 1686 | } 1687 | 1688 | if (index == path.Count - 1) 1689 | { 1690 | latestNode = new TomlTable(); 1691 | arr.Add(latestNode); 1692 | break; 1693 | } 1694 | 1695 | latestNode = arr[arr.ChildrenCount - 1]; 1696 | continue; 1697 | } 1698 | 1699 | if (node is TomlTable { IsInline: true }) 1700 | { 1701 | AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); 1702 | return null; 1703 | } 1704 | 1705 | if (node.HasValue) 1706 | { 1707 | if (!(node is TomlArray { IsTableArray: true } array)) 1708 | { 1709 | AddError($"The key {".".Join(path)} has a value assigned to it!"); 1710 | return null; 1711 | } 1712 | 1713 | latestNode = array[array.ChildrenCount - 1]; 1714 | continue; 1715 | } 1716 | 1717 | if (index == path.Count - 1) 1718 | { 1719 | if (arrayTable && !node.IsArray) 1720 | { 1721 | AddError($"The table {".".Join(path)} cannot be redefined as an array table!"); 1722 | return null; 1723 | } 1724 | 1725 | if (node is TomlTable { isImplicit: false }) 1726 | { 1727 | AddError($"The table {".".Join(path)} is defined multiple times!"); 1728 | return null; 1729 | } 1730 | } 1731 | } 1732 | else 1733 | { 1734 | if (index == path.Count - 1 && arrayTable) 1735 | { 1736 | var table = new TomlTable(); 1737 | var arr = new TomlArray 1738 | { 1739 | IsTableArray = true 1740 | }; 1741 | arr.Add(table); 1742 | latestNode[subkey] = arr; 1743 | latestNode = table; 1744 | break; 1745 | } 1746 | 1747 | node = new TomlTable { isImplicit = true }; 1748 | latestNode[subkey] = node; 1749 | } 1750 | 1751 | latestNode = node; 1752 | } 1753 | 1754 | var result = (TomlTable)latestNode; 1755 | result.isImplicit = false; 1756 | return result; 1757 | } 1758 | 1759 | #endregion 1760 | 1761 | #region Misc parsing 1762 | 1763 | private string ParseComment() 1764 | { 1765 | ConsumeChar(); 1766 | var commentLine = reader.ReadLine()?.Trim() ?? ""; 1767 | if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch))) 1768 | AddError("Comment must not contain control characters other than tab.", false); 1769 | return commentLine; 1770 | } 1771 | #endregion 1772 | } 1773 | 1774 | #endregion 1775 | 1776 | public static class TOML 1777 | { 1778 | public static bool ForceASCII { get; set; } = false; 1779 | 1780 | public static TomlTable Parse(TextReader reader) 1781 | { 1782 | using var parser = new TOMLParser(reader) { ForceASCII = ForceASCII }; 1783 | return parser.Parse(); 1784 | } 1785 | } 1786 | 1787 | #region Exception Types 1788 | 1789 | public class TomlFormatException : Exception 1790 | { 1791 | public TomlFormatException(string message) : base(message) { } 1792 | } 1793 | 1794 | public class TomlParseException : Exception 1795 | { 1796 | public TomlParseException(TomlTable parsed, IEnumerable<TomlSyntaxException> exceptions) : 1797 | base("TOML file contains format errors") 1798 | { 1799 | ParsedTable = parsed; 1800 | SyntaxErrors = exceptions; 1801 | } 1802 | 1803 | public TomlTable ParsedTable { get; } 1804 | 1805 | public IEnumerable<TomlSyntaxException> SyntaxErrors { get; } 1806 | } 1807 | 1808 | public class TomlSyntaxException : Exception 1809 | { 1810 | public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message) 1811 | { 1812 | ParseState = state; 1813 | Line = line; 1814 | Column = col; 1815 | } 1816 | 1817 | public TOMLParser.ParseState ParseState { get; } 1818 | 1819 | public int Line { get; } 1820 | 1821 | public int Column { get; } 1822 | } 1823 | 1824 | #endregion 1825 | 1826 | #region Parse utilities 1827 | 1828 | internal static class TomlSyntax 1829 | { 1830 | #region Type Patterns 1831 | 1832 | public const string TRUE_VALUE = "true"; 1833 | public const string FALSE_VALUE = "false"; 1834 | public const string NAN_VALUE = "nan"; 1835 | public const string POS_NAN_VALUE = "+nan"; 1836 | public const string NEG_NAN_VALUE = "-nan"; 1837 | public const string INF_VALUE = "inf"; 1838 | public const string POS_INF_VALUE = "+inf"; 1839 | public const string NEG_INF_VALUE = "-inf"; 1840 | 1841 | public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE; 1842 | 1843 | public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE; 1844 | 1845 | public static bool IsNegInf(string s) => s == NEG_INF_VALUE; 1846 | 1847 | public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE; 1848 | 1849 | public static bool IsInteger(string s) => IntegerPattern.IsMatch(s); 1850 | 1851 | public static bool IsFloat(string s) => FloatPattern.IsMatch(s); 1852 | 1853 | public static bool IsIntegerWithBase(string s, out int numberBase) 1854 | { 1855 | numberBase = 10; 1856 | var match = BasedIntegerPattern.Match(s); 1857 | if (!match.Success) return false; 1858 | IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase); 1859 | return true; 1860 | } 1861 | 1862 | /** 1863 | * A pattern to verify the integer value according to the TOML specification. 1864 | */ 1865 | public static readonly Regex IntegerPattern = 1866 | new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled); 1867 | 1868 | /** 1869 | * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification. 1870 | */ 1871 | public static readonly Regex BasedIntegerPattern = 1872 | new(@"^0(?<base>x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); 1873 | 1874 | /** 1875 | * A pattern to verify the float value according to the TOML specification. 1876 | */ 1877 | public static readonly Regex FloatPattern = 1878 | new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$", 1879 | RegexOptions.Compiled | RegexOptions.IgnoreCase); 1880 | 1881 | /** 1882 | * A helper dictionary to map TOML base codes into the radii. 1883 | */ 1884 | public static readonly Dictionary<string, int> IntegerBases = new() 1885 | { 1886 | ["x"] = 16, 1887 | ["o"] = 8, 1888 | ["b"] = 2 1889 | }; 1890 | 1891 | /** 1892 | * A helper dictionary to map non-decimal bases to their TOML identifiers 1893 | */ 1894 | public static readonly Dictionary<int, string> BaseIdentifiers = new() 1895 | { 1896 | [2] = "b", 1897 | [8] = "o", 1898 | [16] = "x" 1899 | }; 1900 | 1901 | public const string RFC3339EmptySeparator = " "; 1902 | public const string ISO861Separator = "T"; 1903 | public const string ISO861ZeroZone = "+00:00"; 1904 | public const string RFC3339ZeroZone = "Z"; 1905 | 1906 | /** 1907 | * Valid date formats with timezone as per RFC3339. 1908 | */ 1909 | public static readonly string[] RFC3339Formats = 1910 | { 1911 | "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK", 1912 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK", 1913 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK", 1914 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK" 1915 | }; 1916 | 1917 | /** 1918 | * Valid date formats without timezone (assumes local) as per RFC3339. 1919 | */ 1920 | public static readonly string[] RFC3339LocalDateTimeFormats = 1921 | { 1922 | "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff", 1923 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff", 1924 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff", 1925 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff" 1926 | }; 1927 | 1928 | /** 1929 | * Valid full date format as per TOML spec. 1930 | */ 1931 | public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd"; 1932 | 1933 | /** 1934 | * Valid time formats as per TOML spec. 1935 | */ 1936 | public static readonly string[] RFC3339LocalTimeFormats = 1937 | { 1938 | "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff", 1939 | "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff" 1940 | }; 1941 | 1942 | #endregion 1943 | 1944 | #region Character definitions 1945 | 1946 | public const char ARRAY_END_SYMBOL = ']'; 1947 | public const char ITEM_SEPARATOR = ','; 1948 | public const char ARRAY_START_SYMBOL = '['; 1949 | public const char BASIC_STRING_SYMBOL = '\"'; 1950 | public const char COMMENT_SYMBOL = '#'; 1951 | public const char ESCAPE_SYMBOL = '\\'; 1952 | public const char KEY_VALUE_SEPARATOR = '='; 1953 | public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r'; 1954 | public const char NEWLINE_CHARACTER = '\n'; 1955 | public const char SUBKEY_SEPARATOR = '.'; 1956 | public const char TABLE_END_SYMBOL = ']'; 1957 | public const char TABLE_START_SYMBOL = '['; 1958 | public const char INLINE_TABLE_START_SYMBOL = '{'; 1959 | public const char INLINE_TABLE_END_SYMBOL = '}'; 1960 | public const char LITERAL_STRING_SYMBOL = '\''; 1961 | public const char INT_NUMBER_SEPARATOR = '_'; 1962 | 1963 | public static readonly char[] NewLineCharacters = { NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER }; 1964 | 1965 | public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; 1966 | 1967 | public static bool IsWhiteSpace(char c) => c is ' ' or '\t'; 1968 | 1969 | public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER; 1970 | 1971 | public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER; 1972 | 1973 | public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c); 1974 | 1975 | public static bool IsBareKey(char c) => 1976 | c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'; 1977 | 1978 | public static bool MustBeEscaped(char c, bool allowNewLines = false) 1979 | { 1980 | var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f'; 1981 | if (!allowNewLines) 1982 | result |= c is >= '\u000a' and <= '\u000e'; 1983 | return result; 1984 | } 1985 | 1986 | public static bool IsValueSeparator(char c) => 1987 | c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL; 1988 | 1989 | #endregion 1990 | } 1991 | 1992 | internal static class StringUtils 1993 | { 1994 | public static string AsKey(this string key) 1995 | { 1996 | var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c)); 1997 | return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}"; 1998 | } 1999 | 2000 | public static string Join(this string self, IEnumerable<string> subItems) 2001 | { 2002 | var sb = new StringBuilder(); 2003 | var first = true; 2004 | 2005 | foreach (var subItem in subItems) 2006 | { 2007 | if (!first) sb.Append(self); 2008 | first = false; 2009 | sb.Append(subItem); 2010 | } 2011 | 2012 | return sb.ToString(); 2013 | } 2014 | 2015 | public delegate bool TryDateParseDelegate<T>(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); 2016 | 2017 | public static bool TryParseDateTime<T>(string s, 2018 | string[] formats, 2019 | DateTimeStyles styles, 2020 | TryDateParseDelegate<T> parser, 2021 | out T dateTime, 2022 | out int parsedFormat) 2023 | { 2024 | parsedFormat = 0; 2025 | dateTime = default; 2026 | for (var i = 0; i < formats.Length; i++) 2027 | { 2028 | var format = formats[i]; 2029 | if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue; 2030 | parsedFormat = i; 2031 | return true; 2032 | } 2033 | 2034 | return false; 2035 | } 2036 | 2037 | public static void AsComment(this string self, TextWriter tw) 2038 | { 2039 | foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER)) 2040 | tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}"); 2041 | } 2042 | 2043 | public static string RemoveAll(this string txt, char toRemove) 2044 | { 2045 | var sb = new StringBuilder(txt.Length); 2046 | foreach (var c in txt.Where(c => c != toRemove)) 2047 | sb.Append(c); 2048 | return sb.ToString(); 2049 | } 2050 | 2051 | public static string Escape(this string txt, bool escapeNewlines = true) 2052 | { 2053 | var stringBuilder = new StringBuilder(txt.Length + 2); 2054 | for (var i = 0; i < txt.Length; i++) 2055 | { 2056 | var c = txt[i]; 2057 | 2058 | static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) 2059 | ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" 2060 | : $"\\u{(ushort)c:X4}"; 2061 | 2062 | stringBuilder.Append(c switch 2063 | { 2064 | '\b' => @"\b", 2065 | '\t' => @"\t", 2066 | '\n' when escapeNewlines => @"\n", 2067 | '\f' => @"\f", 2068 | '\r' when escapeNewlines => @"\r", 2069 | '\\' => @"\\", 2070 | '\"' => @"\""", 2071 | var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => 2072 | CodePoint(txt, ref i, c), 2073 | var _ => c 2074 | }); 2075 | } 2076 | 2077 | return stringBuilder.ToString(); 2078 | } 2079 | 2080 | public static bool TryUnescape(this string txt, out string unescaped, out Exception exception) 2081 | { 2082 | try 2083 | { 2084 | exception = null; 2085 | unescaped = txt.Unescape(); 2086 | return true; 2087 | } 2088 | catch (Exception e) 2089 | { 2090 | exception = e; 2091 | unescaped = null; 2092 | return false; 2093 | } 2094 | } 2095 | 2096 | public static string Unescape(this string txt) 2097 | { 2098 | if (string.IsNullOrEmpty(txt)) return txt; 2099 | var stringBuilder = new StringBuilder(txt.Length); 2100 | for (var i = 0; i < txt.Length;) 2101 | { 2102 | var num = txt.IndexOf('\\', i); 2103 | var next = num + 1; 2104 | if (num < 0 || num == txt.Length - 1) num = txt.Length; 2105 | stringBuilder.Append(txt, i, num - i); 2106 | if (num >= txt.Length) break; 2107 | var c = txt[next]; 2108 | 2109 | static string CodePoint(int next, string txt, ref int num, int size) 2110 | { 2111 | if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!"); 2112 | num += size; 2113 | return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16)); 2114 | } 2115 | 2116 | stringBuilder.Append(c switch 2117 | { 2118 | 'b' => "\b", 2119 | 't' => "\t", 2120 | 'n' => "\n", 2121 | 'f' => "\f", 2122 | 'r' => "\r", 2123 | '\'' => "\'", 2124 | '\"' => "\"", 2125 | '\\' => "\\", 2126 | 'u' => CodePoint(next, txt, ref num, 4), 2127 | 'U' => CodePoint(next, txt, ref num, 8), 2128 | var _ => throw new Exception("Undefined escape sequence!") 2129 | }); 2130 | i = num + 2; 2131 | } 2132 | 2133 | return stringBuilder.ToString(); 2134 | } 2135 | } 2136 | 2137 | #endregion 2138 | } 2139 | ```