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