This is page 10 of 13. Use http://codebase.md/justinpbarnett/unity-mcp?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 using System; using System.Collections.Generic; using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Net.Sockets; using System.Net; using System.IO; using System.Linq; using System.Runtime.InteropServices; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Windows { public class MCPForUnityEditorWindow : EditorWindow { private bool isUnityBridgeRunning = false; private Vector2 scrollPosition; private string pythonServerInstallationStatus = "Not Installed"; private Color pythonServerInstallationStatusColor = Color.red; private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server) private readonly McpClients mcpClients = new(); private bool autoRegisterEnabled; private bool lastClientRegisteredOk; private bool lastBridgeVerifiedOk; private string pythonDirOverride = null; private bool debugLogsEnabled; // Script validation settings private int validationLevelIndex = 1; // Default to Standard private readonly string[] validationLevelOptions = new string[] { "Basic - Only syntax checks", "Standard - Syntax + Unity practices", "Comprehensive - All checks + semantic analysis", "Strict - Full semantic validation (requires Roslyn)" }; // UI state private int selectedClientIndex = 0; public static void ShowWindow() { GetWindow<MCPForUnityEditorWindow>("MCP For Unity"); } private void OnEnable() { UpdatePythonServerInstallationStatus(); // Refresh bridge status isUnityBridgeRunning = MCPForUnityBridge.IsRunning; autoRegisterEnabled = EditorPrefs.GetBool("MCPForUnity.AutoRegisterEnabled", true); debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); if (debugLogsEnabled) { LogDebugPrefsState(); } foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); } // Load validation level setting LoadValidationLevelSetting(); // First-run auto-setup only if Claude CLI is available if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) { AutoFirstRunSetup(); } } private void OnFocus() { // Refresh bridge running state on focus in case initialization completed after domain reload isUnityBridgeRunning = MCPForUnityBridge.IsRunning; if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) { McpClient selectedClient = mcpClients.clients[selectedClientIndex]; CheckMcpConfiguration(selectedClient); } Repaint(); } private Color GetStatusColor(McpStatus status) { // Return appropriate color based on the status enum return status switch { McpStatus.Configured => Color.green, McpStatus.Running => Color.green, McpStatus.Connected => Color.green, McpStatus.IncorrectPath => Color.yellow, McpStatus.CommunicationError => Color.yellow, McpStatus.NoResponse => Color.yellow, _ => Color.red, // Default to red for error states or not configured }; } private void UpdatePythonServerInstallationStatus() { try { string installedPath = ServerInstaller.GetServerPath(); bool installedOk = !string.IsNullOrEmpty(installedPath) && File.Exists(Path.Combine(installedPath, "server.py")); if (installedOk) { pythonServerInstallationStatus = "Installed"; pythonServerInstallationStatusColor = Color.green; return; } // Fall back to embedded/dev source via our existing resolution logic string embeddedPath = FindPackagePythonDirectory(); bool embeddedOk = !string.IsNullOrEmpty(embeddedPath) && File.Exists(Path.Combine(embeddedPath, "server.py")); if (embeddedOk) { pythonServerInstallationStatus = "Installed (Embedded)"; pythonServerInstallationStatusColor = Color.green; } else { pythonServerInstallationStatus = "Not Installed"; pythonServerInstallationStatusColor = Color.red; } } catch { pythonServerInstallationStatus = "Not Installed"; pythonServerInstallationStatusColor = Color.red; } } private void DrawStatusDot(Rect statusRect, Color statusColor, float size = 12) { float offsetX = (statusRect.width - size) / 2; float offsetY = (statusRect.height - size) / 2; Rect dotRect = new(statusRect.x + offsetX, statusRect.y + offsetY, size, size); Vector3 center = new( dotRect.x + (dotRect.width / 2), dotRect.y + (dotRect.height / 2), 0 ); float radius = size / 2; // Draw the main dot Handles.color = statusColor; Handles.DrawSolidDisc(center, Vector3.forward, radius); // Draw the border Color borderColor = new( statusColor.r * 0.7f, statusColor.g * 0.7f, statusColor.b * 0.7f ); Handles.color = borderColor; Handles.DrawWireDisc(center, Vector3.forward, radius); } private void OnGUI() { scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); // Header DrawHeader(); // Compute equal column widths for uniform layout float horizontalSpacing = 2f; float outerPadding = 20f; // approximate padding // Make columns a bit less wide for a tighter layout float computed = (position.width - outerPadding - horizontalSpacing) / 2f; float colWidth = Mathf.Clamp(computed, 220f, 340f); // Use fixed heights per row so paired panels match exactly float topPanelHeight = 190f; float bottomPanelHeight = 230f; // Top row: Server Status (left) and Unity Bridge (right) EditorGUILayout.BeginHorizontal(); { EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); DrawServerStatusSection(); EditorGUILayout.EndVertical(); EditorGUILayout.Space(horizontalSpacing); EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); DrawBridgeSection(); EditorGUILayout.EndVertical(); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(10); // Second row: MCP Client Configuration (left) and Script Validation (right) EditorGUILayout.BeginHorizontal(); { EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); DrawUnifiedClientConfiguration(); EditorGUILayout.EndVertical(); EditorGUILayout.Space(horizontalSpacing); EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); DrawValidationSection(); EditorGUILayout.EndVertical(); } EditorGUILayout.EndHorizontal(); // Minimal bottom padding EditorGUILayout.Space(2); EditorGUILayout.EndScrollView(); } private void DrawHeader() { EditorGUILayout.Space(15); Rect titleRect = EditorGUILayout.GetControlRect(false, 40); EditorGUI.DrawRect(titleRect, new Color(0.2f, 0.2f, 0.2f, 0.1f)); GUIStyle titleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 16, alignment = TextAnchor.MiddleLeft }; GUI.Label( new Rect(titleRect.x + 15, titleRect.y + 8, titleRect.width - 30, titleRect.height), "MCP For Unity", titleStyle ); // Place the Show Debug Logs toggle on the same header row, right-aligned float toggleWidth = 160f; Rect toggleRect = new Rect(titleRect.xMax - toggleWidth - 12f, titleRect.y + 10f, toggleWidth, 20f); bool newDebug = GUI.Toggle(toggleRect, debugLogsEnabled, "Show Debug Logs"); if (newDebug != debugLogsEnabled) { debugLogsEnabled = newDebug; EditorPrefs.SetBool("MCPForUnity.DebugLogs", debugLogsEnabled); if (debugLogsEnabled) { LogDebugPrefsState(); } } EditorGUILayout.Space(15); } private void LogDebugPrefsState() { try { string pythonDirOverridePref = SafeGetPrefString("MCPForUnity.PythonDirOverride"); string uvPathPref = SafeGetPrefString("MCPForUnity.UvPath"); string serverSrcPref = SafeGetPrefString("MCPForUnity.ServerSrc"); bool useEmbedded = SafeGetPrefBool("MCPForUnity.UseEmbeddedServer"); // Version-scoped detection key string embeddedVer = ReadEmbeddedVersionOrFallback(); string detectKey = $"MCPForUnity.LegacyDetectLogged:{embeddedVer}"; bool detectLogged = SafeGetPrefBool(detectKey); // Project-scoped auto-register key string projectPath = Application.dataPath ?? string.Empty; string autoKey = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; bool autoRegistered = SafeGetPrefBool(autoKey); MCPForUnity.Editor.Helpers.McpLog.Info( "MCP Debug Prefs:\n" + $" DebugLogs: {debugLogsEnabled}\n" + $" PythonDirOverride: '{pythonDirOverridePref}'\n" + $" UvPath: '{uvPathPref}'\n" + $" ServerSrc: '{serverSrcPref}'\n" + $" UseEmbeddedServer: {useEmbedded}\n" + $" DetectOnceKey: '{detectKey}' => {detectLogged}\n" + $" AutoRegisteredKey: '{autoKey}' => {autoRegistered}", always: false ); } catch (Exception ex) { UnityEngine.Debug.LogWarning($"MCP Debug Prefs logging failed: {ex.Message}"); } } private static string SafeGetPrefString(string key) { try { return EditorPrefs.GetString(key, string.Empty) ?? string.Empty; } catch { return string.Empty; } } private static bool SafeGetPrefBool(string key) { try { return EditorPrefs.GetBool(key, false); } catch { return false; } } private static string ReadEmbeddedVersionOrFallback() { try { if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) { var p = Path.Combine(embeddedSrc, "server_version.txt"); if (File.Exists(p)) { var s = File.ReadAllText(p)?.Trim(); if (!string.IsNullOrEmpty(s)) return s; } } } catch { } return "unknown"; } private void DrawServerStatusSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("Server Status", sectionTitleStyle); EditorGUILayout.Space(8); EditorGUILayout.BeginHorizontal(); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); DrawStatusDot(statusRect, pythonServerInstallationStatusColor, 16); GUIStyle statusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, fontStyle = FontStyle.Bold }; EditorGUILayout.LabelField(pythonServerInstallationStatus, statusStyle, GUILayout.Height(28)); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(5); EditorGUILayout.BeginHorizontal(); bool isAutoMode = MCPForUnityBridge.IsAutoConnectMode(); GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); int currentUnityPort = MCPForUnityBridge.GetCurrentPort(); GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); EditorGUILayout.Space(5); /// Auto-Setup button below ports string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup"; if (GUILayout.Button(setupButtonText, GUILayout.Height(24))) { RunSetupNow(); } EditorGUILayout.Space(4); // Rebuild MCP Server button with tooltip tag using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); GUIContent repairLabel = new GUIContent( "Rebuild MCP Server", "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." ); if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22))) { bool ok = global::MCPForUnity.Editor.Helpers.ServerInstaller.RebuildMcpServer(); if (ok) { EditorUtility.DisplayDialog("MCP For Unity", "Server rebuilt successfully.", "OK"); UpdatePythonServerInstallationStatus(); } else { EditorUtility.DisplayDialog("MCP For Unity", "Rebuild failed. Please check Console for details.", "OK"); } } } // (Removed descriptive tool tag under the Repair button) // (Show Debug Logs toggle moved to header) EditorGUILayout.Space(2); // Python detection warning with link if (!IsPythonDetected()) { GUIStyle warnStyle = new GUIStyle(EditorStyles.label) { richText = true, wordWrap = true }; EditorGUILayout.LabelField("<color=#cc3333><b>Warning:</b></color> No Python installation found.", warnStyle); using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Open Install Instructions", GUILayout.Width(200))) { Application.OpenURL("https://www.python.org/downloads/"); } } EditorGUILayout.Space(4); } // Troubleshooting helpers if (pythonServerInstallationStatusColor != Color.green) { using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Select server folder…", GUILayout.Width(160))) { string picked = EditorUtility.OpenFolderPanel("Select UnityMcpServer/src", Application.dataPath, ""); if (!string.IsNullOrEmpty(picked) && File.Exists(Path.Combine(picked, "server.py"))) { pythonDirOverride = picked; EditorPrefs.SetString("MCPForUnity.PythonDirOverride", pythonDirOverride); UpdatePythonServerInstallationStatus(); } else if (!string.IsNullOrEmpty(picked)) { EditorUtility.DisplayDialog("Invalid Selection", "The selected folder does not contain server.py", "OK"); } } if (GUILayout.Button("Verify again", GUILayout.Width(120))) { UpdatePythonServerInstallationStatus(); } } } EditorGUILayout.EndVertical(); } private void DrawBridgeSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); // Always reflect the live state each repaint to avoid stale UI after recompiles isUnityBridgeRunning = MCPForUnityBridge.IsRunning; GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("Unity Bridge", sectionTitleStyle); EditorGUILayout.Space(8); EditorGUILayout.BeginHorizontal(); Color bridgeColor = isUnityBridgeRunning ? Color.green : Color.red; Rect bridgeStatusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); DrawStatusDot(bridgeStatusRect, bridgeColor, 16); GUIStyle bridgeStatusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, fontStyle = FontStyle.Bold }; EditorGUILayout.LabelField(isUnityBridgeRunning ? "Running" : "Stopped", bridgeStatusStyle, GUILayout.Height(28)); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(8); if (GUILayout.Button(isUnityBridgeRunning ? "Stop Bridge" : "Start Bridge", GUILayout.Height(32))) { ToggleUnityBridge(); } EditorGUILayout.Space(5); EditorGUILayout.EndVertical(); } private void DrawValidationSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("Script Validation", sectionTitleStyle); EditorGUILayout.Space(8); EditorGUI.BeginChangeCheck(); validationLevelIndex = EditorGUILayout.Popup("Validation Level", validationLevelIndex, validationLevelOptions, GUILayout.Height(20)); if (EditorGUI.EndChangeCheck()) { SaveValidationLevelSetting(); } EditorGUILayout.Space(8); string description = GetValidationLevelDescription(validationLevelIndex); EditorGUILayout.HelpBox(description, MessageType.Info); EditorGUILayout.Space(4); // (Show Debug Logs toggle moved to header) EditorGUILayout.Space(2); EditorGUILayout.EndVertical(); } private void DrawUnifiedClientConfiguration() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); EditorGUILayout.Space(10); // (Auto-connect toggle removed per design) // Client selector string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); EditorGUI.BeginChangeCheck(); selectedClientIndex = EditorGUILayout.Popup("Select Client", selectedClientIndex, clientNames, GUILayout.Height(20)); if (EditorGUI.EndChangeCheck()) { selectedClientIndex = Mathf.Clamp(selectedClientIndex, 0, mcpClients.clients.Count - 1); } EditorGUILayout.Space(10); if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) { McpClient selectedClient = mcpClients.clients[selectedClientIndex]; DrawClientConfigurationCompact(selectedClient); } EditorGUILayout.Space(5); EditorGUILayout.EndVertical(); } private void AutoFirstRunSetup() { try { // Project-scoped one-time flag string projectPath = Application.dataPath ?? string.Empty; string key = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; if (EditorPrefs.GetBool(key, false)) { return; } // Attempt client registration using discovered Python server dir pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) { bool anyRegistered = false; foreach (McpClient client in mcpClients.clients) { try { if (client.mcpType == McpTypes.ClaudeCode) { // Only attempt if Claude CLI is present if (!IsClaudeConfigured() && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) { RegisterWithClaudeCode(pythonDir); anyRegistered = true; } } else { CheckMcpConfiguration(client); bool alreadyConfigured = client.status == McpStatus.Configured; if (!alreadyConfigured) { ConfigureMcpClient(client); anyRegistered = true; } } } catch (Exception ex) { MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || CodexConfigHelper.IsCodexConfigured(pythonDir) || IsClaudeConfigured(); } // Ensure the bridge is listening and has a fresh saved port if (!MCPForUnityBridge.IsRunning) { try { MCPForUnityBridge.StartAutoConnect(); isUnityBridgeRunning = MCPForUnityBridge.IsRunning; Repaint(); } catch (Exception ex) { MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup StartAutoConnect failed: {ex.Message}"); } } // Verify bridge with a quick ping lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); EditorPrefs.SetBool(key, true); } catch (Exception e) { MCPForUnity.Editor.Helpers.McpLog.Warn($"MCP for Unity auto-setup skipped: {e.Message}"); } } private static string ComputeSha1(string input) { try { using SHA1 sha1 = SHA1.Create(); byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); byte[] hash = sha1.ComputeHash(bytes); StringBuilder sb = new StringBuilder(hash.Length * 2); foreach (byte b in hash) { sb.Append(b.ToString("x2")); } return sb.ToString(); } catch { return ""; } } private void RunSetupNow() { // Force a one-shot setup regardless of first-run flag try { pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); if (string.IsNullOrEmpty(pythonDir) || !File.Exists(Path.Combine(pythonDir, "server.py"))) { EditorUtility.DisplayDialog("Setup", "Python server not found. Please select UnityMcpServer/src.", "OK"); return; } bool anyRegistered = false; foreach (McpClient client in mcpClients.clients) { try { if (client.mcpType == McpTypes.ClaudeCode) { if (!IsClaudeConfigured()) { RegisterWithClaudeCode(pythonDir); anyRegistered = true; } } else { CheckMcpConfiguration(client); bool alreadyConfigured = client.status == McpStatus.Configured; if (!alreadyConfigured) { ConfigureMcpClient(client); anyRegistered = true; } } } catch (Exception ex) { UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); } } lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || CodexConfigHelper.IsCodexConfigured(pythonDir) || IsClaudeConfigured(); // Restart/ensure bridge MCPForUnityBridge.StartAutoConnect(); isUnityBridgeRunning = MCPForUnityBridge.IsRunning; // Verify lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); Repaint(); } catch (Exception e) { EditorUtility.DisplayDialog("Setup Failed", e.Message, "OK"); } } private static bool IsCursorConfigured(string pythonDir) { try { string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json") : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"); if (!File.Exists(configPath)) return false; string json = File.ReadAllText(configPath); dynamic cfg = JsonConvert.DeserializeObject(json); var servers = cfg?.mcpServers; if (servers == null) return false; var unity = servers.unityMCP ?? servers.UnityMCP; if (unity == null) return false; var args = unity.args; if (args == null) return false; // Prefer exact extraction of the --directory value and compare normalized paths string[] strArgs = ((System.Collections.Generic.IEnumerable<object>)args) .Select(x => x?.ToString() ?? string.Empty) .ToArray(); string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs); if (string.IsNullOrEmpty(dir)) return false; return McpConfigFileHelper.PathsEqual(dir, pythonDir); } catch { return false; } } private static bool IsClaudeConfigured() { try { string claudePath = ExecPath.ResolveClaude(); if (string.IsNullOrEmpty(claudePath)) return false; // Only prepend PATH on Unix string pathPrepend = null; if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { pathPrepend = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" : "/usr/local/bin:/usr/bin:/bin"; } if (!ExecPath.TryRun(claudePath, "mcp list", workingDir: null, out var stdout, out var stderr, 5000, pathPrepend)) { return false; } return (stdout ?? string.Empty).IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; } catch { return false; } } private static bool VerifyBridgePing(int port) { // Use strict framed protocol to match bridge (FRAMING=1) const int ConnectTimeoutMs = 1000; const int FrameTimeoutMs = 30000; // match bridge frame I/O timeout try { using TcpClient client = new TcpClient(); var connectTask = client.ConnectAsync(IPAddress.Loopback, port); if (!connectTask.Wait(ConnectTimeoutMs)) return false; using NetworkStream stream = client.GetStream(); try { client.NoDelay = true; } catch { } // 1) Read handshake line (ASCII, newline-terminated) string handshake = ReadLineAscii(stream, 2000); if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) { UnityEngine.Debug.LogWarning("MCP for Unity: Bridge handshake missing FRAMING=1"); return false; } // 2) Send framed "ping" byte[] payload = Encoding.UTF8.GetBytes("ping"); WriteFrame(stream, payload, FrameTimeoutMs); // 3) Read framed response and check for pong string response = ReadFrameUtf8(stream, FrameTimeoutMs); bool ok = !string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0; if (!ok) { UnityEngine.Debug.LogWarning($"MCP for Unity: Framed ping failed; response='{response}'"); } return ok; } catch (Exception ex) { UnityEngine.Debug.LogWarning($"MCP for Unity: VerifyBridgePing error: {ex.Message}"); return false; } } // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) { if (payload == null) throw new ArgumentNullException(nameof(payload)); if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); byte[] header = new byte[8]; ulong len = (ulong)payload.LongLength; header[0] = (byte)(len >> 56); header[1] = (byte)(len >> 48); header[2] = (byte)(len >> 40); header[3] = (byte)(len >> 32); header[4] = (byte)(len >> 24); header[5] = (byte)(len >> 16); header[6] = (byte)(len >> 8); header[7] = (byte)(len); stream.WriteTimeout = timeoutMs; stream.Write(header, 0, header.Length); stream.Write(payload, 0, payload.Length); } private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) { byte[] header = ReadExact(stream, 8, timeoutMs); ulong len = ((ulong)header[0] << 56) | ((ulong)header[1] << 48) | ((ulong)header[2] << 40) | ((ulong)header[3] << 32) | ((ulong)header[4] << 24) | ((ulong)header[5] << 16) | ((ulong)header[6] << 8) | header[7]; if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); if (len > int.MaxValue) throw new IOException("Frame too large"); byte[] payload = ReadExact(stream, (int)len, timeoutMs); return Encoding.UTF8.GetString(payload); } private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) { byte[] buffer = new byte[count]; int offset = 0; stream.ReadTimeout = timeoutMs; while (offset < count) { int read = stream.Read(buffer, offset, count - offset); if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); offset += read; } return buffer; } private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) { stream.ReadTimeout = timeoutMs; using var ms = new MemoryStream(); byte[] one = new byte[1]; while (ms.Length < maxLen) { int n = stream.Read(one, 0, 1); if (n <= 0) break; if (one[0] == (byte)'\n') break; ms.WriteByte(one[0]); } return Encoding.ASCII.GetString(ms.ToArray()); } private void DrawClientConfigurationCompact(McpClient mcpClient) { // Special pre-check for Claude Code: if CLI missing, reflect in status UI if (mcpClient.mcpType == McpTypes.ClaudeCode) { string claudeCheck = ExecPath.ResolveClaude(); if (string.IsNullOrEmpty(claudeCheck)) { mcpClient.configStatus = "Claude Not Found"; mcpClient.status = McpStatus.NotConfigured; } } // Pre-check for clients that require uv (all except Claude Code) bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode; bool uvMissingEarly = false; if (uvRequired) { string uvPathEarly = FindUvPath(); if (string.IsNullOrEmpty(uvPathEarly)) { uvMissingEarly = true; mcpClient.configStatus = "uv Not Found"; mcpClient.status = McpStatus.NotConfigured; } } // Status display EditorGUILayout.BeginHorizontal(); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); Color statusColor = GetStatusColor(mcpClient.status); DrawStatusDot(statusRect, statusColor, 16); GUIStyle clientStatusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, fontStyle = FontStyle.Bold }; EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28)); EditorGUILayout.EndHorizontal(); // When Claude CLI is missing, show a clear install hint directly below status if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) { GUIStyle installHintStyle = new GUIStyle(clientStatusStyle); installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange EditorGUILayout.BeginHorizontal(); GUIContent installText = new GUIContent("Make sure Claude Code is installed!"); Vector2 textSize = installHintStyle.CalcSize(installText); EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false)); GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; GUILayout.Space(6); if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) { Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code"); } EditorGUILayout.EndHorizontal(); } EditorGUILayout.Space(10); // If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls if (uvRequired && uvMissingEarly) { GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label) { fontSize = 12, fontStyle = FontStyle.Bold, wordWrap = false }; installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f); EditorGUILayout.BeginHorizontal(); GUIContent installText2 = new GUIContent("Make sure uv is installed!"); Vector2 sz = installHintStyle2.CalcSize(installText2); EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false)); GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; GUILayout.Space(6); if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) { Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf"); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(8); EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("Choose uv Install Location", GUILayout.Width(260), GUILayout.Height(22))) { string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, ""); if (!string.IsNullOrEmpty(picked)) { EditorPrefs.SetString("MCPForUnity.UvPath", picked); ConfigureMcpClient(mcpClient); Repaint(); } } EditorGUILayout.EndHorizontal(); return; } // Action buttons in horizontal layout EditorGUILayout.BeginHorizontal(); if (mcpClient.mcpType == McpTypes.VSCode) { if (GUILayout.Button("Auto Configure", GUILayout.Height(32))) { ConfigureMcpClient(mcpClient); } } else if (mcpClient.mcpType == McpTypes.ClaudeCode) { bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); if (claudeAvailable) { bool isConfigured = mcpClient.status == McpStatus.Configured; string buttonText = isConfigured ? "Unregister MCP for Unity with Claude Code" : "Register with Claude Code"; if (GUILayout.Button(buttonText, GUILayout.Height(32))) { if (isConfigured) { UnregisterWithClaudeCode(); } else { string pythonDir = FindPackagePythonDirectory(); RegisterWithClaudeCode(pythonDir); } } // Hide the picker once a valid binary is available EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true }; string resolvedClaude = ExecPath.ResolveClaude(); EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); } // CLI picker row (only when not found) EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); if (!claudeAvailable) { // Only show the picker button in not-found state (no redundant "not found" label) if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22))) { string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, ""); if (!string.IsNullOrEmpty(picked)) { ExecPath.SetClaudeCliPath(picked); // Auto-register after setting a valid path string pythonDir = FindPackagePythonDirectory(); RegisterWithClaudeCode(pythonDir); Repaint(); } } } EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); } else { if (GUILayout.Button($"Auto Configure", GUILayout.Height(32))) { ConfigureMcpClient(mcpClient); } } if (mcpClient.mcpType != McpTypes.ClaudeCode) { if (GUILayout.Button("Manual Setup", GUILayout.Height(32))) { string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath; if (mcpClient.mcpType == McpTypes.VSCode) { string pythonDir = FindPackagePythonDirectory(); string uvPath = FindUvPath(); if (uvPath == null) { UnityEngine.Debug.LogError("UV package manager not found. Cannot configure VSCode."); return; } // VSCode now reads from mcp.json with a top-level "servers" block var vscodeConfig = new { servers = new { unityMCP = new { command = uvPath, args = new[] { "run", "--directory", pythonDir, "server.py" } } } }; JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; string manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); VSCodeManualSetupWindow.ShowWindow(configPath, manualConfigJson); } else { ShowManualInstructionsWindow(configPath, mcpClient); } } } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(8); // Quick info (hide when Claude is not found to avoid confusion) bool hideConfigInfo = (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) || ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath())); if (!hideConfigInfo) { GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 10 }; EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); } } private void ToggleUnityBridge() { if (isUnityBridgeRunning) { MCPForUnityBridge.Stop(); } else { MCPForUnityBridge.Start(); } // Reflect the actual state post-operation (avoid optimistic toggle) isUnityBridgeRunning = MCPForUnityBridge.IsRunning; Repaint(); } // New method to show manual instructions without changing status private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) { // Get the Python directory path using Package Manager API string pythonDir = FindPackagePythonDirectory(); // Build manual JSON centrally using the shared builder string uvPathForManual = FindUvPath(); if (uvPathForManual == null) { UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); return; } string manualConfig = mcpClient?.mcpType == McpTypes.Codex ? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine : ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient); } private string FindPackagePythonDirectory() { // Use shared helper for consistent path resolution across both windows return McpPathResolver.FindPackagePythonDirectory(debugLogsEnabled); } private string ConfigureMcpClient(McpClient mcpClient) { try { // Use shared helper for consistent config path resolution string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); // Create directory if it doesn't exist McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); // Find the server.py file location using shared helper string pythonDir = FindPackagePythonDirectory(); if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) { ShowManualInstructionsWindow(configPath, mcpClient); return "Manual Configuration Required"; } string result = mcpClient.mcpType == McpTypes.Codex ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); // Update the client status after successful configuration if (result == "Configured successfully") { mcpClient.SetStatus(McpStatus.Configured); } return result; } catch (Exception e) { // Determine the config file path based on OS for error message string configPath = ""; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { configPath = mcpClient.windowsConfigPath; } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ) { configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; } ShowManualInstructionsWindow(configPath, mcpClient); UnityEngine.Debug.LogError( $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" ); return $"Failed to configure {mcpClient.name}"; } } private void LoadValidationLevelSetting() { string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); validationLevelIndex = savedLevel.ToLower() switch { "basic" => 0, "standard" => 1, "comprehensive" => 2, "strict" => 3, _ => 1 // Default to Standard }; } private void SaveValidationLevelSetting() { string levelString = validationLevelIndex switch { 0 => "basic", 1 => "standard", 2 => "comprehensive", 3 => "strict", _ => "standard" }; EditorPrefs.SetString("MCPForUnity_ScriptValidationLevel", levelString); } private string GetValidationLevelDescription(int index) { return index switch { 0 => "Only basic syntax checks (braces, quotes, comments)", 1 => "Syntax checks + Unity best practices and warnings", 2 => "All checks + semantic analysis and performance warnings", 3 => "Full semantic validation with namespace/type resolution (requires Roslyn)", _ => "Standard validation" }; } private void CheckMcpConfiguration(McpClient mcpClient) { try { // Special handling for Claude Code if (mcpClient.mcpType == McpTypes.ClaudeCode) { CheckClaudeCodeConfiguration(mcpClient); return; } // Use shared helper for consistent config path resolution string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); if (!File.Exists(configPath)) { mcpClient.SetStatus(McpStatus.NotConfigured); return; } string configJson = File.ReadAllText(configPath); // Use the same path resolution as configuration to avoid false "Incorrect Path" in dev mode string pythonDir = FindPackagePythonDirectory(); // Use switch statement to handle different client types, extracting common logic string[] args = null; bool configExists = false; switch (mcpClient.mcpType) { case McpTypes.VSCode: dynamic config = JsonConvert.DeserializeObject(configJson); // New schema: top-level servers if (config?.servers?.unityMCP != null) { args = config.servers.unityMCP.args.ToObject<string[]>(); configExists = true; } // Back-compat: legacy mcp.servers else if (config?.mcp?.servers?.unityMCP != null) { args = config.mcp.servers.unityMCP.args.ToObject<string[]>(); configExists = true; } break; case McpTypes.Codex: if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) { args = codexArgs; configExists = true; } break; default: // Standard MCP configuration check for Claude Desktop, Cursor, etc. McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson); if (standardConfig?.mcpServers?.unityMCP != null) { args = standardConfig.mcpServers.unityMCP.args; configExists = true; } break; } // Common logic for checking configuration status if (configExists) { string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args); bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir); if (matches) { mcpClient.SetStatus(McpStatus.Configured); } else { // Attempt auto-rewrite once if the package path changed try { string rewriteResult = mcpClient.mcpType == McpTypes.Codex ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); if (rewriteResult == "Configured successfully") { if (debugLogsEnabled) { MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false); } mcpClient.SetStatus(McpStatus.Configured); } else { mcpClient.SetStatus(McpStatus.IncorrectPath); } } catch (Exception ex) { mcpClient.SetStatus(McpStatus.IncorrectPath); if (debugLogsEnabled) { UnityEngine.Debug.LogWarning($"MCP for Unity: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); } } } } else { mcpClient.SetStatus(McpStatus.MissingConfig); } } catch (Exception e) { mcpClient.SetStatus(McpStatus.Error, e.Message); } } private void RegisterWithClaudeCode(string pythonDir) { // Resolve claude and uv; then run register command string claudePath = ExecPath.ResolveClaude(); if (string.IsNullOrEmpty(claudePath)) { UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); return; } string uvPath = ExecPath.ResolveUv() ?? "uv"; // Prefer embedded/dev path when available string srcDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); if (string.IsNullOrEmpty(srcDir)) srcDir = pythonDir; string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{srcDir}\" server.py"; string projectDir = Path.GetDirectoryName(Application.dataPath); // Ensure PATH includes common locations on Unix; on Windows leave PATH as-is string pathPrepend = null; if (Application.platform == RuntimePlatform.OSXEditor || Application.platform == RuntimePlatform.LinuxEditor) { pathPrepend = Application.platform == RuntimePlatform.OSXEditor ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" : "/usr/local/bin:/usr/bin:/bin"; } if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { string combined = ($"{stdout}\n{stderr}") ?? string.Empty; if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) { // Treat as success if Claude reports existing registration var existingClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (existingClient != null) CheckClaudeCodeConfiguration(existingClient); Repaint(); UnityEngine.Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCP for Unity already registered with Claude Code."); } else { UnityEngine.Debug.LogError($"MCP for Unity: Failed to start Claude CLI.\n{stderr}\n{stdout}"); } return; } // Update status var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) CheckClaudeCodeConfiguration(claudeClient); Repaint(); UnityEngine.Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Registered with Claude Code."); } private void UnregisterWithClaudeCode() { string claudePath = ExecPath.ResolveClaude(); if (string.IsNullOrEmpty(claudePath)) { UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); return; } string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = Application.platform == RuntimePlatform.OSXEditor ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" : null; // On Windows, don't modify PATH - use system PATH as-is // Determine if Claude has a "UnityMCP" server registered by using exit codes from `claude mcp get <name>` string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; List<string> existingNames = new List<string>(); foreach (var candidate in candidateNamesForGet) { if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) { // Success exit code indicates the server exists existingNames.Add(candidate); } } if (existingNames.Count == 0) { // Nothing to unregister – set status and bail early var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { claudeClient.SetStatus(McpStatus.NotConfigured); UnityEngine.Debug.Log("Claude CLI reports no MCP for Unity server via 'mcp get' - setting status to NotConfigured and aborting unregister."); Repaint(); } return; } // Try different possible server names string[] possibleNames = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; bool success = false; foreach (string serverName in possibleNames) { if (ExecPath.TryRun(claudePath, $"mcp remove {serverName}", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) { success = true; UnityEngine.Debug.Log($"MCP for Unity: Successfully removed MCP server: {serverName}"); break; } else if (!string.IsNullOrEmpty(stderr) && !stderr.Contains("No MCP server found", StringComparison.OrdinalIgnoreCase)) { // If it's not a "not found" error, log it and stop trying UnityEngine.Debug.LogWarning($"Error removing {serverName}: {stderr}"); break; } } if (success) { var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { // Optimistically flip to NotConfigured; then verify claudeClient.SetStatus(McpStatus.NotConfigured); CheckClaudeCodeConfiguration(claudeClient); } Repaint(); UnityEngine.Debug.Log("MCP for Unity: MCP server successfully unregistered from Claude Code."); } else { // If no servers were found to remove, they're already unregistered // Force status to NotConfigured and update the UI UnityEngine.Debug.Log("No MCP servers found to unregister - already unregistered."); var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { claudeClient.SetStatus(McpStatus.NotConfigured); CheckClaudeCodeConfiguration(claudeClient); } Repaint(); } } // Removed unused ParseTextOutput private string FindUvPath() { try { return MCPForUnity.Editor.Helpers.ServerInstaller.FindUvPath(); } catch { return null; } } // Validation and platform-specific scanning are handled by ServerInstaller.FindUvPath() // Windows-specific discovery removed; use ServerInstaller.FindUvPath() instead // Removed unused FindClaudeCommand private void CheckClaudeCodeConfiguration(McpClient mcpClient) { try { // Get the Unity project directory to check project-specific config string unityProjectDir = Application.dataPath; string projectDir = Path.GetDirectoryName(unityProjectDir); // Read the global Claude config file (honor macConfigPath on macOS) string configPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) configPath = mcpClient.windowsConfigPath; else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; else configPath = mcpClient.linuxConfigPath; if (debugLogsEnabled) { MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false); } if (!File.Exists(configPath)) { UnityEngine.Debug.LogWarning($"Claude config file not found at: {configPath}"); mcpClient.SetStatus(McpStatus.NotConfigured); return; } string configJson = File.ReadAllText(configPath); dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); // Check for "UnityMCP" server in the mcpServers section (current format) if (claudeConfig?.mcpServers != null) { var servers = claudeConfig.mcpServers; if (servers.UnityMCP != null || servers.unityMCP != null) { // Found MCP for Unity configured mcpClient.SetStatus(McpStatus.Configured); return; } } // Also check if there's a project-specific configuration for this Unity project (legacy format) if (claudeConfig?.projects != null) { // Look for the project path in the config foreach (var project in claudeConfig.projects) { string projectPath = project.Name; // Normalize paths for comparison (handle forward/back slash differences) string normalizedProjectPath = Path.GetFullPath(projectPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string normalizedProjectDir = Path.GetFullPath(projectDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (string.Equals(normalizedProjectPath, normalizedProjectDir, StringComparison.OrdinalIgnoreCase) && project.Value?.mcpServers != null) { // Check for "UnityMCP" (case variations) var servers = project.Value.mcpServers; if (servers.UnityMCP != null || servers.unityMCP != null) { // Found MCP for Unity configured for this project mcpClient.SetStatus(McpStatus.Configured); return; } } } } // No configuration found for this project mcpClient.SetStatus(McpStatus.NotConfigured); } catch (Exception e) { UnityEngine.Debug.LogWarning($"Error checking Claude Code config: {e.Message}"); mcpClient.SetStatus(McpStatus.Error, e.Message); } } private bool IsPythonDetected() { try { // Windows-specific Python detection if (Application.platform == RuntimePlatform.WindowsEditor) { // Common Windows Python installation paths string[] windowsCandidates = { @"C:\Python313\python.exe", @"C:\Python312\python.exe", @"C:\Python311\python.exe", @"C:\Python310\python.exe", @"C:\Python39\python.exe", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), }; foreach (string c in windowsCandidates) { if (File.Exists(c)) return true; } // Try 'where python' command (Windows equivalent of 'which') var psi = new ProcessStartInfo { FileName = "where", Arguments = "python", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var p = Process.Start(psi); string outp = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(2000); if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) { string[] lines = outp.Split('\n'); foreach (string line in lines) { string trimmed = line.Trim(); if (File.Exists(trimmed)) return true; } } } else { // macOS/Linux detection (existing code) string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; string[] candidates = { "/opt/homebrew/bin/python3", "/usr/local/bin/python3", "/usr/bin/python3", "/opt/local/bin/python3", Path.Combine(home, ".local", "bin", "python3"), "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", }; foreach (string c in candidates) { if (File.Exists(c)) return true; } // Try 'which python3' var psi = new ProcessStartInfo { FileName = "/usr/bin/which", Arguments = "python3", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var p = Process.Start(psi); string outp = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(2000); if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; } } catch { } return false; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/External/Tommy.cs: -------------------------------------------------------------------------------- ```csharp #region LICENSE /* * MIT License * * Copyright (c) 2020 Denis Zhidkikh * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ #endregion using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace MCPForUnity.External.Tommy { #region TOML Nodes public abstract class TomlNode : IEnumerable { public virtual bool HasValue { get; } = false; public virtual bool IsArray { get; } = false; public virtual bool IsTable { get; } = false; public virtual bool IsString { get; } = false; public virtual bool IsInteger { get; } = false; public virtual bool IsFloat { get; } = false; public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset; public virtual bool IsDateTimeLocal { get; } = false; public virtual bool IsDateTimeOffset { get; } = false; public virtual bool IsBoolean { get; } = false; public virtual string Comment { get; set; } public virtual int CollapseLevel { get; set; } public virtual TomlTable AsTable => this as TomlTable; public virtual TomlString AsString => this as TomlString; public virtual TomlInteger AsInteger => this as TomlInteger; public virtual TomlFloat AsFloat => this as TomlFloat; public virtual TomlBoolean AsBoolean => this as TomlBoolean; public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal; public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset; public virtual TomlDateTime AsDateTime => this as TomlDateTime; public virtual TomlArray AsArray => this as TomlArray; public virtual int ChildrenCount => 0; public virtual TomlNode this[string key] { get => null; set { } } public virtual TomlNode this[int index] { get => null; set { } } public virtual IEnumerable<TomlNode> Children { get { yield break; } } public virtual IEnumerable<string> Keys { get { yield break; } } public IEnumerator GetEnumerator() => Children.GetEnumerator(); public virtual bool TryGetNode(string key, out TomlNode node) { node = null; return false; } public virtual bool HasKey(string key) => false; public virtual bool HasItemAt(int index) => false; public virtual void Add(string key, TomlNode node) { } public virtual void Add(TomlNode node) { } public virtual void Delete(TomlNode node) { } public virtual void Delete(string key) { } public virtual void Delete(int index) { } public virtual void AddRange(IEnumerable<TomlNode> nodes) { foreach (var tomlNode in nodes) Add(tomlNode); } public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml()); public virtual string ToInlineToml() => ToString(); #region Native type to TOML cast public static implicit operator TomlNode(string value) => new TomlString { Value = value }; public static implicit operator TomlNode(bool value) => new TomlBoolean { Value = value }; public static implicit operator TomlNode(long value) => new TomlInteger { Value = value }; public static implicit operator TomlNode(float value) => new TomlFloat { Value = value }; public static implicit operator TomlNode(double value) => new TomlFloat { Value = value }; public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal { Value = value }; public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset { Value = value }; public static implicit operator TomlNode(TomlNode[] nodes) { var result = new TomlArray(); result.AddRange(nodes); return result; } #endregion #region TOML to native type cast public static implicit operator string(TomlNode value) => value.ToString(); public static implicit operator int(TomlNode value) => (int)value.AsInteger.Value; public static implicit operator long(TomlNode value) => value.AsInteger.Value; public static implicit operator float(TomlNode value) => (float)value.AsFloat.Value; public static implicit operator double(TomlNode value) => value.AsFloat.Value; public static implicit operator bool(TomlNode value) => value.AsBoolean.Value; public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value; public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value; #endregion } public class TomlString : TomlNode { public override bool HasValue { get; } = true; public override bool IsString { get; } = true; public bool IsMultiline { get; set; } public bool MultilineTrimFirstLine { get; set; } public bool PreferLiteral { get; set; } public string Value { get; set; } public override string ToString() => Value; public override string ToInlineToml() { // Automatically convert literal to non-literal if there are too many literal string symbols if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false; var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL, IsMultiline ? 3 : 1); var result = PreferLiteral ? Value : Value.Escape(!IsMultiline); if (IsMultiline) result = result.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(Environment.NewLine))) result = $"{Environment.NewLine}{result}"; return $"{quotes}{result}{quotes}"; } } public class TomlInteger : TomlNode { public enum Base { Binary = 2, Octal = 8, Decimal = 10, Hexadecimal = 16 } public override bool IsInteger { get; } = true; public override bool HasValue { get; } = true; public Base IntegerBase { get; set; } = Base.Decimal; public long Value { get; set; } public override string ToString() => Value.ToString(); public override string ToInlineToml() => IntegerBase != Base.Decimal ? $"0{TomlSyntax.BaseIdentifiers[(int)IntegerBase]}{Convert.ToString(Value, (int)IntegerBase)}" : Value.ToString(CultureInfo.InvariantCulture); } public class TomlFloat : TomlNode, IFormattable { public override bool IsFloat { get; } = true; public override bool HasValue { get; } = true; public double Value { get; set; } public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); public override string ToInlineToml() => Value switch { var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() }; } public class TomlBoolean : TomlNode { public override bool IsBoolean { get; } = true; public override bool HasValue { get; } = true; public bool Value { get; set; } public override string ToString() => Value.ToString(); public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE; } public class TomlDateTime : TomlNode, IFormattable { public int SecondsPrecision { get; set; } public override bool HasValue { get; } = true; public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty; public virtual string ToString(IFormatProvider formatProvider) => string.Empty; protected virtual string ToInlineTomlInternal() => string.Empty; public override string ToInlineToml() => ToInlineTomlInternal() .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator) .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone); } public class TomlDateTimeOffset : TomlDateTime { public override bool IsDateTimeOffset { get; } = true; public DateTimeOffset Value { get; set; } public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); public override string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]); } public class TomlDateTimeLocal : TomlDateTime { public enum DateTimeStyle { Date, Time, DateTime } public override bool IsDateTimeLocal { get; } = true; public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; public DateTime Value { get; set; } public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); public override string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); public override string ToInlineToml() => Style switch { DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) }; } public class TomlArray : TomlNode { private List<TomlNode> values; public override bool HasValue { get; } = true; public override bool IsArray { get; } = true; public bool IsMultiline { get; set; } public bool IsTableArray { get; set; } public List<TomlNode> RawArray => values ??= new List<TomlNode>(); public override TomlNode this[int index] { get { if (index < RawArray.Count) return RawArray[index]; var lazy = new TomlLazy(this); this[index] = lazy; return lazy; } set { if (index == RawArray.Count) RawArray.Add(value); else RawArray[index] = value; } } public override int ChildrenCount => RawArray.Count; public override IEnumerable<TomlNode> Children => RawArray.AsEnumerable(); public override void Add(TomlNode node) => RawArray.Add(node); public override void AddRange(IEnumerable<TomlNode> nodes) => RawArray.AddRange(nodes); public override void Delete(TomlNode node) => RawArray.Remove(node); public override void Delete(int index) => RawArray.RemoveAt(index); public override string ToString() => ToString(false); public string ToString(bool multiline) { var sb = new StringBuilder(); sb.Append(TomlSyntax.ARRAY_START_SYMBOL); if (ChildrenCount != 0) { var arrayStart = multiline ? $"{Environment.NewLine} " : " "; var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{Environment.NewLine} " : $"{TomlSyntax.ITEM_SEPARATOR} "; var arrayEnd = multiline ? Environment.NewLine : " "; sb.Append(arrayStart) .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml()))) .Append(arrayEnd); } sb.Append(TomlSyntax.ARRAY_END_SYMBOL); return sb.ToString(); } public override void WriteTo(TextWriter tw, string name = null) { // If it's a normal array, write it as usual if (!IsTableArray) { tw.WriteLine(ToString(IsMultiline)); return; } if (!(Comment is null)) { tw.WriteLine(); Comment.AsComment(tw); } tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(name); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.WriteLine(); var first = true; foreach (var tomlNode in RawArray) { if (!(tomlNode is TomlTable tbl)) throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); // Ensure it's parsed as a section tbl.IsInline = false; if (!first) { tw.WriteLine(); Comment?.AsComment(tw); tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(name); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.WriteLine(); } first = false; // Don't write section since it's already written here tbl.WriteTo(tw, name, false); } } } public class TomlTable : TomlNode { private Dictionary<string, TomlNode> children; internal bool isImplicit; public override bool HasValue { get; } = false; public override bool IsTable { get; } = true; public bool IsInline { get; set; } public Dictionary<string, TomlNode> RawTable => children ??= new Dictionary<string, TomlNode>(); public override TomlNode this[string key] { get { if (RawTable.TryGetValue(key, out var result)) return result; var lazy = new TomlLazy(this); RawTable[key] = lazy; return lazy; } set => RawTable[key] = value; } public override int ChildrenCount => RawTable.Count; public override IEnumerable<TomlNode> Children => RawTable.Select(kv => kv.Value); public override IEnumerable<string> Keys => RawTable.Select(kv => kv.Key); public override bool HasKey(string key) => RawTable.ContainsKey(key); public override void Add(string key, TomlNode node) => RawTable.Add(key, node); public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node); public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key); public override void Delete(string key) => RawTable.Remove(key); public override string ToString() { var sb = new StringBuilder(); sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL); if (ChildrenCount != 0) { var collapsed = CollectCollapsedItems(normalizeOrder: false); if (collapsed.Count != 0) sb.Append(' ') .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n => $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}"))); sb.Append(' '); } sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL); return sb.ToString(); } private LinkedList<KeyValuePair<string, TomlNode>> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true) { var nodes = new LinkedList<KeyValuePair<string, TomlNode>>(); var postNodes = normalizeOrder ? new LinkedList<KeyValuePair<string, TomlNode>>() : nodes; foreach (var keyValuePair in RawTable) { var node = keyValuePair.Value; var key = keyValuePair.Key.AsKey(); if (node is TomlTable tbl) { var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); // Write main table first before writing collapsed items if (subnodes.Count == 0 && node.CollapseLevel == level) { postNodes.AddLast(new KeyValuePair<string, TomlNode>($"{prefix}{key}", node)); } foreach (var kv in subnodes) postNodes.AddLast(kv); } else if (node.CollapseLevel == level) nodes.AddLast(new KeyValuePair<string, TomlNode>($"{prefix}{key}", node)); } if (normalizeOrder) foreach (var kv in postNodes) nodes.AddLast(kv); return nodes; } public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true); internal void WriteTo(TextWriter tw, string name, bool writeSectionName) { // The table is inline table if (IsInline && name != null) { tw.WriteLine(ToInlineToml()); return; } var collapsedItems = CollectCollapsedItems(); if (collapsedItems.Count == 0) return; var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable { IsInline: false } or TomlArray { IsTableArray: true }); Comment?.AsComment(tw); if (name != null && (hasRealValues || Comment != null) && writeSectionName) { tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(name); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.WriteLine(); } else if (Comment != null) // Add some spacing between the first node and the comment { tw.WriteLine(); } var namePrefix = name == null ? "" : $"{name}."; var first = true; foreach (var collapsedItem in collapsedItems) { var key = collapsedItem.Key; if (collapsedItem.Value is TomlArray { IsTableArray: true } or TomlTable { IsInline: false }) { if (!first) tw.WriteLine(); first = false; collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); continue; } first = false; collapsedItem.Value.Comment?.AsComment(tw); tw.Write(key); tw.Write(' '); tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); tw.Write(' '); collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); } } } internal class TomlLazy : TomlNode { private readonly TomlNode parent; private TomlNode replacement; public TomlLazy(TomlNode parent) => this.parent = parent; public override TomlNode this[int index] { get => Set<TomlArray>()[index]; set => Set<TomlArray>()[index] = value; } public override TomlNode this[string key] { get => Set<TomlTable>()[key]; set => Set<TomlTable>()[key] = value; } public override void Add(TomlNode node) => Set<TomlArray>().Add(node); public override void Add(string key, TomlNode node) => Set<TomlTable>().Add(key, node); public override void AddRange(IEnumerable<TomlNode> nodes) => Set<TomlArray>().AddRange(nodes); private TomlNode Set<T>() where T : TomlNode, new() { if (replacement != null) return replacement; var newNode = new T { Comment = Comment }; if (parent.IsTable) { var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this)); if (key == null) return default(T); parent[key] = newNode; } else if (parent.IsArray) { var index = parent.Children.TakeWhile(child => child != this).Count(); if (index == parent.ChildrenCount) return default(T); parent[index] = newNode; } else { return default(T); } replacement = newNode; return newNode; } } #endregion #region Parser public class TOMLParser : IDisposable { public enum ParseState { None, KeyValuePair, SkipToNextLine, Table } private readonly TextReader reader; private ParseState currentState; private int line, col; private List<TomlSyntaxException> syntaxErrors; public TOMLParser(TextReader reader) { this.reader = reader; line = col = 0; } public bool ForceASCII { get; set; } public void Dispose() => reader?.Dispose(); public TomlTable Parse() { syntaxErrors = new List<TomlSyntaxException>(); line = col = 1; var rootNode = new TomlTable(); var currentNode = rootNode; currentState = ParseState.None; var keyParts = new List<string>(); var arrayTable = false; StringBuilder latestComment = null; var firstComment = true; int currentChar; while ((currentChar = reader.Peek()) >= 0) { var c = (char)currentChar; if (currentState == ParseState.None) { // Skip white space if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; if (TomlSyntax.IsNewLine(c)) { // Check if there are any comments and so far no items being declared if (latestComment != null && firstComment) { rootNode.Comment = latestComment.ToString().TrimEnd(); latestComment = null; firstComment = false; } if (TomlSyntax.IsLineBreak(c)) AdvanceLine(); goto consume_character; } // Start of a comment; ignore until newline if (c == TomlSyntax.COMMENT_SYMBOL) { latestComment ??= new StringBuilder(); latestComment.AppendLine(ParseComment()); AdvanceLine(1); continue; } // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)! firstComment = false; if (c == TomlSyntax.TABLE_START_SYMBOL) { currentState = ParseState.Table; goto consume_character; } if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c)) { currentState = ParseState.KeyValuePair; } else { AddError($"Unexpected character \"{c}\""); continue; } } if (currentState == ParseState.KeyValuePair) { var keyValuePair = ReadKeyValuePair(keyParts); if (keyValuePair == null) { latestComment = null; keyParts.Clear(); if (currentState != ParseState.None) AddError("Failed to parse key-value pair!"); continue; } keyValuePair.Comment = latestComment?.ToString()?.TrimEnd(); var inserted = InsertNode(keyValuePair, currentNode, keyParts); latestComment = null; keyParts.Clear(); if (inserted) currentState = ParseState.SkipToNextLine; continue; } if (currentState == ParseState.Table) { if (keyParts.Count == 0) { // We have array table if (c == TomlSyntax.TABLE_START_SYMBOL) { // Consume the character ConsumeChar(); arrayTable = true; } if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL)) { keyParts.Clear(); continue; } if (keyParts.Count == 0) { AddError("Table name is emtpy."); arrayTable = false; latestComment = null; keyParts.Clear(); } continue; } if (c == TomlSyntax.TABLE_END_SYMBOL) { if (arrayTable) { // Consume the ending bracket so we can peek the next character ConsumeChar(); var nextChar = reader.Peek(); if (nextChar < 0 || (char)nextChar != TomlSyntax.TABLE_END_SYMBOL) { AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); keyParts.Clear(); arrayTable = false; latestComment = null; continue; } } currentNode = CreateTable(rootNode, keyParts, arrayTable); if (currentNode != null) { currentNode.IsInline = false; currentNode.Comment = latestComment?.ToString()?.TrimEnd(); } keyParts.Clear(); arrayTable = false; latestComment = null; if (currentNode == null) { if (currentState != ParseState.None) AddError("Error creating table array!"); // Reset a node to root in order to try and continue parsing currentNode = rootNode; continue; } currentState = ParseState.SkipToNextLine; goto consume_character; } if (keyParts.Count != 0) { AddError($"Unexpected character \"{c}\""); keyParts.Clear(); arrayTable = false; latestComment = null; } } if (currentState == ParseState.SkipToNextLine) { if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER) goto consume_character; if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER) { currentState = ParseState.None; AdvanceLine(); if (c == TomlSyntax.COMMENT_SYMBOL) { col++; ParseComment(); continue; } goto consume_character; } AddError($"Unexpected character \"{c}\" at the end of the line."); } consume_character: reader.Read(); col++; } if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine) AddError("Unexpected end of file!"); if (syntaxErrors.Count > 0) throw new TomlParseException(rootNode, syntaxErrors); return rootNode; } private bool AddError(string message, bool skipLine = true) { syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col)); // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that) if (skipLine) { reader.ReadLine(); AdvanceLine(1); } currentState = ParseState.None; return false; } private void AdvanceLine(int startCol = 0) { line++; col = startCol; } private int ConsumeChar() { col++; return reader.Read(); } #region Key-Value pair parsing /** * Reads a single key-value pair. * Assumes the cursor is at the first character that belong to the pair (including possible whitespace). * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end). * * Example: * foo = "bar" ==> foo = "bar" * ^ ^ */ private TomlNode ReadKeyValuePair(List<string> keyParts) { int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) { if (keyParts.Count != 0) { AddError("Encountered extra characters in key definition!"); return null; } if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR)) return null; continue; } if (TomlSyntax.IsWhiteSpace(c)) { ConsumeChar(); continue; } if (c == TomlSyntax.KEY_VALUE_SEPARATOR) { ConsumeChar(); return ReadValue(); } AddError($"Unexpected character \"{c}\" in key name."); return null; } return null; } /** * Reads a single value. * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace). * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end). * * Example: * "test" ==> "test" * ^ ^ */ private TomlNode ReadValue(bool skipNewlines = false) { int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (TomlSyntax.IsWhiteSpace(c)) { ConsumeChar(); continue; } if (c == TomlSyntax.COMMENT_SYMBOL) { AddError("No value found!"); return null; } if (TomlSyntax.IsNewLine(c)) { if (skipNewlines) { reader.Read(); AdvanceLine(1); continue; } AddError("Encountered a newline when expecting a value!"); return null; } if (TomlSyntax.IsQuoted(c)) { var isMultiline = IsTripleQuote(c, out var excess); // Error occurred in triple quote parsing if (currentState == ParseState.None) return null; var value = isMultiline ? ReadQuotedValueMultiLine(c) : ReadQuotedValueSingleLine(c, excess); if (value is null) return null; return new TomlString { Value = value, IsMultiline = isMultiline, PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL }; } return c switch { TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), var _ => ReadTomlValue() }; } return null; } /** * Reads a single key name. * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`). * Consumes all the characters until the `until` character is met (but does not consume the character itself). * * Example 1: * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`) * ^ ^ * * Example 2: * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`) * ^ ^ */ private bool ReadKeyName(ref List<string> parts, char until) { var buffer = new StringBuilder(); var quoted = false; var prevWasSpace = false; int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; // Reached the final character if (c == until) break; if (TomlSyntax.IsWhiteSpace(c)) { prevWasSpace = true; goto consume_character; } if (buffer.Length == 0) prevWasSpace = false; if (c == TomlSyntax.SUBKEY_SEPARATOR) { if (buffer.Length == 0 && !quoted) return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); parts.Add(buffer.ToString()); buffer.Length = 0; quoted = false; prevWasSpace = false; goto consume_character; } if (prevWasSpace) return AddError("Invalid spacing in key name"); if (TomlSyntax.IsQuoted(c)) { if (quoted) return AddError("Expected a subkey separator but got extra data instead!"); if (buffer.Length != 0) return AddError("Encountered a quote in the middle of subkey name!"); // Consume the quote character and read the key name col++; buffer.Append(ReadQuotedValueSingleLine((char)reader.Read())); quoted = true; continue; } if (TomlSyntax.IsBareKey(c)) { buffer.Append(c); goto consume_character; } // If we see an invalid symbol, let the next parser handle it break; consume_character: reader.Read(); col++; } if (buffer.Length == 0 && !quoted) return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); parts.Add(buffer.ToString()); return true; } #endregion #region Non-string value parsing /** * Reads the whole raw value until the first non-value character is encountered. * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value. * Example: * * 1_0_0_0 ==> 1_0_0_0 * ^ ^ */ private string ReadRawValue() { var result = new StringBuilder(); int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; result.Append(c); ConsumeChar(); } // Replace trim with manual space counting? return result.ToString().Trim(); } /** * Reads and parses a non-string, non-composite TOML value. * Assumes the cursor at the first character that is related to the value (with possible spaces). * Consumes all the characters that are related to the value. * * Example * 1_0_0_0 # This is a comment * <newline> * ==> 1_0_0_0 # This is a comment * ^ ^ */ private TomlNode ReadTomlValue() { var value = ReadRawValue(); TomlNode node = value switch { var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), var v when TomlSyntax.IsNaN(v) => double.NaN, var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), CultureInfo.InvariantCulture), var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), CultureInfo.InvariantCulture), var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger { Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), IntegerBase = (TomlInteger.Base)numberBase }, var _ => null }; if (node != null) return node; // Normalize by removing space separator value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator); if (StringUtils.TryParseDateTime<DateTime>(value, TomlSyntax.RFC3339LocalDateTimeFormats, DateTimeStyles.AssumeLocal, DateTime.TryParseExact, out var dateTimeResult, out var precision)) return new TomlDateTimeLocal { Value = dateTimeResult, SecondsPrecision = precision }; if (DateTime.TryParseExact(value, TomlSyntax.LocalDateFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dateTimeResult)) return new TomlDateTimeLocal { Value = dateTimeResult, Style = TomlDateTimeLocal.DateTimeStyle.Date }; if (StringUtils.TryParseDateTime(value, TomlSyntax.RFC3339LocalTimeFormats, DateTimeStyles.AssumeLocal, DateTime.TryParseExact, out dateTimeResult, out precision)) return new TomlDateTimeLocal { Value = dateTimeResult, Style = TomlDateTimeLocal.DateTimeStyle.Time, SecondsPrecision = precision }; if (StringUtils.TryParseDateTime<DateTimeOffset>(value, TomlSyntax.RFC3339Formats, DateTimeStyles.None, DateTimeOffset.TryParseExact, out var dateTimeOffsetResult, out precision)) return new TomlDateTimeOffset { Value = dateTimeOffsetResult, SecondsPrecision = precision }; AddError($"Value \"{value}\" is not a valid TOML value!"); return null; } /** * Reads an array value. * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket. * * Example: * [1, 2, 3] ==> [1, 2, 3] * ^ ^ */ private TomlArray ReadArray() { // Consume the start of array character ConsumeChar(); var result = new TomlArray(); TomlNode currentValue = null; var expectValue = true; int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (c == TomlSyntax.ARRAY_END_SYMBOL) { ConsumeChar(); break; } if (c == TomlSyntax.COMMENT_SYMBOL) { reader.ReadLine(); AdvanceLine(1); continue; } if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c)) { if (TomlSyntax.IsLineBreak(c)) AdvanceLine(); goto consume_character; } if (c == TomlSyntax.ITEM_SEPARATOR) { if (currentValue == null) { AddError("Encountered multiple value separators"); return null; } result.Add(currentValue); currentValue = null; expectValue = true; goto consume_character; } if (!expectValue) { AddError("Missing separator between values"); return null; } currentValue = ReadValue(true); if (currentValue == null) { if (currentState != ParseState.None) AddError("Failed to determine and parse a value!"); return null; } expectValue = false; continue; consume_character: ConsumeChar(); } if (currentValue != null) result.Add(currentValue); return result; } /** * Reads an inline table. * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket. * * Example: * { test = "foo", value = 1 } ==> { test = "foo", value = 1 } * ^ ^ */ private TomlNode ReadInlineTable() { ConsumeChar(); var result = new TomlTable { IsInline = true }; TomlNode currentValue = null; var separator = false; var keyParts = new List<string>(); int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) { ConsumeChar(); break; } if (c == TomlSyntax.COMMENT_SYMBOL) { AddError("Incomplete inline table definition!"); return null; } if (TomlSyntax.IsNewLine(c)) { AddError("Inline tables are only allowed to be on single line"); return null; } if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; if (c == TomlSyntax.ITEM_SEPARATOR) { if (currentValue == null) { AddError("Encountered multiple value separators in inline table!"); return null; } if (!InsertNode(currentValue, result, keyParts)) return null; keyParts.Clear(); currentValue = null; separator = true; goto consume_character; } separator = false; currentValue = ReadKeyValuePair(keyParts); continue; consume_character: ConsumeChar(); } if (separator) { AddError("Trailing commas are not allowed in inline tables."); return null; } if (currentValue != null && !InsertNode(currentValue, result, keyParts)) return null; return result; } #endregion #region String parsing /** * Checks if the string value a multiline string (i.e. a triple quoted string). * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline. * * If the result is false, returns the consumed character through the `excess` variable. * * Example 1: * """test""" ==> """test""" * ^ ^ * * Example 2: * "test" ==> "test" (doesn't return the first quote) * ^ ^ * * Example 3: * "" ==> "" (returns the extra `"` through the `excess` variable) * ^ ^ */ private bool IsTripleQuote(char quote, out char excess) { // Copypasta, but it's faster... int cur; // Consume the first quote ConsumeChar(); if ((cur = reader.Peek()) < 0) { excess = '\0'; return AddError("Unexpected end of file!"); } if ((char)cur != quote) { excess = '\0'; return false; } // Consume the second quote excess = (char)ConsumeChar(); if ((cur = reader.Peek()) < 0 || (char)cur != quote) return false; // Consume the final quote ConsumeChar(); excess = '\0'; return true; } /** * A convenience method to process a single character within a quote. */ private bool ProcessQuotedValueCharacter(char quote, bool isNonLiteral, char c, StringBuilder sb, ref bool escaped) { if (TomlSyntax.MustBeEscaped(c)) return AddError($"The character U+{(int)c:X8} must be escaped in a string!"); if (escaped) { sb.Append(c); escaped = false; return false; } if (c == quote) { if (!isNonLiteral && reader.Peek() == quote) { reader.Read(); col++; sb.Append(quote); return false; } return true; } if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) escaped = true; if (c == TomlSyntax.NEWLINE_CHARACTER) return AddError("Encountered newline in single line string!"); sb.Append(c); return false; } /** * Reads a single-line string. * Assumes the cursor is at the first character that belongs to the string. * Consumes all characters that belong to the string (including the closing quote). * * Example: * "test" ==> "test" * ^ ^ */ private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') { var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL; var sb = new StringBuilder(); var escaped = false; if (initialData != '\0') { var shouldReturn = ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped); if (currentState == ParseState.None) return null; if (shouldReturn) if (isNonLiteral) { if (sb.ToString().TryUnescape(out var res, out var ex)) return res; AddError(ex.Message); return null; } else return sb.ToString(); } int cur; var readDone = false; while ((cur = reader.Read()) >= 0) { // Consume the character col++; var c = (char)cur; readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); if (readDone) { if (currentState == ParseState.None) return null; break; } } if (!readDone) { AddError("Unclosed string."); return null; } if (!isNonLiteral) return sb.ToString(); if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped; AddError(unescapedEx.Message); return null; } /** * Reads a multiline string. * Assumes the cursor is at the first character that belongs to the string. * Consumes all characters that belong to the string and the three closing quotes. * * Example: * """test""" ==> """test""" * ^ ^ */ private string ReadQuotedValueMultiLine(char quote) { var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL; var sb = new StringBuilder(); var escaped = false; var skipWhitespace = false; var skipWhitespaceLineSkipped = false; var quotesEncountered = 0; var first = true; int cur; while ((cur = ConsumeChar()) >= 0) { var c = (char)cur; if (TomlSyntax.MustBeEscaped(c, true)) { AddError($"The character U+{(int)c:X8} must be escaped!"); return null; } // Trim the first newline if (first && TomlSyntax.IsNewLine(c)) { if (TomlSyntax.IsLineBreak(c)) first = false; else AdvanceLine(); continue; } first = false; //TODO: Reuse ProcessQuotedValueCharacter // Skip the current character if it is going to be escaped later if (escaped) { sb.Append(c); escaped = false; continue; } // If we are currently skipping empty spaces, skip if (skipWhitespace) { if (TomlSyntax.IsEmptySpace(c)) { if (TomlSyntax.IsLineBreak(c)) { skipWhitespaceLineSkipped = true; AdvanceLine(); } continue; } if (!skipWhitespaceLineSkipped) { AddError("Non-whitespace character after trim marker."); return null; } skipWhitespaceLineSkipped = false; skipWhitespace = false; } // If we encounter an escape sequence... if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) { var next = reader.Peek(); var nc = (char)next; if (next >= 0) { // ...and the next char is empty space, we must skip all whitespaces if (TomlSyntax.IsEmptySpace(nc)) { skipWhitespace = true; continue; } // ...and we have \" or \, skip the character if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true; } } // Count the consecutive quotes if (c == quote) quotesEncountered++; else quotesEncountered = 0; // If the are three quotes, count them as closing quotes if (quotesEncountered == 3) break; sb.Append(c); } // TOML actually allows to have five ending quotes like // """"" => "" belong to the string + """ is the actual ending quotesEncountered = 0; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (c == quote && ++quotesEncountered < 3) { sb.Append(c); ConsumeChar(); } else break; } // Remove last two quotes (third one wasn't included by default) sb.Length -= 2; if (!isBasic) return sb.ToString(); if (sb.ToString().TryUnescape(out var res, out var ex)) return res; AddError(ex.Message); return null; } #endregion #region Node creation private bool InsertNode(TomlNode node, TomlNode root, IList<string> path) { var latestNode = root; if (path.Count > 1) for (var index = 0; index < path.Count - 1; index++) { var subkey = path[index]; if (latestNode.TryGetNode(subkey, out var currentNode)) { if (currentNode.HasValue) return AddError($"The key {".".Join(path)} already has a value assigned to it!"); } else { currentNode = new TomlTable(); latestNode[subkey] = currentNode; } latestNode = currentNode; if (latestNode is TomlTable { IsInline: true }) return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table."); } if (latestNode.HasKey(path[path.Count - 1])) return AddError($"The key {".".Join(path)} is already defined!"); latestNode[path[path.Count - 1]] = node; node.CollapseLevel = path.Count - 1; return true; } private TomlTable CreateTable(TomlNode root, IList<string> path, bool arrayTable) { if (path.Count == 0) return null; var latestNode = root; for (var index = 0; index < path.Count; index++) { var subkey = path[index]; if (latestNode.TryGetNode(subkey, out var node)) { if (node.IsArray && arrayTable) { var arr = (TomlArray)node; if (!arr.IsTableArray) { AddError($"The array {".".Join(path)} cannot be redefined as an array table!"); return null; } if (index == path.Count - 1) { latestNode = new TomlTable(); arr.Add(latestNode); break; } latestNode = arr[arr.ChildrenCount - 1]; continue; } if (node is TomlTable { IsInline: true }) { AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); return null; } if (node.HasValue) { if (!(node is TomlArray { IsTableArray: true } array)) { AddError($"The key {".".Join(path)} has a value assigned to it!"); return null; } latestNode = array[array.ChildrenCount - 1]; continue; } if (index == path.Count - 1) { if (arrayTable && !node.IsArray) { AddError($"The table {".".Join(path)} cannot be redefined as an array table!"); return null; } if (node is TomlTable { isImplicit: false }) { AddError($"The table {".".Join(path)} is defined multiple times!"); return null; } } } else { if (index == path.Count - 1 && arrayTable) { var table = new TomlTable(); var arr = new TomlArray { IsTableArray = true }; arr.Add(table); latestNode[subkey] = arr; latestNode = table; break; } node = new TomlTable { isImplicit = true }; latestNode[subkey] = node; } latestNode = node; } var result = (TomlTable)latestNode; result.isImplicit = false; return result; } #endregion #region Misc parsing private string ParseComment() { ConsumeChar(); var commentLine = reader.ReadLine()?.Trim() ?? ""; if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch))) AddError("Comment must not contain control characters other than tab.", false); return commentLine; } #endregion } #endregion public static class TOML { public static bool ForceASCII { get; set; } = false; public static TomlTable Parse(TextReader reader) { using var parser = new TOMLParser(reader) { ForceASCII = ForceASCII }; return parser.Parse(); } } #region Exception Types public class TomlFormatException : Exception { public TomlFormatException(string message) : base(message) { } } public class TomlParseException : Exception { public TomlParseException(TomlTable parsed, IEnumerable<TomlSyntaxException> exceptions) : base("TOML file contains format errors") { ParsedTable = parsed; SyntaxErrors = exceptions; } public TomlTable ParsedTable { get; } public IEnumerable<TomlSyntaxException> SyntaxErrors { get; } } public class TomlSyntaxException : Exception { public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message) { ParseState = state; Line = line; Column = col; } public TOMLParser.ParseState ParseState { get; } public int Line { get; } public int Column { get; } } #endregion #region Parse utilities internal static class TomlSyntax { #region Type Patterns public const string TRUE_VALUE = "true"; public const string FALSE_VALUE = "false"; public const string NAN_VALUE = "nan"; public const string POS_NAN_VALUE = "+nan"; public const string NEG_NAN_VALUE = "-nan"; public const string INF_VALUE = "inf"; public const string POS_INF_VALUE = "+inf"; public const string NEG_INF_VALUE = "-inf"; public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE; public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE; public static bool IsNegInf(string s) => s == NEG_INF_VALUE; public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE; public static bool IsInteger(string s) => IntegerPattern.IsMatch(s); public static bool IsFloat(string s) => FloatPattern.IsMatch(s); public static bool IsIntegerWithBase(string s, out int numberBase) { numberBase = 10; var match = BasedIntegerPattern.Match(s); if (!match.Success) return false; IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase); return true; } /** * A pattern to verify the integer value according to the TOML specification. */ public static readonly Regex IntegerPattern = new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled); /** * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification. */ public static readonly Regex BasedIntegerPattern = new(@"^0(?<base>x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /** * A pattern to verify the float value according to the TOML specification. */ public static readonly Regex FloatPattern = new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /** * A helper dictionary to map TOML base codes into the radii. */ public static readonly Dictionary<string, int> IntegerBases = new() { ["x"] = 16, ["o"] = 8, ["b"] = 2 }; /** * A helper dictionary to map non-decimal bases to their TOML identifiers */ public static readonly Dictionary<int, string> BaseIdentifiers = new() { [2] = "b", [8] = "o", [16] = "x" }; public const string RFC3339EmptySeparator = " "; public const string ISO861Separator = "T"; public const string ISO861ZeroZone = "+00:00"; public const string RFC3339ZeroZone = "Z"; /** * Valid date formats with timezone as per RFC3339. */ public static readonly string[] RFC3339Formats = { "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK" }; /** * Valid date formats without timezone (assumes local) as per RFC3339. */ public static readonly string[] RFC3339LocalDateTimeFormats = { "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff", "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff" }; /** * Valid full date format as per TOML spec. */ public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd"; /** * Valid time formats as per TOML spec. */ public static readonly string[] RFC3339LocalTimeFormats = { "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff", "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff" }; #endregion #region Character definitions public const char ARRAY_END_SYMBOL = ']'; public const char ITEM_SEPARATOR = ','; public const char ARRAY_START_SYMBOL = '['; public const char BASIC_STRING_SYMBOL = '\"'; public const char COMMENT_SYMBOL = '#'; public const char ESCAPE_SYMBOL = '\\'; public const char KEY_VALUE_SEPARATOR = '='; public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r'; public const char NEWLINE_CHARACTER = '\n'; public const char SUBKEY_SEPARATOR = '.'; public const char TABLE_END_SYMBOL = ']'; public const char TABLE_START_SYMBOL = '['; public const char INLINE_TABLE_START_SYMBOL = '{'; public const char INLINE_TABLE_END_SYMBOL = '}'; public const char LITERAL_STRING_SYMBOL = '\''; public const char INT_NUMBER_SEPARATOR = '_'; public static readonly char[] NewLineCharacters = { NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER }; public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; public static bool IsWhiteSpace(char c) => c is ' ' or '\t'; public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER; public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER; public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c); public static bool IsBareKey(char c) => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'; public static bool MustBeEscaped(char c, bool allowNewLines = false) { var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f'; if (!allowNewLines) result |= c is >= '\u000a' and <= '\u000e'; return result; } public static bool IsValueSeparator(char c) => c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL; #endregion } internal static class StringUtils { public static string AsKey(this string key) { var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c)); return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}"; } public static string Join(this string self, IEnumerable<string> subItems) { var sb = new StringBuilder(); var first = true; foreach (var subItem in subItems) { if (!first) sb.Append(self); first = false; sb.Append(subItem); } return sb.ToString(); } public delegate bool TryDateParseDelegate<T>(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); public static bool TryParseDateTime<T>(string s, string[] formats, DateTimeStyles styles, TryDateParseDelegate<T> parser, out T dateTime, out int parsedFormat) { parsedFormat = 0; dateTime = default; for (var i = 0; i < formats.Length; i++) { var format = formats[i]; if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue; parsedFormat = i; return true; } return false; } public static void AsComment(this string self, TextWriter tw) { foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER)) tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}"); } public static string RemoveAll(this string txt, char toRemove) { var sb = new StringBuilder(txt.Length); foreach (var c in txt.Where(c => c != toRemove)) sb.Append(c); return sb.ToString(); } public static string Escape(this string txt, bool escapeNewlines = true) { var stringBuilder = new StringBuilder(txt.Length + 2); for (var i = 0; i < txt.Length; i++) { var c = txt[i]; static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" : $"\\u{(ushort)c:X4}"; stringBuilder.Append(c switch { '\b' => @"\b", '\t' => @"\t", '\n' when escapeNewlines => @"\n", '\f' => @"\f", '\r' when escapeNewlines => @"\r", '\\' => @"\\", '\"' => @"\""", var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => CodePoint(txt, ref i, c), var _ => c }); } return stringBuilder.ToString(); } public static bool TryUnescape(this string txt, out string unescaped, out Exception exception) { try { exception = null; unescaped = txt.Unescape(); return true; } catch (Exception e) { exception = e; unescaped = null; return false; } } public static string Unescape(this string txt) { if (string.IsNullOrEmpty(txt)) return txt; var stringBuilder = new StringBuilder(txt.Length); for (var i = 0; i < txt.Length;) { var num = txt.IndexOf('\\', i); var next = num + 1; if (num < 0 || num == txt.Length - 1) num = txt.Length; stringBuilder.Append(txt, i, num - i); if (num >= txt.Length) break; var c = txt[next]; static string CodePoint(int next, string txt, ref int num, int size) { if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!"); num += size; return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16)); } stringBuilder.Append(c switch { 'b' => "\b", 't' => "\t", 'n' => "\n", 'f' => "\f", 'r' => "\r", '\'' => "\'", '\"' => "\"", '\\' => "\\", 'u' => CodePoint(next, txt, ref num, 4), 'U' => CodePoint(next, txt, ref num, 8), var _ => throw new Exception("Undefined escape sequence!") }); i = num + 2; } return stringBuilder.ToString(); } } #endregion } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/External/Tommy.cs: -------------------------------------------------------------------------------- ```csharp #region LICENSE /* * MIT License * * Copyright (c) 2020 Denis Zhidkikh * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ #endregion using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace MCPForUnity.External.Tommy { #region TOML Nodes public abstract class TomlNode : IEnumerable { public virtual bool HasValue { get; } = false; public virtual bool IsArray { get; } = false; public virtual bool IsTable { get; } = false; public virtual bool IsString { get; } = false; public virtual bool IsInteger { get; } = false; public virtual bool IsFloat { get; } = false; public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset; public virtual bool IsDateTimeLocal { get; } = false; public virtual bool IsDateTimeOffset { get; } = false; public virtual bool IsBoolean { get; } = false; public virtual string Comment { get; set; } public virtual int CollapseLevel { get; set; } public virtual TomlTable AsTable => this as TomlTable; public virtual TomlString AsString => this as TomlString; public virtual TomlInteger AsInteger => this as TomlInteger; public virtual TomlFloat AsFloat => this as TomlFloat; public virtual TomlBoolean AsBoolean => this as TomlBoolean; public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal; public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset; public virtual TomlDateTime AsDateTime => this as TomlDateTime; public virtual TomlArray AsArray => this as TomlArray; public virtual int ChildrenCount => 0; public virtual TomlNode this[string key] { get => null; set { } } public virtual TomlNode this[int index] { get => null; set { } } public virtual IEnumerable<TomlNode> Children { get { yield break; } } public virtual IEnumerable<string> Keys { get { yield break; } } public IEnumerator GetEnumerator() => Children.GetEnumerator(); public virtual bool TryGetNode(string key, out TomlNode node) { node = null; return false; } public virtual bool HasKey(string key) => false; public virtual bool HasItemAt(int index) => false; public virtual void Add(string key, TomlNode node) { } public virtual void Add(TomlNode node) { } public virtual void Delete(TomlNode node) { } public virtual void Delete(string key) { } public virtual void Delete(int index) { } public virtual void AddRange(IEnumerable<TomlNode> nodes) { foreach (var tomlNode in nodes) Add(tomlNode); } public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml()); public virtual string ToInlineToml() => ToString(); #region Native type to TOML cast public static implicit operator TomlNode(string value) => new TomlString { Value = value }; public static implicit operator TomlNode(bool value) => new TomlBoolean { Value = value }; public static implicit operator TomlNode(long value) => new TomlInteger { Value = value }; public static implicit operator TomlNode(float value) => new TomlFloat { Value = value }; public static implicit operator TomlNode(double value) => new TomlFloat { Value = value }; public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal { Value = value }; public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset { Value = value }; public static implicit operator TomlNode(TomlNode[] nodes) { var result = new TomlArray(); result.AddRange(nodes); return result; } #endregion #region TOML to native type cast public static implicit operator string(TomlNode value) => value.ToString(); public static implicit operator int(TomlNode value) => (int)value.AsInteger.Value; public static implicit operator long(TomlNode value) => value.AsInteger.Value; public static implicit operator float(TomlNode value) => (float)value.AsFloat.Value; public static implicit operator double(TomlNode value) => value.AsFloat.Value; public static implicit operator bool(TomlNode value) => value.AsBoolean.Value; public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value; public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value; #endregion } public class TomlString : TomlNode { public override bool HasValue { get; } = true; public override bool IsString { get; } = true; public bool IsMultiline { get; set; } public bool MultilineTrimFirstLine { get; set; } public bool PreferLiteral { get; set; } public string Value { get; set; } public override string ToString() => Value; public override string ToInlineToml() { // Automatically convert literal to non-literal if there are too many literal string symbols if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false; var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL, IsMultiline ? 3 : 1); var result = PreferLiteral ? Value : Value.Escape(!IsMultiline); if (IsMultiline) result = result.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(Environment.NewLine))) result = $"{Environment.NewLine}{result}"; return $"{quotes}{result}{quotes}"; } } public class TomlInteger : TomlNode { public enum Base { Binary = 2, Octal = 8, Decimal = 10, Hexadecimal = 16 } public override bool IsInteger { get; } = true; public override bool HasValue { get; } = true; public Base IntegerBase { get; set; } = Base.Decimal; public long Value { get; set; } public override string ToString() => Value.ToString(); public override string ToInlineToml() => IntegerBase != Base.Decimal ? $"0{TomlSyntax.BaseIdentifiers[(int)IntegerBase]}{Convert.ToString(Value, (int)IntegerBase)}" : Value.ToString(CultureInfo.InvariantCulture); } public class TomlFloat : TomlNode, IFormattable { public override bool IsFloat { get; } = true; public override bool HasValue { get; } = true; public double Value { get; set; } public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); public override string ToInlineToml() => Value switch { var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() }; } public class TomlBoolean : TomlNode { public override bool IsBoolean { get; } = true; public override bool HasValue { get; } = true; public bool Value { get; set; } public override string ToString() => Value.ToString(); public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE; } public class TomlDateTime : TomlNode, IFormattable { public int SecondsPrecision { get; set; } public override bool HasValue { get; } = true; public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty; public virtual string ToString(IFormatProvider formatProvider) => string.Empty; protected virtual string ToInlineTomlInternal() => string.Empty; public override string ToInlineToml() => ToInlineTomlInternal() .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator) .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone); } public class TomlDateTimeOffset : TomlDateTime { public override bool IsDateTimeOffset { get; } = true; public DateTimeOffset Value { get; set; } public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); public override string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]); } public class TomlDateTimeLocal : TomlDateTime { public enum DateTimeStyle { Date, Time, DateTime } public override bool IsDateTimeLocal { get; } = true; public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; public DateTime Value { get; set; } public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); public override string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); public override string ToInlineToml() => Style switch { DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) }; } public class TomlArray : TomlNode { private List<TomlNode> values; public override bool HasValue { get; } = true; public override bool IsArray { get; } = true; public bool IsMultiline { get; set; } public bool IsTableArray { get; set; } public List<TomlNode> RawArray => values ??= new List<TomlNode>(); public override TomlNode this[int index] { get { if (index < RawArray.Count) return RawArray[index]; var lazy = new TomlLazy(this); this[index] = lazy; return lazy; } set { if (index == RawArray.Count) RawArray.Add(value); else RawArray[index] = value; } } public override int ChildrenCount => RawArray.Count; public override IEnumerable<TomlNode> Children => RawArray.AsEnumerable(); public override void Add(TomlNode node) => RawArray.Add(node); public override void AddRange(IEnumerable<TomlNode> nodes) => RawArray.AddRange(nodes); public override void Delete(TomlNode node) => RawArray.Remove(node); public override void Delete(int index) => RawArray.RemoveAt(index); public override string ToString() => ToString(false); public string ToString(bool multiline) { var sb = new StringBuilder(); sb.Append(TomlSyntax.ARRAY_START_SYMBOL); if (ChildrenCount != 0) { var arrayStart = multiline ? $"{Environment.NewLine} " : " "; var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{Environment.NewLine} " : $"{TomlSyntax.ITEM_SEPARATOR} "; var arrayEnd = multiline ? Environment.NewLine : " "; sb.Append(arrayStart) .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml()))) .Append(arrayEnd); } sb.Append(TomlSyntax.ARRAY_END_SYMBOL); return sb.ToString(); } public override void WriteTo(TextWriter tw, string name = null) { // If it's a normal array, write it as usual if (!IsTableArray) { tw.WriteLine(ToString(IsMultiline)); return; } if (!(Comment is null)) { tw.WriteLine(); Comment.AsComment(tw); } tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(name); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.WriteLine(); var first = true; foreach (var tomlNode in RawArray) { if (!(tomlNode is TomlTable tbl)) throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); // Ensure it's parsed as a section tbl.IsInline = false; if (!first) { tw.WriteLine(); Comment?.AsComment(tw); tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(name); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.WriteLine(); } first = false; // Don't write section since it's already written here tbl.WriteTo(tw, name, false); } } } public class TomlTable : TomlNode { private Dictionary<string, TomlNode> children; internal bool isImplicit; public override bool HasValue { get; } = false; public override bool IsTable { get; } = true; public bool IsInline { get; set; } public Dictionary<string, TomlNode> RawTable => children ??= new Dictionary<string, TomlNode>(); public override TomlNode this[string key] { get { if (RawTable.TryGetValue(key, out var result)) return result; var lazy = new TomlLazy(this); RawTable[key] = lazy; return lazy; } set => RawTable[key] = value; } public override int ChildrenCount => RawTable.Count; public override IEnumerable<TomlNode> Children => RawTable.Select(kv => kv.Value); public override IEnumerable<string> Keys => RawTable.Select(kv => kv.Key); public override bool HasKey(string key) => RawTable.ContainsKey(key); public override void Add(string key, TomlNode node) => RawTable.Add(key, node); public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node); public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key); public override void Delete(string key) => RawTable.Remove(key); public override string ToString() { var sb = new StringBuilder(); sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL); if (ChildrenCount != 0) { var collapsed = CollectCollapsedItems(normalizeOrder: false); if (collapsed.Count != 0) sb.Append(' ') .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n => $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}"))); sb.Append(' '); } sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL); return sb.ToString(); } private LinkedList<KeyValuePair<string, TomlNode>> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true) { var nodes = new LinkedList<KeyValuePair<string, TomlNode>>(); var postNodes = normalizeOrder ? new LinkedList<KeyValuePair<string, TomlNode>>() : nodes; foreach (var keyValuePair in RawTable) { var node = keyValuePair.Value; var key = keyValuePair.Key.AsKey(); if (node is TomlTable tbl) { var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); // Write main table first before writing collapsed items if (subnodes.Count == 0 && node.CollapseLevel == level) { postNodes.AddLast(new KeyValuePair<string, TomlNode>($"{prefix}{key}", node)); } foreach (var kv in subnodes) postNodes.AddLast(kv); } else if (node.CollapseLevel == level) nodes.AddLast(new KeyValuePair<string, TomlNode>($"{prefix}{key}", node)); } if (normalizeOrder) foreach (var kv in postNodes) nodes.AddLast(kv); return nodes; } public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true); internal void WriteTo(TextWriter tw, string name, bool writeSectionName) { // The table is inline table if (IsInline && name != null) { tw.WriteLine(ToInlineToml()); return; } var collapsedItems = CollectCollapsedItems(); if (collapsedItems.Count == 0) return; var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable { IsInline: false } or TomlArray { IsTableArray: true }); Comment?.AsComment(tw); if (name != null && (hasRealValues || Comment != null) && writeSectionName) { tw.Write(TomlSyntax.ARRAY_START_SYMBOL); tw.Write(name); tw.Write(TomlSyntax.ARRAY_END_SYMBOL); tw.WriteLine(); } else if (Comment != null) // Add some spacing between the first node and the comment { tw.WriteLine(); } var namePrefix = name == null ? "" : $"{name}."; var first = true; foreach (var collapsedItem in collapsedItems) { var key = collapsedItem.Key; if (collapsedItem.Value is TomlArray { IsTableArray: true } or TomlTable { IsInline: false }) { if (!first) tw.WriteLine(); first = false; collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); continue; } first = false; collapsedItem.Value.Comment?.AsComment(tw); tw.Write(key); tw.Write(' '); tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); tw.Write(' '); collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); } } } internal class TomlLazy : TomlNode { private readonly TomlNode parent; private TomlNode replacement; public TomlLazy(TomlNode parent) => this.parent = parent; public override TomlNode this[int index] { get => Set<TomlArray>()[index]; set => Set<TomlArray>()[index] = value; } public override TomlNode this[string key] { get => Set<TomlTable>()[key]; set => Set<TomlTable>()[key] = value; } public override void Add(TomlNode node) => Set<TomlArray>().Add(node); public override void Add(string key, TomlNode node) => Set<TomlTable>().Add(key, node); public override void AddRange(IEnumerable<TomlNode> nodes) => Set<TomlArray>().AddRange(nodes); private TomlNode Set<T>() where T : TomlNode, new() { if (replacement != null) return replacement; var newNode = new T { Comment = Comment }; if (parent.IsTable) { var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this)); if (key == null) return default(T); parent[key] = newNode; } else if (parent.IsArray) { var index = parent.Children.TakeWhile(child => child != this).Count(); if (index == parent.ChildrenCount) return default(T); parent[index] = newNode; } else { return default(T); } replacement = newNode; return newNode; } } #endregion #region Parser public class TOMLParser : IDisposable { public enum ParseState { None, KeyValuePair, SkipToNextLine, Table } private readonly TextReader reader; private ParseState currentState; private int line, col; private List<TomlSyntaxException> syntaxErrors; public TOMLParser(TextReader reader) { this.reader = reader; line = col = 0; } public bool ForceASCII { get; set; } public void Dispose() => reader?.Dispose(); public TomlTable Parse() { syntaxErrors = new List<TomlSyntaxException>(); line = col = 1; var rootNode = new TomlTable(); var currentNode = rootNode; currentState = ParseState.None; var keyParts = new List<string>(); var arrayTable = false; StringBuilder latestComment = null; var firstComment = true; int currentChar; while ((currentChar = reader.Peek()) >= 0) { var c = (char)currentChar; if (currentState == ParseState.None) { // Skip white space if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; if (TomlSyntax.IsNewLine(c)) { // Check if there are any comments and so far no items being declared if (latestComment != null && firstComment) { rootNode.Comment = latestComment.ToString().TrimEnd(); latestComment = null; firstComment = false; } if (TomlSyntax.IsLineBreak(c)) AdvanceLine(); goto consume_character; } // Start of a comment; ignore until newline if (c == TomlSyntax.COMMENT_SYMBOL) { latestComment ??= new StringBuilder(); latestComment.AppendLine(ParseComment()); AdvanceLine(1); continue; } // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)! firstComment = false; if (c == TomlSyntax.TABLE_START_SYMBOL) { currentState = ParseState.Table; goto consume_character; } if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c)) { currentState = ParseState.KeyValuePair; } else { AddError($"Unexpected character \"{c}\""); continue; } } if (currentState == ParseState.KeyValuePair) { var keyValuePair = ReadKeyValuePair(keyParts); if (keyValuePair == null) { latestComment = null; keyParts.Clear(); if (currentState != ParseState.None) AddError("Failed to parse key-value pair!"); continue; } keyValuePair.Comment = latestComment?.ToString()?.TrimEnd(); var inserted = InsertNode(keyValuePair, currentNode, keyParts); latestComment = null; keyParts.Clear(); if (inserted) currentState = ParseState.SkipToNextLine; continue; } if (currentState == ParseState.Table) { if (keyParts.Count == 0) { // We have array table if (c == TomlSyntax.TABLE_START_SYMBOL) { // Consume the character ConsumeChar(); arrayTable = true; } if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL)) { keyParts.Clear(); continue; } if (keyParts.Count == 0) { AddError("Table name is emtpy."); arrayTable = false; latestComment = null; keyParts.Clear(); } continue; } if (c == TomlSyntax.TABLE_END_SYMBOL) { if (arrayTable) { // Consume the ending bracket so we can peek the next character ConsumeChar(); var nextChar = reader.Peek(); if (nextChar < 0 || (char)nextChar != TomlSyntax.TABLE_END_SYMBOL) { AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); keyParts.Clear(); arrayTable = false; latestComment = null; continue; } } currentNode = CreateTable(rootNode, keyParts, arrayTable); if (currentNode != null) { currentNode.IsInline = false; currentNode.Comment = latestComment?.ToString()?.TrimEnd(); } keyParts.Clear(); arrayTable = false; latestComment = null; if (currentNode == null) { if (currentState != ParseState.None) AddError("Error creating table array!"); // Reset a node to root in order to try and continue parsing currentNode = rootNode; continue; } currentState = ParseState.SkipToNextLine; goto consume_character; } if (keyParts.Count != 0) { AddError($"Unexpected character \"{c}\""); keyParts.Clear(); arrayTable = false; latestComment = null; } } if (currentState == ParseState.SkipToNextLine) { if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER) goto consume_character; if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER) { currentState = ParseState.None; AdvanceLine(); if (c == TomlSyntax.COMMENT_SYMBOL) { col++; ParseComment(); continue; } goto consume_character; } AddError($"Unexpected character \"{c}\" at the end of the line."); } consume_character: reader.Read(); col++; } if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine) AddError("Unexpected end of file!"); if (syntaxErrors.Count > 0) throw new TomlParseException(rootNode, syntaxErrors); return rootNode; } private bool AddError(string message, bool skipLine = true) { syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col)); // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that) if (skipLine) { reader.ReadLine(); AdvanceLine(1); } currentState = ParseState.None; return false; } private void AdvanceLine(int startCol = 0) { line++; col = startCol; } private int ConsumeChar() { col++; return reader.Read(); } #region Key-Value pair parsing /** * Reads a single key-value pair. * Assumes the cursor is at the first character that belong to the pair (including possible whitespace). * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end). * * Example: * foo = "bar" ==> foo = "bar" * ^ ^ */ private TomlNode ReadKeyValuePair(List<string> keyParts) { int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) { if (keyParts.Count != 0) { AddError("Encountered extra characters in key definition!"); return null; } if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR)) return null; continue; } if (TomlSyntax.IsWhiteSpace(c)) { ConsumeChar(); continue; } if (c == TomlSyntax.KEY_VALUE_SEPARATOR) { ConsumeChar(); return ReadValue(); } AddError($"Unexpected character \"{c}\" in key name."); return null; } return null; } /** * Reads a single value. * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace). * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end). * * Example: * "test" ==> "test" * ^ ^ */ private TomlNode ReadValue(bool skipNewlines = false) { int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (TomlSyntax.IsWhiteSpace(c)) { ConsumeChar(); continue; } if (c == TomlSyntax.COMMENT_SYMBOL) { AddError("No value found!"); return null; } if (TomlSyntax.IsNewLine(c)) { if (skipNewlines) { reader.Read(); AdvanceLine(1); continue; } AddError("Encountered a newline when expecting a value!"); return null; } if (TomlSyntax.IsQuoted(c)) { var isMultiline = IsTripleQuote(c, out var excess); // Error occurred in triple quote parsing if (currentState == ParseState.None) return null; var value = isMultiline ? ReadQuotedValueMultiLine(c) : ReadQuotedValueSingleLine(c, excess); if (value is null) return null; return new TomlString { Value = value, IsMultiline = isMultiline, PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL }; } return c switch { TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), var _ => ReadTomlValue() }; } return null; } /** * Reads a single key name. * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`). * Consumes all the characters until the `until` character is met (but does not consume the character itself). * * Example 1: * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`) * ^ ^ * * Example 2: * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`) * ^ ^ */ private bool ReadKeyName(ref List<string> parts, char until) { var buffer = new StringBuilder(); var quoted = false; var prevWasSpace = false; int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; // Reached the final character if (c == until) break; if (TomlSyntax.IsWhiteSpace(c)) { prevWasSpace = true; goto consume_character; } if (buffer.Length == 0) prevWasSpace = false; if (c == TomlSyntax.SUBKEY_SEPARATOR) { if (buffer.Length == 0 && !quoted) return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); parts.Add(buffer.ToString()); buffer.Length = 0; quoted = false; prevWasSpace = false; goto consume_character; } if (prevWasSpace) return AddError("Invalid spacing in key name"); if (TomlSyntax.IsQuoted(c)) { if (quoted) return AddError("Expected a subkey separator but got extra data instead!"); if (buffer.Length != 0) return AddError("Encountered a quote in the middle of subkey name!"); // Consume the quote character and read the key name col++; buffer.Append(ReadQuotedValueSingleLine((char)reader.Read())); quoted = true; continue; } if (TomlSyntax.IsBareKey(c)) { buffer.Append(c); goto consume_character; } // If we see an invalid symbol, let the next parser handle it break; consume_character: reader.Read(); col++; } if (buffer.Length == 0 && !quoted) return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); parts.Add(buffer.ToString()); return true; } #endregion #region Non-string value parsing /** * Reads the whole raw value until the first non-value character is encountered. * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value. * Example: * * 1_0_0_0 ==> 1_0_0_0 * ^ ^ */ private string ReadRawValue() { var result = new StringBuilder(); int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; result.Append(c); ConsumeChar(); } // Replace trim with manual space counting? return result.ToString().Trim(); } /** * Reads and parses a non-string, non-composite TOML value. * Assumes the cursor at the first character that is related to the value (with possible spaces). * Consumes all the characters that are related to the value. * * Example * 1_0_0_0 # This is a comment * <newline> * ==> 1_0_0_0 # This is a comment * ^ ^ */ private TomlNode ReadTomlValue() { var value = ReadRawValue(); TomlNode node = value switch { var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), var v when TomlSyntax.IsNaN(v) => double.NaN, var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), CultureInfo.InvariantCulture), var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), CultureInfo.InvariantCulture), var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger { Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), IntegerBase = (TomlInteger.Base)numberBase }, var _ => null }; if (node != null) return node; // Normalize by removing space separator value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator); if (StringUtils.TryParseDateTime<DateTime>(value, TomlSyntax.RFC3339LocalDateTimeFormats, DateTimeStyles.AssumeLocal, DateTime.TryParseExact, out var dateTimeResult, out var precision)) return new TomlDateTimeLocal { Value = dateTimeResult, SecondsPrecision = precision }; if (DateTime.TryParseExact(value, TomlSyntax.LocalDateFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dateTimeResult)) return new TomlDateTimeLocal { Value = dateTimeResult, Style = TomlDateTimeLocal.DateTimeStyle.Date }; if (StringUtils.TryParseDateTime(value, TomlSyntax.RFC3339LocalTimeFormats, DateTimeStyles.AssumeLocal, DateTime.TryParseExact, out dateTimeResult, out precision)) return new TomlDateTimeLocal { Value = dateTimeResult, Style = TomlDateTimeLocal.DateTimeStyle.Time, SecondsPrecision = precision }; if (StringUtils.TryParseDateTime<DateTimeOffset>(value, TomlSyntax.RFC3339Formats, DateTimeStyles.None, DateTimeOffset.TryParseExact, out var dateTimeOffsetResult, out precision)) return new TomlDateTimeOffset { Value = dateTimeOffsetResult, SecondsPrecision = precision }; AddError($"Value \"{value}\" is not a valid TOML value!"); return null; } /** * Reads an array value. * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket. * * Example: * [1, 2, 3] ==> [1, 2, 3] * ^ ^ */ private TomlArray ReadArray() { // Consume the start of array character ConsumeChar(); var result = new TomlArray(); TomlNode currentValue = null; var expectValue = true; int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (c == TomlSyntax.ARRAY_END_SYMBOL) { ConsumeChar(); break; } if (c == TomlSyntax.COMMENT_SYMBOL) { reader.ReadLine(); AdvanceLine(1); continue; } if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c)) { if (TomlSyntax.IsLineBreak(c)) AdvanceLine(); goto consume_character; } if (c == TomlSyntax.ITEM_SEPARATOR) { if (currentValue == null) { AddError("Encountered multiple value separators"); return null; } result.Add(currentValue); currentValue = null; expectValue = true; goto consume_character; } if (!expectValue) { AddError("Missing separator between values"); return null; } currentValue = ReadValue(true); if (currentValue == null) { if (currentState != ParseState.None) AddError("Failed to determine and parse a value!"); return null; } expectValue = false; continue; consume_character: ConsumeChar(); } if (currentValue != null) result.Add(currentValue); return result; } /** * Reads an inline table. * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket. * * Example: * { test = "foo", value = 1 } ==> { test = "foo", value = 1 } * ^ ^ */ private TomlNode ReadInlineTable() { ConsumeChar(); var result = new TomlTable { IsInline = true }; TomlNode currentValue = null; var separator = false; var keyParts = new List<string>(); int cur; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) { ConsumeChar(); break; } if (c == TomlSyntax.COMMENT_SYMBOL) { AddError("Incomplete inline table definition!"); return null; } if (TomlSyntax.IsNewLine(c)) { AddError("Inline tables are only allowed to be on single line"); return null; } if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; if (c == TomlSyntax.ITEM_SEPARATOR) { if (currentValue == null) { AddError("Encountered multiple value separators in inline table!"); return null; } if (!InsertNode(currentValue, result, keyParts)) return null; keyParts.Clear(); currentValue = null; separator = true; goto consume_character; } separator = false; currentValue = ReadKeyValuePair(keyParts); continue; consume_character: ConsumeChar(); } if (separator) { AddError("Trailing commas are not allowed in inline tables."); return null; } if (currentValue != null && !InsertNode(currentValue, result, keyParts)) return null; return result; } #endregion #region String parsing /** * Checks if the string value a multiline string (i.e. a triple quoted string). * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline. * * If the result is false, returns the consumed character through the `excess` variable. * * Example 1: * """test""" ==> """test""" * ^ ^ * * Example 2: * "test" ==> "test" (doesn't return the first quote) * ^ ^ * * Example 3: * "" ==> "" (returns the extra `"` through the `excess` variable) * ^ ^ */ private bool IsTripleQuote(char quote, out char excess) { // Copypasta, but it's faster... int cur; // Consume the first quote ConsumeChar(); if ((cur = reader.Peek()) < 0) { excess = '\0'; return AddError("Unexpected end of file!"); } if ((char)cur != quote) { excess = '\0'; return false; } // Consume the second quote excess = (char)ConsumeChar(); if ((cur = reader.Peek()) < 0 || (char)cur != quote) return false; // Consume the final quote ConsumeChar(); excess = '\0'; return true; } /** * A convenience method to process a single character within a quote. */ private bool ProcessQuotedValueCharacter(char quote, bool isNonLiteral, char c, StringBuilder sb, ref bool escaped) { if (TomlSyntax.MustBeEscaped(c)) return AddError($"The character U+{(int)c:X8} must be escaped in a string!"); if (escaped) { sb.Append(c); escaped = false; return false; } if (c == quote) { if (!isNonLiteral && reader.Peek() == quote) { reader.Read(); col++; sb.Append(quote); return false; } return true; } if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) escaped = true; if (c == TomlSyntax.NEWLINE_CHARACTER) return AddError("Encountered newline in single line string!"); sb.Append(c); return false; } /** * Reads a single-line string. * Assumes the cursor is at the first character that belongs to the string. * Consumes all characters that belong to the string (including the closing quote). * * Example: * "test" ==> "test" * ^ ^ */ private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') { var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL; var sb = new StringBuilder(); var escaped = false; if (initialData != '\0') { var shouldReturn = ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped); if (currentState == ParseState.None) return null; if (shouldReturn) if (isNonLiteral) { if (sb.ToString().TryUnescape(out var res, out var ex)) return res; AddError(ex.Message); return null; } else return sb.ToString(); } int cur; var readDone = false; while ((cur = reader.Read()) >= 0) { // Consume the character col++; var c = (char)cur; readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); if (readDone) { if (currentState == ParseState.None) return null; break; } } if (!readDone) { AddError("Unclosed string."); return null; } if (!isNonLiteral) return sb.ToString(); if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped; AddError(unescapedEx.Message); return null; } /** * Reads a multiline string. * Assumes the cursor is at the first character that belongs to the string. * Consumes all characters that belong to the string and the three closing quotes. * * Example: * """test""" ==> """test""" * ^ ^ */ private string ReadQuotedValueMultiLine(char quote) { var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL; var sb = new StringBuilder(); var escaped = false; var skipWhitespace = false; var skipWhitespaceLineSkipped = false; var quotesEncountered = 0; var first = true; int cur; while ((cur = ConsumeChar()) >= 0) { var c = (char)cur; if (TomlSyntax.MustBeEscaped(c, true)) { AddError($"The character U+{(int)c:X8} must be escaped!"); return null; } // Trim the first newline if (first && TomlSyntax.IsNewLine(c)) { if (TomlSyntax.IsLineBreak(c)) first = false; else AdvanceLine(); continue; } first = false; //TODO: Reuse ProcessQuotedValueCharacter // Skip the current character if it is going to be escaped later if (escaped) { sb.Append(c); escaped = false; continue; } // If we are currently skipping empty spaces, skip if (skipWhitespace) { if (TomlSyntax.IsEmptySpace(c)) { if (TomlSyntax.IsLineBreak(c)) { skipWhitespaceLineSkipped = true; AdvanceLine(); } continue; } if (!skipWhitespaceLineSkipped) { AddError("Non-whitespace character after trim marker."); return null; } skipWhitespaceLineSkipped = false; skipWhitespace = false; } // If we encounter an escape sequence... if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) { var next = reader.Peek(); var nc = (char)next; if (next >= 0) { // ...and the next char is empty space, we must skip all whitespaces if (TomlSyntax.IsEmptySpace(nc)) { skipWhitespace = true; continue; } // ...and we have \" or \, skip the character if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true; } } // Count the consecutive quotes if (c == quote) quotesEncountered++; else quotesEncountered = 0; // If the are three quotes, count them as closing quotes if (quotesEncountered == 3) break; sb.Append(c); } // TOML actually allows to have five ending quotes like // """"" => "" belong to the string + """ is the actual ending quotesEncountered = 0; while ((cur = reader.Peek()) >= 0) { var c = (char)cur; if (c == quote && ++quotesEncountered < 3) { sb.Append(c); ConsumeChar(); } else break; } // Remove last two quotes (third one wasn't included by default) sb.Length -= 2; if (!isBasic) return sb.ToString(); if (sb.ToString().TryUnescape(out var res, out var ex)) return res; AddError(ex.Message); return null; } #endregion #region Node creation private bool InsertNode(TomlNode node, TomlNode root, IList<string> path) { var latestNode = root; if (path.Count > 1) for (var index = 0; index < path.Count - 1; index++) { var subkey = path[index]; if (latestNode.TryGetNode(subkey, out var currentNode)) { if (currentNode.HasValue) return AddError($"The key {".".Join(path)} already has a value assigned to it!"); } else { currentNode = new TomlTable(); latestNode[subkey] = currentNode; } latestNode = currentNode; if (latestNode is TomlTable { IsInline: true }) return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table."); } if (latestNode.HasKey(path[path.Count - 1])) return AddError($"The key {".".Join(path)} is already defined!"); latestNode[path[path.Count - 1]] = node; node.CollapseLevel = path.Count - 1; return true; } private TomlTable CreateTable(TomlNode root, IList<string> path, bool arrayTable) { if (path.Count == 0) return null; var latestNode = root; for (var index = 0; index < path.Count; index++) { var subkey = path[index]; if (latestNode.TryGetNode(subkey, out var node)) { if (node.IsArray && arrayTable) { var arr = (TomlArray)node; if (!arr.IsTableArray) { AddError($"The array {".".Join(path)} cannot be redefined as an array table!"); return null; } if (index == path.Count - 1) { latestNode = new TomlTable(); arr.Add(latestNode); break; } latestNode = arr[arr.ChildrenCount - 1]; continue; } if (node is TomlTable { IsInline: true }) { AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); return null; } if (node.HasValue) { if (!(node is TomlArray { IsTableArray: true } array)) { AddError($"The key {".".Join(path)} has a value assigned to it!"); return null; } latestNode = array[array.ChildrenCount - 1]; continue; } if (index == path.Count - 1) { if (arrayTable && !node.IsArray) { AddError($"The table {".".Join(path)} cannot be redefined as an array table!"); return null; } if (node is TomlTable { isImplicit: false }) { AddError($"The table {".".Join(path)} is defined multiple times!"); return null; } } } else { if (index == path.Count - 1 && arrayTable) { var table = new TomlTable(); var arr = new TomlArray { IsTableArray = true }; arr.Add(table); latestNode[subkey] = arr; latestNode = table; break; } node = new TomlTable { isImplicit = true }; latestNode[subkey] = node; } latestNode = node; } var result = (TomlTable)latestNode; result.isImplicit = false; return result; } #endregion #region Misc parsing private string ParseComment() { ConsumeChar(); var commentLine = reader.ReadLine()?.Trim() ?? ""; if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch))) AddError("Comment must not contain control characters other than tab.", false); return commentLine; } #endregion } #endregion public static class TOML { public static bool ForceASCII { get; set; } = false; public static TomlTable Parse(TextReader reader) { using var parser = new TOMLParser(reader) { ForceASCII = ForceASCII }; return parser.Parse(); } } #region Exception Types public class TomlFormatException : Exception { public TomlFormatException(string message) : base(message) { } } public class TomlParseException : Exception { public TomlParseException(TomlTable parsed, IEnumerable<TomlSyntaxException> exceptions) : base("TOML file contains format errors") { ParsedTable = parsed; SyntaxErrors = exceptions; } public TomlTable ParsedTable { get; } public IEnumerable<TomlSyntaxException> SyntaxErrors { get; } } public class TomlSyntaxException : Exception { public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message) { ParseState = state; Line = line; Column = col; } public TOMLParser.ParseState ParseState { get; } public int Line { get; } public int Column { get; } } #endregion #region Parse utilities internal static class TomlSyntax { #region Type Patterns public const string TRUE_VALUE = "true"; public const string FALSE_VALUE = "false"; public const string NAN_VALUE = "nan"; public const string POS_NAN_VALUE = "+nan"; public const string NEG_NAN_VALUE = "-nan"; public const string INF_VALUE = "inf"; public const string POS_INF_VALUE = "+inf"; public const string NEG_INF_VALUE = "-inf"; public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE; public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE; public static bool IsNegInf(string s) => s == NEG_INF_VALUE; public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE; public static bool IsInteger(string s) => IntegerPattern.IsMatch(s); public static bool IsFloat(string s) => FloatPattern.IsMatch(s); public static bool IsIntegerWithBase(string s, out int numberBase) { numberBase = 10; var match = BasedIntegerPattern.Match(s); if (!match.Success) return false; IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase); return true; } /** * A pattern to verify the integer value according to the TOML specification. */ public static readonly Regex IntegerPattern = new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled); /** * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification. */ public static readonly Regex BasedIntegerPattern = new(@"^0(?<base>x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /** * A pattern to verify the float value according to the TOML specification. */ public static readonly Regex FloatPattern = new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /** * A helper dictionary to map TOML base codes into the radii. */ public static readonly Dictionary<string, int> IntegerBases = new() { ["x"] = 16, ["o"] = 8, ["b"] = 2 }; /** * A helper dictionary to map non-decimal bases to their TOML identifiers */ public static readonly Dictionary<int, string> BaseIdentifiers = new() { [2] = "b", [8] = "o", [16] = "x" }; public const string RFC3339EmptySeparator = " "; public const string ISO861Separator = "T"; public const string ISO861ZeroZone = "+00:00"; public const string RFC3339ZeroZone = "Z"; /** * Valid date formats with timezone as per RFC3339. */ public static readonly string[] RFC3339Formats = { "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK" }; /** * Valid date formats without timezone (assumes local) as per RFC3339. */ public static readonly string[] RFC3339LocalDateTimeFormats = { "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff", "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff" }; /** * Valid full date format as per TOML spec. */ public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd"; /** * Valid time formats as per TOML spec. */ public static readonly string[] RFC3339LocalTimeFormats = { "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff", "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff" }; #endregion #region Character definitions public const char ARRAY_END_SYMBOL = ']'; public const char ITEM_SEPARATOR = ','; public const char ARRAY_START_SYMBOL = '['; public const char BASIC_STRING_SYMBOL = '\"'; public const char COMMENT_SYMBOL = '#'; public const char ESCAPE_SYMBOL = '\\'; public const char KEY_VALUE_SEPARATOR = '='; public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r'; public const char NEWLINE_CHARACTER = '\n'; public const char SUBKEY_SEPARATOR = '.'; public const char TABLE_END_SYMBOL = ']'; public const char TABLE_START_SYMBOL = '['; public const char INLINE_TABLE_START_SYMBOL = '{'; public const char INLINE_TABLE_END_SYMBOL = '}'; public const char LITERAL_STRING_SYMBOL = '\''; public const char INT_NUMBER_SEPARATOR = '_'; public static readonly char[] NewLineCharacters = { NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER }; public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; public static bool IsWhiteSpace(char c) => c is ' ' or '\t'; public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER; public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER; public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c); public static bool IsBareKey(char c) => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'; public static bool MustBeEscaped(char c, bool allowNewLines = false) { var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f'; if (!allowNewLines) result |= c is >= '\u000a' and <= '\u000e'; return result; } public static bool IsValueSeparator(char c) => c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL; #endregion } internal static class StringUtils { public static string AsKey(this string key) { var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c)); return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}"; } public static string Join(this string self, IEnumerable<string> subItems) { var sb = new StringBuilder(); var first = true; foreach (var subItem in subItems) { if (!first) sb.Append(self); first = false; sb.Append(subItem); } return sb.ToString(); } public delegate bool TryDateParseDelegate<T>(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); public static bool TryParseDateTime<T>(string s, string[] formats, DateTimeStyles styles, TryDateParseDelegate<T> parser, out T dateTime, out int parsedFormat) { parsedFormat = 0; dateTime = default; for (var i = 0; i < formats.Length; i++) { var format = formats[i]; if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue; parsedFormat = i; return true; } return false; } public static void AsComment(this string self, TextWriter tw) { foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER)) tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}"); } public static string RemoveAll(this string txt, char toRemove) { var sb = new StringBuilder(txt.Length); foreach (var c in txt.Where(c => c != toRemove)) sb.Append(c); return sb.ToString(); } public static string Escape(this string txt, bool escapeNewlines = true) { var stringBuilder = new StringBuilder(txt.Length + 2); for (var i = 0; i < txt.Length; i++) { var c = txt[i]; static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" : $"\\u{(ushort)c:X4}"; stringBuilder.Append(c switch { '\b' => @"\b", '\t' => @"\t", '\n' when escapeNewlines => @"\n", '\f' => @"\f", '\r' when escapeNewlines => @"\r", '\\' => @"\\", '\"' => @"\""", var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => CodePoint(txt, ref i, c), var _ => c }); } return stringBuilder.ToString(); } public static bool TryUnescape(this string txt, out string unescaped, out Exception exception) { try { exception = null; unescaped = txt.Unescape(); return true; } catch (Exception e) { exception = e; unescaped = null; return false; } } public static string Unescape(this string txt) { if (string.IsNullOrEmpty(txt)) return txt; var stringBuilder = new StringBuilder(txt.Length); for (var i = 0; i < txt.Length;) { var num = txt.IndexOf('\\', i); var next = num + 1; if (num < 0 || num == txt.Length - 1) num = txt.Length; stringBuilder.Append(txt, i, num - i); if (num >= txt.Length) break; var c = txt[next]; static string CodePoint(int next, string txt, ref int num, int size) { if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!"); num += size; return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16)); } stringBuilder.Append(c switch { 'b' => "\b", 't' => "\t", 'n' => "\n", 'f' => "\f", 'r' => "\r", '\'' => "\'", '\"' => "\"", '\\' => "\\", 'u' => CodePoint(next, txt, ref num, 4), 'U' => CodePoint(next, txt, ref num, 8), var _ => throw new Exception("Undefined escape sequence!") }); i = num + 2; } return stringBuilder.ToString(); } } #endregion } ```