This is page 12 of 13. Use http://codebase.md/justinpbarnett/unity-mcp?lines=false&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/Tools/ManageGameObject.cs: -------------------------------------------------------------------------------- ```csharp #nullable disable using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using Newtonsoft.Json; // Added for JsonSerializationException using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.Compilation; // For CompilationPipeline using UnityEditor.SceneManagement; using UnityEditorInternal; using UnityEngine; using UnityEngine.SceneManagement; using MCPForUnity.Editor.Helpers; // For Response class using MCPForUnity.Runtime.Serialization; namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles GameObject manipulation within the current scene (CRUD, find, components). /// </summary> [McpForUnityTool("manage_gameobject")] public static class ManageGameObject { // Shared JsonSerializer to avoid per-call allocation overhead private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings { Converters = new List<JsonConverter> { new Vector3Converter(), new Vector2Converter(), new QuaternionConverter(), new ColorConverter(), new RectConverter(), new BoundsConverter(), new UnityEngineObjectConverter() } }); // --- Main Handler --- public static object HandleCommand(JObject @params) { if (@params == null) { return Response.Error("Parameters cannot be null."); } string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } // Parameters used by various actions JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) string searchMethod = @params["searchMethod"]?.ToString().ToLower(); // Get common parameters (consolidated) string name = @params["name"]?.ToString(); string tag = @params["tag"]?.ToString(); string layer = @params["layer"]?.ToString(); JToken parentToken = @params["parent"]; // --- Add parameter for controlling non-public field inclusion --- bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject<bool>() ?? true; // Default to true // --- End add parameter --- // --- Prefab Redirection Check --- string targetPath = targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; if ( !string.IsNullOrEmpty(targetPath) && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) ) { // Allow 'create' (instantiate), 'find' (?), 'get_components' (?) if (action == "modify" || action == "set_component_property") { Debug.Log( $"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset." ); // Prepare params for ManageAsset.ModifyAsset JObject assetParams = new JObject(); assetParams["action"] = "modify"; // ManageAsset uses "modify" assetParams["path"] = targetPath; // Extract properties. // For 'set_component_property', combine componentName and componentProperties. // For 'modify', directly use componentProperties. JObject properties = null; if (action == "set_component_property") { string compName = @params["componentName"]?.ToString(); JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting if (string.IsNullOrEmpty(compName)) return Response.Error( "Missing 'componentName' for 'set_component_property' on prefab." ); if (compProps == null) return Response.Error( $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." ); properties = new JObject(); properties[compName] = compProps; } else // action == "modify" { properties = @params["componentProperties"] as JObject; if (properties == null) return Response.Error( "Missing 'componentProperties' for 'modify' action on prefab." ); } assetParams["properties"] = properties; // Call ManageAsset handler return ManageAsset.HandleCommand(assetParams); } else if ( action == "delete" || action == "add_component" || action == "remove_component" || action == "get_components" ) // Added get_components here too { // Explicitly block other modifications on the prefab asset itself via manage_gameobject return Response.Error( $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." ); } // Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject. // No specific handling needed here, the code below will run. } // --- End Prefab Redirection Check --- try { switch (action) { case "create": return CreateGameObject(@params); case "modify": return ModifyGameObject(@params, targetToken, searchMethod); case "delete": return DeleteGameObject(targetToken, searchMethod); case "find": return FindGameObjects(@params, targetToken, searchMethod); case "get_components": string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string if (getCompTarget == null) return Response.Error( "'target' parameter required for get_components." ); // Pass the includeNonPublicSerialized flag here return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized); case "get_component": string getSingleCompTarget = targetToken?.ToString(); if (getSingleCompTarget == null) return Response.Error( "'target' parameter required for get_component." ); string componentName = @params["componentName"]?.ToString(); if (string.IsNullOrEmpty(componentName)) return Response.Error( "'componentName' parameter required for get_component." ); return GetSingleComponentFromTarget(getSingleCompTarget, searchMethod, componentName, includeNonPublicSerialized); case "add_component": return AddComponentToTarget(@params, targetToken, searchMethod); case "remove_component": return RemoveComponentFromTarget(@params, targetToken, searchMethod); case "set_component_property": return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); default: return Response.Error($"Unknown action: '{action}'."); } } catch (Exception e) { Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); return Response.Error($"Internal error processing action '{action}': {e.Message}"); } } // --- Action Implementations --- private static object CreateGameObject(JObject @params) { string name = @params["name"]?.ToString(); if (string.IsNullOrEmpty(name)) { return Response.Error("'name' parameter is required for 'create' action."); } // Get prefab creation parameters bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject<bool>() ?? false; string prefabPath = @params["prefabPath"]?.ToString(); string tag = @params["tag"]?.ToString(); // Get tag for creation string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check GameObject newGo = null; // Initialize as null // --- Try Instantiating Prefab First --- string originalPrefabPath = prefabPath; // Keep original for messages if (!string.IsNullOrEmpty(prefabPath)) { // If no extension, search for the prefab by name if ( !prefabPath.Contains("/") && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) ) { string prefabNameOnly = prefabPath; Debug.Log( $"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'" ); string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); if (guids.Length == 0) { return Response.Error( $"Prefab named '{prefabNameOnly}' not found anywhere in the project." ); } else if (guids.Length > 1) { string foundPaths = string.Join( ", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) ); return Response.Error( $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." ); } else // Exactly one found { prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path Debug.Log( $"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'" ); } } else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. Debug.LogWarning( $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending." ); prefabPath += ".prefab"; // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. } // The logic above now handles finding or assuming the .prefab extension. GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath); if (prefabAsset != null) { try { // Instantiate the prefab, initially place it at the root // Parent will be set later if specified newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; if (newGo == null) { // This might happen if the asset exists but isn't a valid GameObject prefab somehow Debug.LogError( $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." ); return Response.Error( $"Failed to instantiate prefab at '{prefabPath}'." ); } // Name the instance based on the 'name' parameter, not the prefab's default name if (!string.IsNullOrEmpty(name)) { newGo.name = name; } // Register Undo for prefab instantiation Undo.RegisterCreatedObjectUndo( newGo, $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" ); Debug.Log( $"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'." ); } catch (Exception e) { return Response.Error( $"Error instantiating prefab '{prefabPath}': {e.Message}" ); } } else { // Only return error if prefabPath was specified but not found. // If prefabPath was empty/null, we proceed to create primitive/empty. Debug.LogWarning( $"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." ); // Do not return error here, allow fallback to primitive/empty creation } } // --- Fallback: Create Primitive or Empty GameObject --- bool createdNewObject = false; // Flag to track if we created (not instantiated) if (newGo == null) // Only proceed if prefab instantiation didn't happen { if (!string.IsNullOrEmpty(primitiveType)) { try { PrimitiveType type = (PrimitiveType) Enum.Parse(typeof(PrimitiveType), primitiveType, true); newGo = GameObject.CreatePrimitive(type); // Set name *after* creation for primitives if (!string.IsNullOrEmpty(name)) { newGo.name = name; } else { UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak return Response.Error( "'name' parameter is required when creating a primitive." ); } createdNewObject = true; } catch (ArgumentException) { return Response.Error( $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" ); } catch (Exception e) { return Response.Error( $"Failed to create primitive '{primitiveType}': {e.Message}" ); } } else // Create empty GameObject { if (string.IsNullOrEmpty(name)) { return Response.Error( "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." ); } newGo = new GameObject(name); createdNewObject = true; } // Record creation for Undo *only* if we created a new object if (createdNewObject) { Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); } } // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- if (newGo == null) { // Should theoretically not happen if logic above is correct, but safety check. return Response.Error("Failed to create or instantiate the GameObject."); } // Record potential changes to the existing prefab instance or the new GO // Record transform separately in case parent changes affect it Undo.RecordObject(newGo.transform, "Set GameObject Transform"); Undo.RecordObject(newGo, "Set GameObject Properties"); // Set Parent JToken parentToken = @params["parent"]; if (parentToken != null) { GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding if (parentGo == null) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object return Response.Error($"Parent specified ('{parentToken}') but not found."); } newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true } // Set Transform Vector3? position = ParseVector3(@params["position"] as JArray); Vector3? rotation = ParseVector3(@params["rotation"] as JArray); Vector3? scale = ParseVector3(@params["scale"] as JArray); if (position.HasValue) newGo.transform.localPosition = position.Value; if (rotation.HasValue) newGo.transform.localEulerAngles = rotation.Value; if (scale.HasValue) newGo.transform.localScale = scale.Value; // Set Tag (added for create action) if (!string.IsNullOrEmpty(tag)) { // Similar logic as in ModifyGameObject for setting/creating tags string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; try { newGo.tag = tagToSet; } catch (UnityException ex) { if (ex.Message.Contains("is not defined")) { Debug.LogWarning( $"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it." ); try { InternalEditorUtility.AddTag(tagToSet); newGo.tag = tagToSet; // Retry Debug.Log( $"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully." ); } catch (Exception innerEx) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return Response.Error( $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." ); } } else { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return Response.Error( $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." ); } } } // Set Layer (new for create action) string layerName = @params["layer"]?.ToString(); if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); if (layerId != -1) { newGo.layer = layerId; } else { Debug.LogWarning( $"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer." ); } } // Add Components if (@params["componentsToAdd"] is JArray componentsToAddArray) { foreach (var compToken in componentsToAddArray) { string typeName = null; JObject properties = null; if (compToken.Type == JTokenType.String) { typeName = compToken.ToString(); } else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } if (!string.IsNullOrEmpty(typeName)) { var addResult = AddComponentInternal(newGo, typeName, properties); if (addResult != null) // Check if AddComponentInternal returned an error object { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return addResult; // Return the error response } } else { Debug.LogWarning( $"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}" ); } } } // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true GameObject finalInstance = newGo; // Use this for selection and return data if (createdNewObject && saveAsPrefab) { string finalPrefabPath = prefabPath; // Use a separate variable for saving path // This check should now happen *before* attempting to save if (string.IsNullOrEmpty(finalPrefabPath)) { // Clean up the created object before returning error UnityEngine.Object.DestroyImmediate(newGo); return Response.Error( "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." ); } // Ensure the *saving* path ends with .prefab if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { Debug.Log( $"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'" ); finalPrefabPath += ".prefab"; } try { // Ensure directory exists using the final saving path string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); if ( !string.IsNullOrEmpty(directoryPath) && !System.IO.Directory.Exists(directoryPath) ) { System.IO.Directory.CreateDirectory(directoryPath); AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder Debug.Log( $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" ); } // Use SaveAsPrefabAssetAndConnect with the final saving path finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( newGo, finalPrefabPath, InteractionMode.UserAction ); if (finalInstance == null) { // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) UnityEngine.Object.DestroyImmediate(newGo); return Response.Error( $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." ); } Debug.Log( $"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." ); // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect } catch (Exception e) { // Clean up the instance if prefab saving fails UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); } } // Select the instance in the scene (either prefab instance or newly created/saved one) Selection.activeGameObject = finalInstance; // Determine appropriate success message using the potentially updated or original path string messagePrefabPath = finalInstance == null ? originalPrefabPath : AssetDatabase.GetAssetPath( PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) ?? (UnityEngine.Object)finalInstance ); string successMessage; if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab { successMessage = $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; } else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab { successMessage = $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; } else // Created new primitive or empty GO, didn't save as prefab { successMessage = $"GameObject '{finalInstance.name}' created successfully in scene."; } // Use the new serializer helper //return Response.Success(successMessage, GetGameObjectData(finalInstance)); return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); } private static object ModifyGameObject( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } // Record state for Undo *before* modifications Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); Undo.RecordObject(targetGo, "Modify GameObject Properties"); bool modified = false; // Rename (using consolidated 'name' parameter) string name = @params["name"]?.ToString(); if (!string.IsNullOrEmpty(name) && targetGo.name != name) { targetGo.name = name; modified = true; } // Change Parent (using consolidated 'parent' parameter) JToken parentToken = @params["parent"]; if (parentToken != null) { GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Check for hierarchy loops if ( newParentGo == null && !( parentToken.Type == JTokenType.Null || ( parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()) ) ) ) { return Response.Error($"New parent ('{parentToken}') not found."); } if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) { return Response.Error( $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." ); } if (targetGo.transform.parent != (newParentGo?.transform)) { targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true modified = true; } } // Set Active State bool? setActive = @params["setActive"]?.ToObject<bool?>(); if (setActive.HasValue && targetGo.activeSelf != setActive.Value) { targetGo.SetActive(setActive.Value); modified = true; } // Change Tag (using consolidated 'tag' parameter) string tag = @params["tag"]?.ToString(); // Only attempt to change tag if a non-null tag is provided and it's different from the current one. // Allow setting an empty string to remove the tag (Unity uses "Untagged"). if (tag != null && targetGo.tag != tag) { // Ensure the tag is not empty, if empty, it means "Untagged" implicitly string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; try { targetGo.tag = tagToSet; modified = true; } catch (UnityException ex) { // Check if the error is specifically because the tag doesn't exist if (ex.Message.Contains("is not defined")) { Debug.LogWarning( $"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it." ); try { // Attempt to create the tag using internal utility InternalEditorUtility.AddTag(tagToSet); // Wait a frame maybe? Not strictly necessary but sometimes helps editor updates. // yield return null; // Cannot yield here, editor script limitation // Retry setting the tag immediately after creation targetGo.tag = tagToSet; modified = true; Debug.Log( $"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully." ); } catch (Exception innerEx) { // Handle failure during tag creation or the second assignment attempt Debug.LogError( $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" ); return Response.Error( $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." ); } } else { // If the exception was for a different reason, return the original error return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); } } } // Change Layer (using consolidated 'layer' parameter) string layerName = @params["layer"]?.ToString(); if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); if (layerId == -1 && layerName != "Default") { return Response.Error( $"Invalid layer specified: '{layerName}'. Use a valid layer name." ); } if (layerId != -1 && targetGo.layer != layerId) { targetGo.layer = layerId; modified = true; } } // Transform Modifications Vector3? position = ParseVector3(@params["position"] as JArray); Vector3? rotation = ParseVector3(@params["rotation"] as JArray); Vector3? scale = ParseVector3(@params["scale"] as JArray); if (position.HasValue && targetGo.transform.localPosition != position.Value) { targetGo.transform.localPosition = position.Value; modified = true; } if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) { targetGo.transform.localEulerAngles = rotation.Value; modified = true; } if (scale.HasValue && targetGo.transform.localScale != scale.Value) { targetGo.transform.localScale = scale.Value; modified = true; } // --- Component Modifications --- // Note: These might need more specific Undo recording per component // Remove Components if (@params["componentsToRemove"] is JArray componentsToRemoveArray) { foreach (var compToken in componentsToRemoveArray) { // ... (parsing logic as in CreateGameObject) ... string typeName = compToken.ToString(); if (!string.IsNullOrEmpty(typeName)) { var removeResult = RemoveComponentInternal(targetGo, typeName); if (removeResult != null) return removeResult; // Return error if removal failed modified = true; } } } // Add Components (similar to create) if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) { foreach (var compToken in componentsToAddArrayModify) { string typeName = null; JObject properties = null; if (compToken.Type == JTokenType.String) typeName = compToken.ToString(); else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } if (!string.IsNullOrEmpty(typeName)) { var addResult = AddComponentInternal(targetGo, typeName, properties); if (addResult != null) return addResult; modified = true; } } } // Set Component Properties var componentErrors = new List<object>(); if (@params["componentProperties"] is JObject componentPropertiesObj) { foreach (var prop in componentPropertiesObj.Properties()) { string compName = prop.Name; JObject propertiesToSet = prop.Value as JObject; if (propertiesToSet != null) { var setResult = SetComponentPropertiesInternal( targetGo, compName, propertiesToSet ); if (setResult != null) { componentErrors.Add(setResult); } else { modified = true; } } } } // Return component errors if any occurred (after processing all components) if (componentErrors.Count > 0) { // Aggregate flattened error strings to make tests/API assertions simpler var aggregatedErrors = new System.Collections.Generic.List<string>(); foreach (var errorObj in componentErrors) { try { var dataProp = errorObj?.GetType().GetProperty("data"); var dataVal = dataProp?.GetValue(errorObj); if (dataVal != null) { var errorsProp = dataVal.GetType().GetProperty("errors"); var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; if (errorsEnum != null) { foreach (var item in errorsEnum) { var s = item?.ToString(); if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); } } } } catch { } } return Response.Error( $"One or more component property operations failed on '{targetGo.name}'.", new { componentErrors = componentErrors, errors = aggregatedErrors } ); } if (!modified) { // Use the new serializer helper // return Response.Success( // $"No modifications applied to GameObject '{targetGo.name}'.", // GetGameObjectData(targetGo)); return Response.Success( $"No modifications applied to GameObject '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } EditorUtility.SetDirty(targetGo); // Mark scene as dirty // Use the new serializer helper return Response.Success( $"GameObject '{targetGo.name}' modified successfully.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); // return Response.Success( // $"GameObject '{targetGo.name}' modified successfully.", // GetGameObjectData(targetGo)); } private static object DeleteGameObject(JToken targetToken, string searchMethod) { // Find potentially multiple objects if name/tag search is used without find_all=false implicitly List<GameObject> targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety if (targets.Count == 0) { return Response.Error( $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } List<object> deletedObjects = new List<object>(); foreach (var targetGo in targets) { if (targetGo != null) { string goName = targetGo.name; int goId = targetGo.GetInstanceID(); // Use Undo.DestroyObjectImmediate for undo support Undo.DestroyObjectImmediate(targetGo); deletedObjects.Add(new { name = goName, instanceID = goId }); } } if (deletedObjects.Count > 0) { string message = targets.Count == 1 ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." : $"{deletedObjects.Count} GameObjects deleted successfully."; return Response.Success(message, deletedObjects); } else { // Should not happen if targets.Count > 0 initially, but defensive check return Response.Error("Failed to delete target GameObject(s)."); } } private static object FindGameObjects( JObject @params, JToken targetToken, string searchMethod ) { bool findAll = @params["findAll"]?.ToObject<bool>() ?? false; List<GameObject> foundObjects = FindObjectsInternal( targetToken, searchMethod, findAll, @params ); if (foundObjects.Count == 0) { return Response.Success("No matching GameObjects found.", new List<object>()); } // Use the new serializer helper //var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); return Response.Success($"Found {results.Count} GameObject(s).", results); } private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) { GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } try { // --- Get components, immediately copy to list, and null original array --- Component[] originalComponents = targetGo.GetComponents<Component>(); List<Component> componentsToIterate = new List<Component>(originalComponents ?? Array.Empty<Component>()); // Copy immediately, handle null case int componentCount = componentsToIterate.Count; originalComponents = null; // Null the original reference // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); // --- End Copy and Null --- var componentData = new List<object>(); for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY { Component c = componentsToIterate[i]; // Use the copy if (c == null) { // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); continue; // Safety check } // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); try { var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); if (data != null) // Ensure GetComponentData didn't return null { componentData.Insert(0, data); // Insert at beginning to maintain original order in final list } // else // { // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] GetComponentData returned null for component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}. Skipping addition."); // } } catch (Exception ex) { Debug.LogError($"[GetComponentsFromTarget REVERSE for] Error processing component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}: {ex.Message}\n{ex.StackTrace}"); // Optionally add placeholder data or just skip componentData.Insert(0, new JObject( // Insert error marker at beginning new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"), new JProperty("instanceID", c.GetInstanceID()), new JProperty("error", ex.Message) )); } } // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); // Cleanup the list we created componentsToIterate.Clear(); componentsToIterate = null; return Response.Success( $"Retrieved {componentData.Count} components from '{targetGo.name}'.", componentData // List was built in original order ); } catch (Exception e) { return Response.Error( $"Error getting components from '{targetGo.name}': {e.Message}" ); } } private static object GetSingleComponentFromTarget(string target, string searchMethod, string componentName, bool includeNonPublicSerialized = true) { GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } try { // Try to find the component by name Component targetComponent = targetGo.GetComponent(componentName); // If not found directly, try to find by type name (handle namespaces) if (targetComponent == null) { Component[] allComponents = targetGo.GetComponents<Component>(); foreach (Component comp in allComponents) { if (comp != null) { string typeName = comp.GetType().Name; string fullTypeName = comp.GetType().FullName; if (typeName == componentName || fullTypeName == componentName) { targetComponent = comp; break; } } } } if (targetComponent == null) { return Response.Error( $"Component '{componentName}' not found on GameObject '{targetGo.name}'." ); } var componentData = Helpers.GameObjectSerializer.GetComponentData(targetComponent, includeNonPublicSerialized); if (componentData == null) { return Response.Error( $"Failed to serialize component '{componentName}' on GameObject '{targetGo.name}'." ); } return Response.Success( $"Retrieved component '{componentName}' from '{targetGo.name}'.", componentData ); } catch (Exception e) { return Response.Error( $"Error getting component '{componentName}' from '{targetGo.name}': {e.Message}" ); } } private static object AddComponentToTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string typeName = null; JObject properties = null; // Allow adding component specified directly or via componentsToAdd array (take first) if (@params["componentName"] != null) { typeName = @params["componentName"]?.ToString(); properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name } else if ( @params["componentsToAdd"] is JArray componentsToAddArray && componentsToAddArray.Count > 0 ) { var compToken = componentsToAddArray.First; if (compToken.Type == JTokenType.String) typeName = compToken.ToString(); else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } } if (string.IsNullOrEmpty(typeName)) { return Response.Error( "Component type name ('componentName' or first element in 'componentsToAdd') is required." ); } var addResult = AddComponentInternal(targetGo, typeName, properties); if (addResult != null) return addResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Component '{typeName}' added to '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); // Return updated GO data } private static object RemoveComponentFromTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string typeName = null; // Allow removing component specified directly or via componentsToRemove array (take first) if (@params["componentName"] != null) { typeName = @params["componentName"]?.ToString(); } else if ( @params["componentsToRemove"] is JArray componentsToRemoveArray && componentsToRemoveArray.Count > 0 ) { typeName = componentsToRemoveArray.First?.ToString(); } if (string.IsNullOrEmpty(typeName)) { return Response.Error( "Component type name ('componentName' or first element in 'componentsToRemove') is required." ); } var removeResult = RemoveComponentInternal(targetGo, typeName); if (removeResult != null) return removeResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Component '{typeName}' removed from '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } private static object SetComponentPropertyOnTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string compName = @params["componentName"]?.ToString(); JObject propertiesToSet = null; if (!string.IsNullOrEmpty(compName)) { // Properties might be directly under componentProperties or nested under the component name if (@params["componentProperties"] is JObject compProps) { propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure } } else { return Response.Error("'componentName' parameter is required."); } if (propertiesToSet == null || !propertiesToSet.HasValues) { return Response.Error( "'componentProperties' dictionary for the specified component is required and cannot be empty." ); } var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); if (setResult != null) return setResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Properties set for component '{compName}' on '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } // --- Internal Helpers --- /// <summary> /// Parses a JArray like [x, y, z] into a Vector3. /// </summary> private static Vector3? ParseVector3(JArray array) { if (array != null && array.Count == 3) { try { return new Vector3( array[0].ToObject<float>(), array[1].ToObject<float>(), array[2].ToObject<float>() ); } catch (Exception ex) { Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}"); } } return null; } /// <summary> /// Finds a single GameObject based on token (ID, name, path) and search method. /// </summary> private static GameObject FindObjectInternal( JToken targetToken, string searchMethod, JObject findParams = null ) { // If find_all is not explicitly false, we still want only one for most single-target operations. bool findAll = findParams?["findAll"]?.ToObject<bool>() ?? false; // If a specific target ID is given, always find just that one. if ( targetToken?.Type == JTokenType.Integer || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) ) { findAll = false; } List<GameObject> results = FindObjectsInternal( targetToken, searchMethod, findAll, findParams ); return results.Count > 0 ? results[0] : null; } /// <summary> /// Core logic for finding GameObjects based on various criteria. /// </summary> private static List<GameObject> FindObjectsInternal( JToken targetToken, string searchMethod, bool findAll, JObject findParams = null ) { List<GameObject> results = new List<GameObject>(); string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself bool searchInChildren = findParams?["searchInChildren"]?.ToObject<bool>() ?? false; bool searchInactive = findParams?["searchInactive"]?.ToObject<bool>() ?? false; // Default search method if not specified if (string.IsNullOrEmpty(searchMethod)) { if (targetToken?.Type == JTokenType.Integer) searchMethod = "by_id"; else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) searchMethod = "by_path"; else searchMethod = "by_name"; // Default fallback } GameObject rootSearchObject = null; // If searching in children, find the initial target first if (searchInChildren && targetToken != null) { rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search if (rootSearchObject == null) { Debug.LogWarning( $"[ManageGameObject.Find] Root object '{targetToken}' for child search not found." ); return results; // Return empty if root not found } } switch (searchMethod) { case "by_id": if (int.TryParse(searchTerm, out int instanceId)) { // EditorUtility.InstanceIDToObject is slow, iterate manually if possible // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; var allObjects = GetAllSceneObjects(searchInactive); // More efficient GameObject obj = allObjects.FirstOrDefault(go => go.GetInstanceID() == instanceId ); if (obj != null) results.Add(obj); } break; case "by_name": var searchPoolName = rootSearchObject ? rootSearchObject .GetComponentsInChildren<Transform>(searchInactive) .Select(t => t.gameObject) : GetAllSceneObjects(searchInactive); results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); break; case "by_path": // Path is relative to scene root or rootSearchObject Transform foundTransform = rootSearchObject ? rootSearchObject.transform.Find(searchTerm) : GameObject.Find(searchTerm)?.transform; if (foundTransform != null) results.Add(foundTransform.gameObject); break; case "by_tag": var searchPoolTag = rootSearchObject ? rootSearchObject .GetComponentsInChildren<Transform>(searchInactive) .Select(t => t.gameObject) : GetAllSceneObjects(searchInactive); results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); break; case "by_layer": var searchPoolLayer = rootSearchObject ? rootSearchObject .GetComponentsInChildren<Transform>(searchInactive) .Select(t => t.gameObject) : GetAllSceneObjects(searchInactive); if (int.TryParse(searchTerm, out int layerIndex)) { results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); } else { int namedLayer = LayerMask.NameToLayer(searchTerm); if (namedLayer != -1) results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); } break; case "by_component": Type componentType = FindType(searchTerm); if (componentType != null) { // Determine FindObjectsInactive based on the searchInactive flag FindObjectsInactive findInactive = searchInactive ? FindObjectsInactive.Include : FindObjectsInactive.Exclude; // Replace FindObjectsOfType with FindObjectsByType, specifying the sorting mode and inactive state var searchPoolComp = rootSearchObject ? rootSearchObject .GetComponentsInChildren(componentType, searchInactive) .Select(c => (c as Component).gameObject) : UnityEngine .Object.FindObjectsByType( componentType, findInactive, FindObjectsSortMode.None ) .Select(c => (c as Component).gameObject); results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid } else { Debug.LogWarning( $"[ManageGameObject.Find] Component type not found: {searchTerm}" ); } break; case "by_id_or_name_or_path": // Helper method used internally if (int.TryParse(searchTerm, out int id)) { var allObjectsId = GetAllSceneObjects(true); // Search inactive for internal lookup GameObject objById = allObjectsId.FirstOrDefault(go => go.GetInstanceID() == id ); if (objById != null) { results.Add(objById); break; } } GameObject objByPath = GameObject.Find(searchTerm); if (objByPath != null) { results.Add(objByPath); break; } var allObjectsName = GetAllSceneObjects(true); results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); break; default: Debug.LogWarning( $"[ManageGameObject.Find] Unknown search method: {searchMethod}" ); break; } // If only one result is needed, return just the first one found. if (!findAll && results.Count > 1) { return new List<GameObject> { results[0] }; } return results.Distinct().ToList(); // Ensure uniqueness } // Helper to get all scene objects efficiently private static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive) { // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType<GameObject>() var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); var allObjects = new List<GameObject>(); foreach (var root in rootObjects) { allObjects.AddRange( root.GetComponentsInChildren<Transform>(includeInactive) .Select(t => t.gameObject) ); } return allObjects; } /// <summary> /// Adds a component by type name and optionally sets properties. /// Returns null on success, or an error response object on failure. /// </summary> private static object AddComponentInternal( GameObject targetGo, string typeName, JObject properties ) { Type componentType = FindType(typeName); if (componentType == null) { return Response.Error( $"Component type '{typeName}' not found or is not a valid Component." ); } if (!typeof(Component).IsAssignableFrom(componentType)) { return Response.Error($"Type '{typeName}' is not a Component."); } // Prevent adding Transform again if (componentType == typeof(Transform)) { return Response.Error("Cannot add another Transform component."); } // Check for 2D/3D physics component conflicts bool isAdding2DPhysics = typeof(Rigidbody2D).IsAssignableFrom(componentType) || typeof(Collider2D).IsAssignableFrom(componentType); bool isAdding3DPhysics = typeof(Rigidbody).IsAssignableFrom(componentType) || typeof(Collider).IsAssignableFrom(componentType); if (isAdding2DPhysics) { // Check if the GameObject already has any 3D Rigidbody or Collider if ( targetGo.GetComponent<Rigidbody>() != null || targetGo.GetComponent<Collider>() != null ) { return Response.Error( $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." ); } } else if (isAdding3DPhysics) { // Check if the GameObject already has any 2D Rigidbody or Collider if ( targetGo.GetComponent<Rigidbody2D>() != null || targetGo.GetComponent<Collider2D>() != null ) { return Response.Error( $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." ); } } try { // Use Undo.AddComponent for undo support Component newComponent = Undo.AddComponent(targetGo, componentType); if (newComponent == null) { return Response.Error( $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." ); } // Set default values for specific component types if (newComponent is Light light) { // Default newly added lights to directional light.type = LightType.Directional; } // Set properties if provided if (properties != null) { var setResult = SetComponentPropertiesInternal( targetGo, typeName, properties, newComponent ); // Pass the new component instance if (setResult != null) { // If setting properties failed, maybe remove the added component? Undo.DestroyObjectImmediate(newComponent); return setResult; // Return the error from setting properties } } return null; // Success } catch (Exception e) { return Response.Error( $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" ); } } /// <summary> /// Removes a component by type name. /// Returns null on success, or an error response object on failure. /// </summary> private static object RemoveComponentInternal(GameObject targetGo, string typeName) { Type componentType = FindType(typeName); if (componentType == null) { return Response.Error($"Component type '{typeName}' not found for removal."); } // Prevent removing essential components if (componentType == typeof(Transform)) { return Response.Error("Cannot remove the Transform component."); } Component componentToRemove = targetGo.GetComponent(componentType); if (componentToRemove == null) { return Response.Error( $"Component '{typeName}' not found on '{targetGo.name}' to remove." ); } try { // Use Undo.DestroyObjectImmediate for undo support Undo.DestroyObjectImmediate(componentToRemove); return null; // Success } catch (Exception e) { return Response.Error( $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" ); } } /// <summary> /// Sets properties on a component. /// Returns null on success, or an error response object on failure. /// </summary> private static object SetComponentPropertiesInternal( GameObject targetGo, string compName, JObject propertiesToSet, Component targetComponentInstance = null ) { Component targetComponent = targetComponentInstance; if (targetComponent == null) { if (ComponentResolver.TryResolve(compName, out var compType, out var compError)) { targetComponent = targetGo.GetComponent(compType); } else { targetComponent = targetGo.GetComponent(compName); // fallback to string-based lookup } } if (targetComponent == null) { return Response.Error( $"Component '{compName}' not found on '{targetGo.name}' to set properties." ); } Undo.RecordObject(targetComponent, "Set Component Properties"); var failures = new List<string>(); foreach (var prop in propertiesToSet.Properties()) { string propName = prop.Name; JToken propValue = prop.Value; try { bool setResult = SetProperty(targetComponent, propName, propValue); if (!setResult) { var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties); var msg = suggestions.Any() ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; Debug.LogWarning($"[ManageGameObject] {msg}"); failures.Add(msg); } } catch (Exception e) { Debug.LogError( $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" ); failures.Add($"Error setting '{propName}': {e.Message}"); } } EditorUtility.SetDirty(targetComponent); return failures.Count == 0 ? null : Response.Error($"One or more properties failed on '{compName}'.", new { errors = failures }); } /// <summary> /// Helper to set a property or field via reflection, handling basic types. /// </summary> private static bool SetProperty(object target, string memberName, JToken value) { Type type = target.GetType(); BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; // Use shared serializer to avoid per-call allocation var inputSerializer = InputSerializer; try { // Handle special case for materials with dot notation (material.property) // Examples: material.color, sharedMaterial.color, materials[0].color if (memberName.Contains('.') || memberName.Contains('[')) { // Pass the inputSerializer down for nested conversions return SetNestedProperty(target, memberName, value, inputSerializer); } PropertyInfo propInfo = type.GetProperty(memberName, flags); if (propInfo != null && propInfo.CanWrite) { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null { propInfo.SetValue(target, convertedValue); return true; } else { Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { FieldInfo fieldInfo = type.GetField(memberName, flags); if (fieldInfo != null) // Check if !IsLiteral? { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null { fieldInfo.SetValue(target, convertedValue); return true; } else { Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { // Try NonPublic [SerializeField] fields var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); if (npField != null && npField.GetCustomAttribute<SerializeField>() != null) { object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) { npField.SetValue(target, convertedValue); return true; } } } } } catch (Exception ex) { Debug.LogError( $"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}" ); } return false; } /// <summary> /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") /// </summary> // Pass the input serializer for conversions //Using the serializer helper private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) { try { // Split the path into parts (handling both dot notation and array indexing) string[] pathParts = SplitPropertyPath(path); if (pathParts.Length == 0) return false; object currentObject = target; Type currentType = currentObject.GetType(); BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; // Traverse the path until we reach the final property for (int i = 0; i < pathParts.Length - 1; i++) { string part = pathParts[i]; bool isArray = false; int arrayIndex = -1; // Check if this part contains array indexing if (part.Contains("[")) { int startBracket = part.IndexOf('['); int endBracket = part.IndexOf(']'); if (startBracket > 0 && endBracket > startBracket) { string indexStr = part.Substring( startBracket + 1, endBracket - startBracket - 1 ); if (int.TryParse(indexStr, out arrayIndex)) { isArray = true; part = part.Substring(0, startBracket); } } } // Get the property/field PropertyInfo propInfo = currentType.GetProperty(part, flags); FieldInfo fieldInfo = null; if (propInfo == null) { fieldInfo = currentType.GetField(part, flags); if (fieldInfo == null) { Debug.LogWarning( $"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'" ); return false; } } // Get the value currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject); //Need to stop if current property is null if (currentObject == null) { Debug.LogWarning( $"[SetNestedProperty] Property '{part}' is null, cannot access nested properties." ); return false; } // If this part was an array or list, access the specific index if (isArray) { if (currentObject is Material[]) { var materials = currentObject as Material[]; if (arrayIndex < 0 || arrayIndex >= materials.Length) { Debug.LogWarning( $"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})" ); return false; } currentObject = materials[arrayIndex]; } else if (currentObject is System.Collections.IList) { var list = currentObject as System.Collections.IList; if (arrayIndex < 0 || arrayIndex >= list.Count) { Debug.LogWarning( $"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})" ); return false; } currentObject = list[arrayIndex]; } else { Debug.LogWarning( $"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index." ); return false; } } currentType = currentObject.GetType(); } // Set the final property string finalPart = pathParts[pathParts.Length - 1]; // Special handling for Material properties (shader properties) if (currentObject is Material material && finalPart.StartsWith("_")) { // Use the serializer to convert the JToken value first if (value is JArray jArray) { // Try converting to known types that SetColor/SetVector accept if (jArray.Count == 4) { try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } try { Vector4 vec = value.ToObject<Vector4>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } } else if (jArray.Count == 3) { try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color } else if (jArray.Count == 2) { try { Vector2 vec = value.ToObject<Vector2>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } } } else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) { try { material.SetFloat(finalPart, value.ToObject<float>(inputSerializer)); return true; } catch { } } else if (value.Type == JTokenType.Boolean) { try { material.SetFloat(finalPart, value.ToObject<bool>(inputSerializer) ? 1f : 0f); return true; } catch { } } else if (value.Type == JTokenType.String) { // Try converting to Texture using the serializer/converter try { Texture texture = value.ToObject<Texture>(inputSerializer); if (texture != null) { material.SetTexture(finalPart, texture); return true; } } catch { } } Debug.LogWarning( $"[SetNestedProperty] Unsupported or failed conversion for material property '{finalPart}' from value: {value.ToString(Formatting.None)}" ); return false; } // For standard properties (not shader specific) PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); if (finalPropInfo != null && finalPropInfo.CanWrite) { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) { finalPropInfo.SetValue(currentObject, convertedValue); return true; } else { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); if (finalFieldInfo != null) { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) { finalFieldInfo.SetValue(currentObject, convertedValue); return true; } else { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { Debug.LogWarning( $"[SetNestedProperty] Could not find final writable property or field '{finalPart}' on type '{currentType.Name}'" ); } } } catch (Exception ex) { Debug.LogError( $"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}" ); } return false; } /// <summary> /// Split a property path into parts, handling both dot notation and array indexers /// </summary> private static string[] SplitPropertyPath(string path) { // Handle complex paths with both dots and array indexers List<string> parts = new List<string>(); int startIndex = 0; bool inBrackets = false; for (int i = 0; i < path.Length; i++) { char c = path[i]; if (c == '[') { inBrackets = true; } else if (c == ']') { inBrackets = false; } else if (c == '.' && !inBrackets) { // Found a dot separator outside of brackets parts.Add(path.Substring(startIndex, i - startIndex)); startIndex = i + 1; } } if (startIndex < path.Length) { parts.Add(path.Substring(startIndex)); } return parts.ToArray(); } /// <summary> /// Simple JToken to Type conversion for common Unity types, using JsonSerializer. /// </summary> // Pass the input serializer private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) { if (token == null || token.Type == JTokenType.Null) { if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) { Debug.LogWarning($"Cannot assign null to non-nullable value type {targetType.Name}. Returning default value."); return Activator.CreateInstance(targetType); } return null; } try { // Use the provided serializer instance which includes our custom converters return token.ToObject(targetType, inputSerializer); } catch (JsonSerializationException jsonEx) { Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}"); // Optionally re-throw or return null/default // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; throw; // Re-throw to indicate failure higher up } catch (ArgumentException argEx) { Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}"); throw; } catch (Exception ex) { Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}"); throw; } // If ToObject succeeded, it would have returned. If it threw, we wouldn't reach here. // This fallback logic is likely unreachable if ToObject covers all cases or throws on failure. // Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}"); // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; } // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- // Keep them temporarily for reference or if specific fallback logic is ever needed. private static Vector3 ParseJTokenToVector3(JToken token) { // ... (implementation - likely replaced by Vector3Converter) ... // Consider removing these if the serializer handles them reliably. if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) { return new Vector3(obj["x"].ToObject<float>(), obj["y"].ToObject<float>(), obj["z"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 3) { return new Vector3(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero."); return Vector3.zero; } private static Vector2 ParseJTokenToVector2(JToken token) { // ... (implementation - likely replaced by Vector2Converter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) { return new Vector2(obj["x"].ToObject<float>(), obj["y"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 2) { return new Vector2(arr[0].ToObject<float>(), arr[1].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); return Vector2.zero; } private static Quaternion ParseJTokenToQuaternion(JToken token) { // ... (implementation - likely replaced by QuaternionConverter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) { return new Quaternion(obj["x"].ToObject<float>(), obj["y"].ToObject<float>(), obj["z"].ToObject<float>(), obj["w"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 4) { return new Quaternion(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity."); return Quaternion.identity; } private static Color ParseJTokenToColor(JToken token) { // ... (implementation - likely replaced by ColorConverter) ... if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a")) { return new Color(obj["r"].ToObject<float>(), obj["g"].ToObject<float>(), obj["b"].ToObject<float>(), obj["a"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 4) { return new Color(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white."); return Color.white; } private static Rect ParseJTokenToRect(JToken token) { // ... (implementation - likely replaced by RectConverter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height")) { return new Rect(obj["x"].ToObject<float>(), obj["y"].ToObject<float>(), obj["width"].ToObject<float>(), obj["height"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 4) { return new Rect(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero."); return Rect.zero; } private static Bounds ParseJTokenToBounds(JToken token) { // ... (implementation - likely replaced by BoundsConverter) ... if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) { // Requires Vector3 conversion, which should ideally use the serializer too Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject<Vector3>(inputSerializer) Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject<Vector3>(inputSerializer) return new Bounds(center, size); } // Array fallback for Bounds is less intuitive, maybe remove? // if (token is JArray arr && arr.Count >= 6) // { // return new Bounds(new Vector3(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>()), new Vector3(arr[3].ToObject<float>(), arr[4].ToObject<float>(), arr[5].ToObject<float>())); // } Debug.LogWarning($"Could not parse JToken '{token}' as Bounds using fallback. Returning new Bounds(Vector3.zero, Vector3.zero)."); return new Bounds(Vector3.zero, Vector3.zero); } // --- End Redundant Parse Helpers --- /// <summary> /// Finds a specific UnityEngine.Object based on a find instruction JObject. /// Primarily used by UnityEngineObjectConverter during deserialization. /// </summary> // Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType. public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) { string findTerm = instruction["find"]?.ToString(); string method = instruction["method"]?.ToString()?.ToLower(); string componentName = instruction["component"]?.ToString(); // Specific component to get if (string.IsNullOrEmpty(findTerm)) { Debug.LogWarning("Find instruction missing 'find' term."); return null; } // Use a flexible default search method if none provided string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; // If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first if (typeof(Material).IsAssignableFrom(targetType) || typeof(Texture).IsAssignableFrom(targetType) || typeof(ScriptableObject).IsAssignableFrom(targetType) || targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc. typeof(AudioClip).IsAssignableFrom(targetType) || typeof(AnimationClip).IsAssignableFrom(targetType) || typeof(Font).IsAssignableFrom(targetType) || typeof(Shader).IsAssignableFrom(targetType) || typeof(ComputeShader).IsAssignableFrom(targetType) || typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check { // Try loading directly by path/GUID first UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); if (asset != null) return asset; asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm); // Try generic if type specific failed if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset; // If direct path failed, try finding by name/type using FindAssets string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name string[] guids = AssetDatabase.FindAssets(searchFilter); if (guids.Length == 1) { asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); if (asset != null) return asset; } else if (guids.Length > 1) { Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); // Optionally return the first one? Or null? Returning null is safer. return null; } // If still not found, fall through to scene search (though unlikely for assets) } // --- Scene Object Search --- // Find the GameObject using the internal finder GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse); if (foundGo == null) { // Don't warn yet, could still be an asset not found above // Debug.LogWarning($"Could not find GameObject using instruction: {instruction}"); return null; } // Now, get the target object/component from the found GameObject if (targetType == typeof(GameObject)) { return foundGo; // We were looking for a GameObject } else if (typeof(Component).IsAssignableFrom(targetType)) { Type componentToGetType = targetType; if (!string.IsNullOrEmpty(componentName)) { Type specificCompType = FindType(componentName); if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) { componentToGetType = specificCompType; } else { Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'."); } } Component foundComp = foundGo.GetComponent(componentToGetType); if (foundComp == null) { Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); } return foundComp; } else { Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}"); return null; } } /// <summary> /// Robust component resolver that avoids Assembly.LoadFrom and works with asmdefs. /// Searches already-loaded assemblies, prioritizing runtime script assemblies. /// </summary> private static Type FindType(string typeName) { if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) { return resolvedType; } // Log the resolver error if type wasn't found if (!string.IsNullOrEmpty(error)) { Debug.LogWarning($"[FindType] {error}"); } return null; } } /// <summary> /// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions. /// Prioritizes runtime (Player) assemblies over Editor assemblies. /// </summary> internal static class ComponentResolver { private static readonly Dictionary<string, Type> CacheByFqn = new(StringComparer.Ordinal); private static readonly Dictionary<string, Type> CacheByName = new(StringComparer.Ordinal); /// <summary> /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. /// Prefers runtime (Player) script assemblies; falls back to Editor assemblies. /// Never uses Assembly.LoadFrom. /// </summary> public static bool TryResolve(string nameOrFullName, out Type type, out string error) { error = string.Empty; type = null!; // Handle null/empty input if (string.IsNullOrWhiteSpace(nameOrFullName)) { error = "Component name cannot be null or empty"; return false; } // 1) Exact cache hits if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true; if (!nameOrFullName.Contains(".") && CacheByName.TryGetValue(nameOrFullName, out type)) return true; type = Type.GetType(nameOrFullName, throwOnError: false); if (IsValidComponent(type)) { Cache(type); return true; } // 2) Search loaded assemblies (prefer Player assemblies) var candidates = FindCandidates(nameOrFullName); if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } #if UNITY_EDITOR // 3) Last resort: Editor-only TypeCache (fast index) var tc = TypeCache.GetTypesDerivedFrom<Component>() .Where(t => NamesMatch(t, nameOrFullName)); candidates = PreferPlayer(tc).ToList(); if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } #endif error = $"Component type '{nameOrFullName}' not found in loaded runtime assemblies. " + "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; type = null!; return false; } private static bool NamesMatch(Type t, string q) => t.Name.Equals(q, StringComparison.Ordinal) || (t.FullName?.Equals(q, StringComparison.Ordinal) ?? false); private static bool IsValidComponent(Type t) => t != null && typeof(Component).IsAssignableFrom(t); private static void Cache(Type t) { if (t.FullName != null) CacheByFqn[t.FullName] = t; CacheByName[t.Name] = t; } private static List<Type> FindCandidates(string query) { bool isShort = !query.Contains('.'); var loaded = AppDomain.CurrentDomain.GetAssemblies(); #if UNITY_EDITOR // Names of Player (runtime) script assemblies (asmdefs + Assembly-CSharp) var playerAsmNames = new HashSet<string>( UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), StringComparer.Ordinal); IEnumerable<System.Reflection.Assembly> playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name)); IEnumerable<System.Reflection.Assembly> editorAsms = loaded.Except(playerAsms); #else IEnumerable<System.Reflection.Assembly> playerAsms = loaded; IEnumerable<System.Reflection.Assembly> editorAsms = Array.Empty<System.Reflection.Assembly>(); #endif static IEnumerable<Type> SafeGetTypes(System.Reflection.Assembly a) { try { return a.GetTypes(); } catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; } } Func<Type, bool> match = isShort ? (t => t.Name.Equals(query, StringComparison.Ordinal)) : (t => t.FullName!.Equals(query, StringComparison.Ordinal)); var fromPlayer = playerAsms.SelectMany(SafeGetTypes) .Where(IsValidComponent) .Where(match); var fromEditor = editorAsms.SelectMany(SafeGetTypes) .Where(IsValidComponent) .Where(match); var list = new List<Type>(fromPlayer); if (list.Count == 0) list.AddRange(fromEditor); return list; } #if UNITY_EDITOR private static IEnumerable<Type> PreferPlayer(IEnumerable<Type> seq) { var player = new HashSet<string>( UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), StringComparer.Ordinal); return seq.OrderBy(t => player.Contains(t.Assembly.GetName().Name) ? 0 : 1); } #endif private static string Ambiguity(string query, IEnumerable<Type> cands) { var lines = cands.Select(t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})"); return $"Multiple component types matched '{query}':\n - " + string.Join("\n - ", lines) + "\nProvide a fully qualified type name to disambiguate."; } /// <summary> /// Gets all accessible property and field names from a component type. /// </summary> public static List<string> GetAllComponentProperties(Type componentType) { if (componentType == null) return new List<string>(); var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.CanWrite) .Select(p => p.Name); var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) .Where(f => !f.IsInitOnly && !f.IsLiteral) .Select(f => f.Name); // Also include SerializeField private fields (common in Unity) var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) .Where(f => f.GetCustomAttribute<SerializeField>() != null) .Select(f => f.Name); return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); } /// <summary> /// Uses AI to suggest the most likely property matches for a user's input. /// </summary> public static List<string> GetAIPropertySuggestions(string userInput, List<string> availableProperties) { if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) return new List<string>(); // Simple caching to avoid repeated AI calls for the same input var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) return cached; try { var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" + $"User requested: \"{userInput}\"\n" + $"Available properties: [{string.Join(", ", availableProperties)}]\n\n" + $"Find 1-3 most likely matches considering:\n" + $"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" + $"- camelCase vs PascalCase vs spaces\n" + $"- Similar meaning/semantics\n" + $"- Common Unity naming patterns\n\n" + $"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" + $"If confidence is low (<70%), return empty string.\n\n" + $"Examples:\n" + $"- \"Max Reach Distance\" → \"maxReachDistance\"\n" + $"- \"Health Points\" → \"healthPoints, hp\"\n" + $"- \"Move Speed\" → \"moveSpeed, movementSpeed\""; // For now, we'll use a simple rule-based approach that mimics AI behavior // This can be replaced with actual AI calls later var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); PropertySuggestionCache[cacheKey] = suggestions; return suggestions; } catch (Exception ex) { Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); return new List<string>(); } } private static readonly Dictionary<string, List<string>> PropertySuggestionCache = new(); /// <summary> /// Rule-based suggestions that mimic AI behavior for property matching. /// This provides immediate value while we could add real AI integration later. /// </summary> private static List<string> GetRuleBasedSuggestions(string userInput, List<string> availableProperties) { var suggestions = new List<string>(); var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); foreach (var property in availableProperties) { var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); // Exact match after cleaning if (cleanedProperty == cleanedInput) { suggestions.Add(property); continue; } // Check if property contains all words from input var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) { suggestions.Add(property); continue; } // Levenshtein distance for close matches if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) { suggestions.Add(property); } } // Prioritize exact matches, then by similarity return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) .Take(3) .ToList(); } /// <summary> /// Calculates Levenshtein distance between two strings for similarity matching. /// </summary> private static int LevenshteinDistance(string s1, string s2) { if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; if (string.IsNullOrEmpty(s2)) return s1.Length; var matrix = new int[s1.Length + 1, s2.Length + 1]; for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; for (int i = 1; i <= s1.Length; i++) { for (int j = 1; j <= s2.Length; j++) { int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; matrix[i, j] = Math.Min(Math.Min( matrix[i - 1, j] + 1, // deletion matrix[i, j - 1] + 1), // insertion matrix[i - 1, j - 1] + cost); // substitution } } return matrix[s1.Length, s2.Length]; } // Removed duplicate ParseVector3 - using the one at line 1114 // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup. // They are now in Helpers.GameObjectSerializer } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ManageScript.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using System.Linq; using System.Collections.Generic; using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; using System.Threading; using System.Security.Cryptography; #if USE_ROSLYN using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Formatting; #endif #if UNITY_EDITOR using UnityEditor.Compilation; #endif namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles CRUD operations for C# scripts within the Unity project. /// /// ROSLYN INSTALLATION GUIDE: /// To enable advanced syntax validation with Roslyn compiler services: /// /// 1. Install Microsoft.CodeAnalysis.CSharp NuGet package: /// - Open Package Manager in Unity /// - Follow the instruction on https://github.com/GlitchEnzo/NuGetForUnity /// /// 2. Open NuGet Package Manager and Install Microsoft.CodeAnalysis.CSharp: /// /// 3. Alternative: Manual DLL installation: /// - Download Microsoft.CodeAnalysis.CSharp.dll and dependencies /// - Place in Assets/Plugins/ folder /// - Ensure .NET compatibility settings are correct /// /// 4. Define USE_ROSLYN symbol: /// - Go to Player Settings > Scripting Define Symbols /// - Add "USE_ROSLYN" to enable Roslyn-based validation /// /// 5. Restart Unity after installation /// /// Note: Without Roslyn, the system falls back to basic structural validation. /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. /// </summary> [McpForUnityTool("manage_script")] public static class ManageScript { /// <summary> /// Resolves a directory under Assets/, preventing traversal and escaping. /// Returns fullPathDir on disk and canonical 'Assets/...' relative path. /// </summary> private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) { string assets = Application.dataPath.Replace('\\', '/'); // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." string rel = (relDir ?? "Scripts").Replace('\\', '/').Trim(); if (string.IsNullOrEmpty(rel)) rel = "Scripts"; if (rel.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(7); rel = rel.TrimStart('/'); string targetDir = Path.Combine(assets, rel).Replace('\\', '/'); string full = Path.GetFullPath(targetDir).Replace('\\', '/'); bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); if (!underAssets) { fullPathDir = null; relPathSafe = null; return false; } // Best-effort symlink guard: if the directory OR ANY ANCESTOR (up to Assets/) is a reparse point/symlink, reject try { var di = new DirectoryInfo(full); while (di != null) { if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) { fullPathDir = null; relPathSafe = null; return false; } var atAssets = string.Equals( di.FullName.Replace('\\', '/'), assets, StringComparison.OrdinalIgnoreCase ); if (atAssets) break; di = di.Parent; } } catch { /* best effort; proceed */ } fullPathDir = full; string tail = full.Length > assets.Length ? full.Substring(assets.Length).TrimStart('/') : string.Empty; relPathSafe = ("Assets/" + tail).TrimEnd('/'); return true; } /// <summary> /// Main handler for script management actions. /// </summary> public static object HandleCommand(JObject @params) { // Handle null parameters if (@params == null) { return Response.Error("invalid_params", "Parameters cannot be null."); } // Extract parameters string action = @params["action"]?.ToString()?.ToLower(); string name = @params["name"]?.ToString(); string path = @params["path"]?.ToString(); // Relative to Assets/ string contents = null; // Check if we have base64 encoded contents bool contentsEncoded = @params["contentsEncoded"]?.ToObject<bool>() ?? false; if (contentsEncoded && @params["encodedContents"] != null) { try { contents = DecodeBase64(@params["encodedContents"].ToString()); } catch (Exception e) { return Response.Error($"Failed to decode script contents: {e.Message}"); } } else { contents = @params["contents"]?.ToString(); } string scriptType = @params["scriptType"]?.ToString(); // For templates/validation string namespaceName = @params["namespace"]?.ToString(); // For organizing code // Validate required parameters if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } if (string.IsNullOrEmpty(name)) { return Response.Error("Name parameter is required."); } // Basic name validation (alphanumeric, underscores, cannot start with number) if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) { return Response.Error( $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." ); } // Resolve and harden target directory under Assets/ if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) { return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); } // Construct file paths string scriptFileName = $"{name}.cs"; string fullPath = Path.Combine(fullPathDir, scriptFileName); string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); // Ensure the target directory exists for create/update if (action == "create" || action == "update") { try { Directory.CreateDirectory(fullPathDir); } catch (Exception e) { return Response.Error( $"Could not create directory '{fullPathDir}': {e.Message}" ); } } // Route to specific action handlers switch (action) { case "create": return CreateScript( fullPath, relativePath, name, contents, scriptType, namespaceName ); case "read": McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); return ReadScript(fullPath, relativePath); case "update": McpLog.Warn("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); return UpdateScript(fullPath, relativePath, name, contents); case "delete": return DeleteScript(fullPath, relativePath); case "apply_text_edits": { var textEdits = @params["edits"] as JArray; string precondition = @params["precondition_sha256"]?.ToString(); // Respect optional options string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); } case "validate": { string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; var chosen = level switch { "basic" => ValidationLevel.Basic, "standard" => ValidationLevel.Standard, "strict" => ValidationLevel.Strict, "comprehensive" => ValidationLevel.Comprehensive, _ => ValidationLevel.Standard }; string fileText; try { fileText = File.ReadAllText(fullPath); } catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); var diags = (diagsRaw ?? Array.Empty<string>()).Select(s => { var m = Regex.Match( s, @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", RegexOptions.CultureInvariant | RegexOptions.Multiline, TimeSpan.FromMilliseconds(250) ); string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; string message = m.Success ? m.Groups[2].Value : s; int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; return new { line = lineNum, col = 0, severity, message }; }).ToArray(); var result = new { diagnostics = diags }; return ok ? Response.Success("Validation completed.", result) : Response.Error("Validation failed.", result); } case "edit": Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); var structEdits = @params["edits"] as JArray; var options = @params["options"] as JObject; return EditScript(fullPath, relativePath, name, structEdits, options); case "get_sha": { try { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); string text = File.ReadAllText(fullPath); string sha = ComputeSha256(text); var fi = new FileInfo(fullPath); long lengthBytes; try { lengthBytes = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetByteCount(text); } catch { lengthBytes = fi.Exists ? fi.Length : 0; } var data = new { uri = $"unity://path/{relativePath}", path = relativePath, sha256 = sha, lengthBytes, lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty }; return Response.Success($"SHA computed for '{relativePath}'.", data); } catch (Exception ex) { return Response.Error($"Failed to compute SHA: {ex.Message}"); } } default: return Response.Error( $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." ); } } /// <summary> /// Decode base64 string to normal text /// </summary> private static string DecodeBase64(string encoded) { byte[] data = Convert.FromBase64String(encoded); return System.Text.Encoding.UTF8.GetString(data); } /// <summary> /// Encode text to base64 string /// </summary> private static string EncodeBase64(string text) { byte[] data = System.Text.Encoding.UTF8.GetBytes(text); return Convert.ToBase64String(data); } private static object CreateScript( string fullPath, string relativePath, string name, string contents, string scriptType, string namespaceName ) { // Check if script already exists if (File.Exists(fullPath)) { return Response.Error( $"Script already exists at '{relativePath}'. Use 'update' action to modify." ); } // Generate default content if none provided if (string.IsNullOrEmpty(contents)) { contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); } // Validate syntax with detailed error reporting using GUI setting ValidationLevel validationLevel = GetValidationLevelFromGUI(); bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); if (!isValid) { return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty<string>() }); } else if (validationErrors != null && validationErrors.Length > 0) { // Log warnings but don't block creation Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); } try { // Atomic create without BOM; schedule refresh after reply var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, contents, enc); try { File.Move(tmp, fullPath); } catch (IOException) { File.Copy(tmp, fullPath, overwrite: true); try { File.Delete(tmp); } catch { } } var uri = $"unity://path/{relativePath}"; var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", new { uri, scheduledRefresh = false } ); ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); return ok; } catch (Exception e) { return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); } } private static object ReadScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) { return Response.Error($"Script not found at '{relativePath}'."); } try { string contents = File.ReadAllText(fullPath); // Return both normal and encoded contents for larger files bool isLarge = contents.Length > 10000; // If content is large, include encoded version var uri = $"unity://path/{relativePath}"; var responseData = new { uri, path = relativePath, contents = contents, // For large files, also include base64-encoded version encodedContents = isLarge ? EncodeBase64(contents) : null, contentsEncoded = isLarge, }; return Response.Success( $"Script '{Path.GetFileName(relativePath)}' read successfully.", responseData ); } catch (Exception e) { return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); } } private static object UpdateScript( string fullPath, string relativePath, string name, string contents ) { if (!File.Exists(fullPath)) { return Response.Error( $"Script not found at '{relativePath}'. Use 'create' action to add a new script." ); } if (string.IsNullOrEmpty(contents)) { return Response.Error("Content is required for the 'update' action."); } // Validate syntax with detailed error reporting using GUI setting ValidationLevel validationLevel = GetValidationLevelFromGUI(); bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); if (!isValid) { return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty<string>() }); } else if (validationErrors != null && validationErrors.Length > 0) { // Log warnings but don't block update Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); } try { // Safe write with atomic replace when available, without BOM var encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); string tempPath = fullPath + ".tmp"; File.WriteAllText(tempPath, contents, encoding); string backupPath = fullPath + ".bak"; try { File.Replace(tempPath, fullPath, backupPath); try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } } catch (PlatformNotSupportedException) { File.Copy(tempPath, fullPath, true); try { File.Delete(tempPath); } catch { } try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } } catch (IOException) { File.Copy(tempPath, fullPath, true); try { File.Delete(tempPath); } catch { } try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } } // Prepare success response BEFORE any operation that can trigger a domain reload var uri = $"unity://path/{relativePath}"; var ok = Response.Success( $"Script '{name}.cs' updated successfully at '{relativePath}'.", new { uri, path = relativePath, scheduledRefresh = true } ); // Schedule a debounced import/compile on next editor tick to avoid stalling the reply ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); return ok; } catch (Exception e) { return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); } } /// <summary> /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. /// </summary> private const int MaxEditPayloadBytes = 64 * 1024; private static object ApplyTextEdits( string fullPath, string relativePath, string name, JArray edits, string preconditionSha256, string refreshModeFromCaller = null, string validateMode = null) { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); // Refuse edits if the target or any ancestor is a symlink try { var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? ""); while (di != null && !string.Equals(di.FullName.Replace('\\', '/'), Application.dataPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)) { if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) return Response.Error("Refusing to edit a symlinked script path."); di = di.Parent; } } catch { // If checking attributes fails, proceed without the symlink guard } if (edits == null || edits.Count == 0) return Response.Error("No edits provided."); string original; try { original = File.ReadAllText(fullPath); } catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } // Require precondition to avoid drift on large files string currentSha = ComputeSha256(original); if (string.IsNullOrEmpty(preconditionSha256)) return Response.Error("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); // Convert edits to absolute index ranges var spans = new List<(int start, int end, string text)>(); long totalBytes = 0; foreach (var e in edits) { try { int sl = Math.Max(1, e.Value<int>("startLine")); int sc = Math.Max(1, e.Value<int>("startCol")); int el = Math.Max(1, e.Value<int>("endLine")); int ec = Math.Max(1, e.Value<int>("endCol")); string newText = e.Value<string>("newText") ?? string.Empty; if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); if (!TryIndexFromLineCol(original, el, ec, out int eidx)) return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); if (eidx < sidx) (sidx, eidx) = (eidx, sidx); spans.Add((sidx, eidx, newText)); checked { totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); } } catch (Exception ex) { return Response.Error($"Invalid edit payload: {ex.Message}"); } } // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption int headerBoundary = (original.Length > 0 && original[0] == '\uFEFF') ? 1 : 0; // skip BOM once if present // Find first top-level using (supports alias, static, and dotted namespaces) var mUsing = System.Text.RegularExpressions.Regex.Match( original, @"(?m)^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*;", System.Text.RegularExpressions.RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2) ); if (mUsing.Success) { headerBoundary = Math.Min(Math.Max(headerBoundary, mUsing.Index), original.Length); } foreach (var sp in spans) { if (sp.start < headerBoundary) { return Response.Error("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); } } // Attempt auto-upgrade: if a single edit targets a method header/body, re-route as structured replace_method if (spans.Count == 1) { var sp = spans[0]; // Heuristic: around the start of the edit, try to match a method header in original int searchStart = Math.Max(0, sp.start - 200); int searchEnd = Math.Min(original.Length, sp.start + 200); string slice = original.Substring(searchStart, searchEnd - searchStart); var rx = new System.Text.RegularExpressions.Regex(@"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial)[\s\S]*?\b([A-Za-z_][A-Za-z0-9_]*)\s*\("); var mh = rx.Match(slice); if (mh.Success) { string methodName = mh.Groups[1].Value; // Find class span containing the edit if (TryComputeClassSpan(original, name, null, out var clsStart, out var clsLen, out _)) { if (TryComputeMethodSpan(original, clsStart, clsLen, methodName, null, null, null, out var mStart, out var mLen, out _)) { // If the edit overlaps the method span significantly, treat as replace_method if (sp.start <= mStart + 2 && sp.end >= mStart + 1) { var structEdits = new JArray(); // Apply the edit to get a candidate string, then recompute method span on the edited text string candidate = original.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); string replacementText; if (TryComputeClassSpan(candidate, name, null, out var cls2Start, out var cls2Len, out _) && TryComputeMethodSpan(candidate, cls2Start, cls2Len, methodName, null, null, null, out var m2Start, out var m2Len, out _)) { replacementText = candidate.Substring(m2Start, m2Len); } else { // Fallback: adjust method start by the net delta if the edit was before the method int delta = (sp.text?.Length ?? 0) - (sp.end - sp.start); int adjustedStart = mStart + (sp.start <= mStart ? delta : 0); adjustedStart = Math.Max(0, Math.Min(adjustedStart, candidate.Length)); // If the edit was within the original method span, adjust the length by the delta within-method int withinMethodDelta = 0; if (sp.start >= mStart && sp.start <= mStart + mLen) { withinMethodDelta = delta; } int adjustedLen = mLen + withinMethodDelta; adjustedLen = Math.Max(0, Math.Min(candidate.Length - adjustedStart, adjustedLen)); replacementText = candidate.Substring(adjustedStart, adjustedLen); } var op = new JObject { ["mode"] = "replace_method", ["className"] = name, ["methodName"] = methodName, ["replacement"] = replacementText }; structEdits.Add(op); // Reuse structured path return EditScript(fullPath, relativePath, name, structEdits, new JObject { ["refresh"] = "immediate", ["validate"] = "standard" }); } } } } } if (totalBytes > MaxEditPayloadBytes) { return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); } // Ensure non-overlap and apply from back to front spans = spans.OrderByDescending(t => t.start).ToList(); for (int i = 1; i < spans.Count; i++) { if (spans[i].end > spans[i - 1].start) { var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); } } string working = original; bool relaxed = string.Equals(validateMode, "relaxed", StringComparison.OrdinalIgnoreCase); bool syntaxOnly = string.Equals(validateMode, "syntax", StringComparison.OrdinalIgnoreCase); foreach (var sp in spans) { string next = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); if (relaxed) { // Scoped balance check: validate just around the changed region to avoid false positives int originalLength = sp.end - sp.start; int newLength = sp.text?.Length ?? 0; int endPos = sp.start + newLength; if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 500))) { return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); } } working = next; } // No-op guard: if resulting text is identical, avoid writes and return explicit no-op if (string.Equals(working, original, StringComparison.Ordinal)) { string noChangeSha = ComputeSha256(original); return Response.Success( $"No-op: contents unchanged for '{relativePath}'.", new { uri = $"unity://path/{relativePath}", path = relativePath, editsApplied = 0, no_op = true, sha256 = noChangeSha, evidence = new { reason = "identical_content" } } ); } // Always check final structural balance regardless of relaxed mode if (!CheckBalancedDelimiters(working, out int line, out char expected)) { int startLine = Math.Max(1, line - 5); int endLine = line + 5; string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); } #if USE_ROSLYN if (!syntaxOnly) { var tree = CSharpSyntaxTree.ParseText(working); var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) .Select(d => new { line = d.Location.GetLineSpan().StartLinePosition.Line + 1, col = d.Location.GetLineSpan().StartLinePosition.Character + 1, code = d.Id, message = d.GetMessage() }).ToArray(); if (diagnostics.Length > 0) { int firstLine = diagnostics[0].line; int startLineRos = Math.Max(1, firstLine - 5); int endLineRos = firstLine + 5; return Response.Error("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); } // Optional formatting try { var root = tree.GetRoot(); var workspace = new AdhocWorkspace(); root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); working = root.ToFullString(); } catch { } } #endif string newSha = ComputeSha256(working); // Atomic write and schedule refresh try { var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, working, enc); string backup = fullPath + ".bak"; try { File.Replace(tmp, fullPath, backup); try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } } catch (PlatformNotSupportedException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } try { if (File.Exists(backup)) File.Delete(backup); } catch { } } catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } try { if (File.Exists(backup)) File.Delete(backup); } catch { } } // Respect refresh mode: immediate vs debounced bool immediate = string.Equals(refreshModeFromCaller, "immediate", StringComparison.OrdinalIgnoreCase) || string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); if (immediate) { McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); AssetDatabase.ImportAsset( relativePath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate ); #if UNITY_EDITOR UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif } else { McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } return Response.Success( $"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { uri = $"unity://path/{relativePath}", path = relativePath, editsApplied = spans.Count, sha256 = newSha, scheduledRefresh = !immediate } ); } catch (Exception ex) { return Response.Error($"Failed to write edits: {ex.Message}"); } } private static bool TryIndexFromLineCol(string text, int line1, int col1, out int index) { // 1-based line/col to absolute index (0-based), col positions are counted in code points int line = 1, col = 1; for (int i = 0; i <= text.Length; i++) { if (line == line1 && col == col1) { index = i; return true; } if (i == text.Length) break; char c = text[i]; if (c == '\r') { // Treat CRLF as a single newline; skip the LF if present if (i + 1 < text.Length && text[i + 1] == '\n') i++; line++; col = 1; } else if (c == '\n') { line++; col = 1; } else { col++; } } index = -1; return false; } private static string ComputeSha256(string contents) { using (var sha = SHA256.Create()) { var bytes = System.Text.Encoding.UTF8.GetBytes(contents); var hash = sha.ComputeHash(bytes); return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); } } private static bool CheckBalancedDelimiters(string text, out int line, out char expected) { var braceStack = new Stack<int>(); var parenStack = new Stack<int>(); var bracketStack = new Stack<int>(); bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false; line = 1; expected = '\0'; for (int i = 0; i < text.Length; i++) { char c = text[i]; char next = i + 1 < text.Length ? text[i + 1] : '\0'; if (c == '\n') { line++; if (inSingle) inSingle = false; } if (escape) { escape = false; continue; } if (inString) { if (c == '\\') { escape = true; } else if (c == '"') inString = false; continue; } if (inChar) { if (c == '\\') { escape = true; } else if (c == '\'') inChar = false; continue; } if (inSingle) continue; if (inMulti) { if (c == '*' && next == '/') { inMulti = false; i++; } continue; } if (c == '"') { inString = true; continue; } if (c == '\'') { inChar = true; continue; } if (c == '/' && next == '/') { inSingle = true; i++; continue; } if (c == '/' && next == '*') { inMulti = true; i++; continue; } switch (c) { case '{': braceStack.Push(line); break; case '}': if (braceStack.Count == 0) { expected = '{'; return false; } braceStack.Pop(); break; case '(': parenStack.Push(line); break; case ')': if (parenStack.Count == 0) { expected = '('; return false; } parenStack.Pop(); break; case '[': bracketStack.Push(line); break; case ']': if (bracketStack.Count == 0) { expected = '['; return false; } bracketStack.Pop(); break; } } if (braceStack.Count > 0) { line = braceStack.Peek(); expected = '}'; return false; } if (parenStack.Count > 0) { line = parenStack.Peek(); expected = ')'; return false; } if (bracketStack.Count > 0) { line = bracketStack.Peek(); expected = ']'; return false; } return true; } // Lightweight scoped balance: checks delimiters within a substring, ignoring outer context private static bool CheckScopedBalance(string text, int start, int end) { start = Math.Max(0, Math.Min(text.Length, start)); end = Math.Max(start, Math.Min(text.Length, end)); int brace = 0, paren = 0, bracket = 0; bool inStr = false, inChr = false, esc = false; for (int i = start; i < end; i++) { char c = text[i]; char n = (i + 1 < end) ? text[i + 1] : '\0'; if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } if (inChr) { if (!esc && c == '\'') inChr = false; esc = (!esc && c == '\\'); continue; } if (c == '"') { inStr = true; esc = false; continue; } if (c == '\'') { inChr = true; esc = false; continue; } if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; } if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; } if (c == '{') brace++; else if (c == '}') brace--; else if (c == '(') paren++; else if (c == ')') paren--; else if (c == '[') bracket++; else if (c == ']') bracket--; // Allow temporary negative balance - will check tolerance at end } return brace >= -3 && paren >= -3 && bracket >= -3; // tolerate more context from outside region } private static object DeleteScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) { return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); } try { // Use AssetDatabase.MoveAssetToTrash for safer deletion (allows undo) bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); if (deleted) { AssetDatabase.Refresh(); return Response.Success( $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", new { deleted = true } ); } else { // Fallback or error if MoveAssetToTrash fails return Response.Error( $"Failed to move script '{relativePath}' to trash. It might be locked or in use." ); } } catch (Exception e) { return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); } } /// <summary> /// Structured edits (AST-backed where available) on existing scripts. /// Supports class-level replace/delete with Roslyn span computation if USE_ROSLYN is defined, /// otherwise falls back to a conservative balanced-brace scan. /// </summary> private static object EditScript( string fullPath, string relativePath, string name, JArray edits, JObject options) { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); // Refuse edits if the target is a symlink try { var attrs = File.GetAttributes(fullPath); if ((attrs & FileAttributes.ReparsePoint) != 0) return Response.Error("Refusing to edit a symlinked script path."); } catch { // ignore failures checking attributes and proceed } if (edits == null || edits.Count == 0) return Response.Error("No edits provided."); string original; try { original = File.ReadAllText(fullPath); } catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } string working = original; try { var replacements = new List<(int start, int length, string text)>(); int appliedCount = 0; // Apply mode: atomic (default) computes all spans against original and applies together. // Sequential applies each edit immediately to the current working text (useful for dependent edits). string applyMode = options?["applyMode"]?.ToString()?.ToLowerInvariant(); bool applySequentially = applyMode == "sequential"; foreach (var e in edits) { var op = (JObject)e; var mode = (op.Value<string>("mode") ?? op.Value<string>("op") ?? string.Empty).ToLowerInvariant(); switch (mode) { case "replace_class": { string className = op.Value<string>("className"); string ns = op.Value<string>("namespace"); string replacement = ExtractReplacement(op); if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_class requires 'className'."); if (replacement == null) return Response.Error("replace_class requires 'replacement' (inline or base64)."); if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) return Response.Error($"replace_class failed: {why}"); if (!ValidateClassSnippet(replacement, className, out var vErr)) return Response.Error($"Replacement snippet invalid: {vErr}"); if (applySequentially) { working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); appliedCount++; } else { replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); } break; } case "delete_class": { string className = op.Value<string>("className"); string ns = op.Value<string>("namespace"); if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_class requires 'className'."); if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) return Response.Error($"delete_class failed: {why}"); if (applySequentially) { working = working.Remove(s, l); appliedCount++; } else { replacements.Add((s, l, string.Empty)); } break; } case "replace_method": { string className = op.Value<string>("className"); string ns = op.Value<string>("namespace"); string methodName = op.Value<string>("methodName"); string replacement = ExtractReplacement(op); string returnType = op.Value<string>("returnType"); string parametersSignature = op.Value<string>("parametersSignature"); string attributesContains = op.Value<string>("attributesContains"); if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) return Response.Error($"replace_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) { bool hasDependentInsert = edits.Any(j => j is JObject jo && string.Equals(jo.Value<string>("className"), className, StringComparison.Ordinal) && string.Equals(jo.Value<string>("methodName"), methodName, StringComparison.Ordinal) && ((jo.Value<string>("mode") ?? jo.Value<string>("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; return Response.Error($"replace_method failed: {whyMethod}.{hint}"); } if (applySequentially) { working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); appliedCount++; } else { replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); } break; } case "delete_method": { string className = op.Value<string>("className"); string ns = op.Value<string>("namespace"); string methodName = op.Value<string>("methodName"); string returnType = op.Value<string>("returnType"); string parametersSignature = op.Value<string>("parametersSignature"); string attributesContains = op.Value<string>("attributesContains"); if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) return Response.Error($"delete_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) { bool hasDependentInsert = edits.Any(j => j is JObject jo && string.Equals(jo.Value<string>("className"), className, StringComparison.Ordinal) && string.Equals(jo.Value<string>("methodName"), methodName, StringComparison.Ordinal) && ((jo.Value<string>("mode") ?? jo.Value<string>("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; return Response.Error($"delete_method failed: {whyMethod}.{hint}"); } if (applySequentially) { working = working.Remove(mStart, mLen); appliedCount++; } else { replacements.Add((mStart, mLen, string.Empty)); } break; } case "insert_method": { string className = op.Value<string>("className"); string ns = op.Value<string>("namespace"); string position = (op.Value<string>("position") ?? "end").ToLowerInvariant(); string afterMethodName = op.Value<string>("afterMethodName"); string afterReturnType = op.Value<string>("afterReturnType"); string afterParameters = op.Value<string>("afterParametersSignature"); string afterAttributesContains = op.Value<string>("afterAttributesContains"); string snippet = ExtractReplacement(op); // Harden: refuse empty replacement for inserts if (snippet == null || snippet.Trim().Length == 0) return Response.Error("insert_method requires a non-empty 'replacement' text."); if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) return Response.Error($"insert_method failed to locate class: {whyClass}"); if (position == "after") { if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); int insAt = aStart + aLen; string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); if (applySequentially) { working = working.Insert(insAt, text); appliedCount++; } else { replacements.Add((insAt, 0, text)); } } else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) return Response.Error($"insert_method failed: {whyIns}"); else { string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); if (applySequentially) { working = working.Insert(insAt, text); appliedCount++; } else { replacements.Add((insAt, 0, text)); } } break; } case "anchor_insert": { string anchor = op.Value<string>("anchor"); string position = (op.Value<string>("position") ?? "before").ToLowerInvariant(); string text = op.Value<string>("text") ?? ExtractReplacement(op); if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex)."); if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}"); int insAt = position == "after" ? m.Index + m.Length : m.Index; string norm = NormalizeNewlines(text); if (!norm.EndsWith("\n")) { norm += "\n"; } // Duplicate guard: if identical snippet already exists within this class, skip insert if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _)) { string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) { // Do not insert duplicate; treat as no-op break; } } if (applySequentially) { working = working.Insert(insAt, norm); appliedCount++; } else { replacements.Add((insAt, 0, norm)); } } catch (Exception ex) { return Response.Error($"anchor_insert failed: {ex.Message}"); } break; } case "anchor_delete": { string anchor = op.Value<string>("anchor"); if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); int delAt = m.Index; int delLen = m.Length; if (applySequentially) { working = working.Remove(delAt, delLen); appliedCount++; } else { replacements.Add((delAt, delLen, string.Empty)); } } catch (Exception ex) { return Response.Error($"anchor_delete failed: {ex.Message}"); } break; } case "anchor_replace": { string anchor = op.Value<string>("anchor"); string replacement = op.Value<string>("text") ?? op.Value<string>("replacement") ?? ExtractReplacement(op) ?? string.Empty; if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); int at = m.Index; int len = m.Length; string norm = NormalizeNewlines(replacement); if (applySequentially) { working = working.Remove(at, len).Insert(at, norm); appliedCount++; } else { replacements.Add((at, len, norm)); } } catch (Exception ex) { return Response.Error($"anchor_replace failed: {ex.Message}"); } break; } default: return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); } } if (!applySequentially) { if (HasOverlaps(replacements)) { var ordered = replacements.OrderByDescending(r => r.start).ToList(); for (int i = 1; i < ordered.Count; i++) { if (ordered[i].start + ordered[i].length > ordered[i - 1].start) { var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } }; return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); } } return Response.Error("overlap", new { status = "overlap" }); } foreach (var r in replacements.OrderByDescending(r => r.start)) working = working.Remove(r.start, r.length).Insert(r.start, r.text); appliedCount = replacements.Count; } // Guard against structural imbalance before validation if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal)) return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); // No-op guard for structured edits: if text unchanged, return explicit no-op if (string.Equals(working, original, StringComparison.Ordinal)) { var sameSha = ComputeSha256(original); return Response.Success( $"No-op: contents unchanged for '{relativePath}'.", new { path = relativePath, uri = $"unity://path/{relativePath}", editsApplied = 0, no_op = true, sha256 = sameSha, evidence = new { reason = "identical_content" } } ); } // Validate result using override from options if provided; otherwise GUI strictness var level = GetValidationLevelFromGUI(); try { var validateOpt = options?["validate"]?.ToString()?.ToLowerInvariant(); if (!string.IsNullOrEmpty(validateOpt)) { level = validateOpt switch { "basic" => ValidationLevel.Basic, "standard" => ValidationLevel.Standard, "comprehensive" => ValidationLevel.Comprehensive, "strict" => ValidationLevel.Strict, _ => level }; } } catch { /* ignore option parsing issues */ } if (!ValidateScriptSyntax(working, level, out var errors)) return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty<string>() }); else if (errors != null && errors.Length > 0) Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); // Atomic write with backup; schedule refresh // Decide refresh behavior string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); bool immediate = refreshMode == "immediate" || refreshMode == "sync"; // Persist changes atomically (no BOM), then compute/return new file SHA var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, working, enc); var backup = fullPath + ".bak"; try { File.Replace(tmp, fullPath, backup); try { if (File.Exists(backup)) File.Delete(backup); } catch { } } catch (PlatformNotSupportedException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } try { if (File.Exists(backup)) File.Delete(backup); } catch { } } catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } try { if (File.Exists(backup)) File.Delete(backup); } catch { } } var newSha = ComputeSha256(working); var ok = Response.Success( $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", new { path = relativePath, uri = $"unity://path/{relativePath}", editsApplied = appliedCount, scheduledRefresh = !immediate, sha256 = newSha } ); if (immediate) { McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false); ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); } else { ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } return ok; } catch (Exception ex) { return Response.Error($"Edit failed: {ex.Message}"); } } private static bool HasOverlaps(IEnumerable<(int start, int length, string text)> list) { var arr = list.OrderBy(x => x.start).ToArray(); for (int i = 1; i < arr.Length; i++) { if (arr[i - 1].start + arr[i - 1].length > arr[i].start) return true; } return false; } private static string ExtractReplacement(JObject op) { var inline = op.Value<string>("replacement"); if (!string.IsNullOrEmpty(inline)) return inline; var b64 = op.Value<string>("replacementBase64"); if (!string.IsNullOrEmpty(b64)) { try { return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } catch { return null; } } return null; } private static string NormalizeNewlines(string t) { if (string.IsNullOrEmpty(t)) return t; return t.Replace("\r\n", "\n").Replace("\r", "\n"); } private static bool ValidateClassSnippet(string snippet, string expectedName, out string err) { #if USE_ROSLYN try { var tree = CSharpSyntaxTree.ParseText(snippet); var root = tree.GetRoot(); var classes = root.DescendantNodes().OfType<Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax>().ToList(); if (classes.Count != 1) { err = "snippet must contain exactly one class declaration"; return false; } // Optional: enforce expected name // if (classes[0].Identifier.ValueText != expectedName) { err = $"snippet declares '{classes[0].Identifier.ValueText}', expected '{expectedName}'"; return false; } err = null; return true; } catch (Exception ex) { err = ex.Message; return false; } #else if (string.IsNullOrWhiteSpace(snippet) || !snippet.Contains("class ")) { err = "no 'class' keyword found in snippet"; return false; } err = null; return true; #endif } private static bool TryComputeClassSpan(string source, string className, string ns, out int start, out int length, out string why) { #if USE_ROSLYN try { var tree = CSharpSyntaxTree.ParseText(source); var root = tree.GetRoot(); var classes = root.DescendantNodes() .OfType<Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax>() .Where(c => c.Identifier.ValueText == className); if (!string.IsNullOrEmpty(ns)) { classes = classes.Where(c => (c.FirstAncestorOrSelf<Microsoft.CodeAnalysis.CSharp.Syntax.NamespaceDeclarationSyntax>()?.Name?.ToString() ?? "") == ns || (c.FirstAncestorOrSelf<Microsoft.CodeAnalysis.CSharp.Syntax.FileScopedNamespaceDeclarationSyntax>()?.Name?.ToString() ?? "") == ns); } var list = classes.ToList(); if (list.Count == 0) { start = length = 0; why = $"class '{className}' not found" + (ns != null ? $" in namespace '{ns}'" : ""); return false; } if (list.Count > 1) { start = length = 0; why = $"class '{className}' matched {list.Count} declarations (partial/nested?). Disambiguate."; return false; } var cls = list[0]; var span = cls.FullSpan; // includes attributes & leading trivia start = span.Start; length = span.Length; why = null; return true; } catch { // fall back below } #endif return TryComputeClassSpanBalanced(source, className, ns, out start, out length, out why); } private static bool TryComputeClassSpanBalanced(string source, string className, string ns, out int start, out int length, out string why) { start = length = 0; why = null; var idx = IndexOfClassToken(source, className); if (idx < 0) { why = $"class '{className}' not found (balanced scan)"; return false; } if (!string.IsNullOrEmpty(ns) && !AppearsWithinNamespaceHeader(source, idx, ns)) { why = $"class '{className}' not under namespace '{ns}' (balanced scan)"; return false; } // Include modifiers/attributes on the same line: back up to the start of line int lineStart = idx; while (lineStart > 0 && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; int i = idx; while (i < source.Length && source[i] != '{') i++; if (i >= source.Length) { why = "no opening brace after class header"; return false; } int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; int startSpan = lineStart; for (; i < source.Length; i++) { char c = source[i]; char n = i + 1 < source.Length ? source[i + 1] : '\0'; if (inSL) { if (c == '\n') inSL = false; continue; } if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } if (c == '/' && n == '/') { inSL = true; i++; continue; } if (c == '/' && n == '*') { inML = true; i++; continue; } if (c == '"') { inStr = true; continue; } if (c == '\'') { inChar = true; continue; } if (c == '{') { depth++; } else if (c == '}') { depth--; if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } if (depth < 0) { why = "brace underflow"; return false; } } } why = "unterminated class block"; return false; } private static bool TryComputeMethodSpan( string source, int classStart, int classLength, string methodName, string returnType, string parametersSignature, string attributesContains, out int start, out int length, out string why) { start = length = 0; why = null; int searchStart = classStart; int searchEnd = Math.Min(source.Length, classStart + classLength); // 1) Find the method header using a stricter regex (allows optional attributes above) string rtPattern = string.IsNullOrEmpty(returnType) ? @"[^\s]+" : Regex.Escape(returnType).Replace("\\ ", "\\s+"); string namePattern = Regex.Escape(methodName); // If a parametersSignature is provided, it may include surrounding parentheses. Strip them so // we can safely embed the signature inside our own parenthesis group without duplicating. string paramsPattern; if (string.IsNullOrEmpty(parametersSignature)) { paramsPattern = @"[\s\S]*?"; // permissive when not specified } else { string ps = parametersSignature.Trim(); if (ps.StartsWith("(") && ps.EndsWith(")") && ps.Length >= 2) { ps = ps.Substring(1, ps.Length - 2); } // Escape literal text of the signature paramsPattern = Regex.Escape(ps); } string pattern = @"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*" + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; string slice = source.Substring(searchStart, searchEnd - searchStart); var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); if (!headerMatch.Success) { why = $"method '{methodName}' header not found in class"; return false; } int headerIndex = searchStart + headerMatch.Index; // Optional attributes filter: look upward from headerIndex for contiguous attribute lines if (!string.IsNullOrEmpty(attributesContains)) { int attrScanStart = headerIndex; while (attrScanStart > searchStart) { int prevNl = source.LastIndexOf('\n', attrScanStart - 1); if (prevNl < 0 || prevNl < searchStart) break; string prevLine = source.Substring(prevNl + 1, attrScanStart - (prevNl + 1)); if (prevLine.TrimStart().StartsWith("[")) { attrScanStart = prevNl; continue; } break; } string attrBlock = source.Substring(attrScanStart, headerIndex - attrScanStart); if (attrBlock.IndexOf(attributesContains, StringComparison.Ordinal) < 0) { why = $"method '{methodName}' found but attributes filter did not match"; return false; } } // backtrack to the very start of header/attributes to include in span int lineStart = headerIndex; while (lineStart > searchStart && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; // If previous lines are attributes, include them int attrStart = lineStart; int probe = lineStart - 1; while (probe > searchStart) { int prevNl = source.LastIndexOf('\n', probe); if (prevNl < 0 || prevNl < searchStart) break; string prev = source.Substring(prevNl + 1, attrStart - (prevNl + 1)); if (prev.TrimStart().StartsWith("[")) { attrStart = prevNl + 1; probe = prevNl - 1; } else break; } // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end // Find the '(' that belongs to the method signature, not attributes int nameTokenIdx = IndexOfTokenWithin(source, methodName, headerIndex, searchEnd); if (nameTokenIdx < 0) { why = $"method '{methodName}' token not found after header"; return false; } int sigOpenParen = IndexOfTokenWithin(source, "(", nameTokenIdx, searchEnd); if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; } int i = sigOpenParen; int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; for (; i < searchEnd; i++) { char c = source[i]; char n = i + 1 < searchEnd ? source[i + 1] : '\0'; if (inSL) { if (c == '\n') inSL = false; continue; } if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } if (c == '/' && n == '/') { inSL = true; i++; continue; } if (c == '/' && n == '*') { inML = true; i++; continue; } if (c == '"') { inStr = true; continue; } if (c == '\'') { inChar = true; continue; } if (c == '(') parenDepth++; if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } } } // After params: detect expression-bodied or block-bodied // Skip whitespace/comments for (; i < searchEnd; i++) { char c = source[i]; char n = i + 1 < searchEnd ? source[i + 1] : '\0'; if (char.IsWhiteSpace(c)) continue; if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } break; } // Tolerate generic constraints between params and body: multiple 'where T : ...' for (; ; ) { // Skip whitespace/comments before checking for 'where' for (; i < searchEnd; i++) { char c = source[i]; char n = i + 1 < searchEnd ? source[i + 1] : '\0'; if (char.IsWhiteSpace(c)) continue; if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } break; } // Check word-boundary 'where' bool hasWhere = false; if (i + 5 <= searchEnd) { hasWhere = source[i] == 'w' && source[i + 1] == 'h' && source[i + 2] == 'e' && source[i + 3] == 'r' && source[i + 4] == 'e'; if (hasWhere) { // Left boundary if (i - 1 >= 0) { char lb = source[i - 1]; if (char.IsLetterOrDigit(lb) || lb == '_') hasWhere = false; } // Right boundary if (hasWhere && i + 5 < searchEnd) { char rb = source[i + 5]; if (char.IsLetterOrDigit(rb) || rb == '_') hasWhere = false; } } } if (!hasWhere) break; // Advance past the entire where-constraint clause until we hit '{' or '=>' or ';' i += 5; // past 'where' while (i < searchEnd) { char c = source[i]; char n = i + 1 < searchEnd ? source[i + 1] : '\0'; if (c == '{' || c == ';' || (c == '=' && n == '>')) break; // Skip comments inline if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } i++; } } // Re-check for expression-bodied after constraints if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') { // expression-bodied method: seek to terminating semicolon int j = i; bool done = false; while (j < searchEnd) { char c = source[j]; if (c == ';') { done = true; break; } j++; } if (!done) { why = "unterminated expression-bodied method"; return false; } start = attrStart; length = (j - attrStart) + 1; return true; } if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; } int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false; int startSpan = attrStart; for (; i < searchEnd; i++) { char c = source[i]; char n = i + 1 < searchEnd ? source[i + 1] : '\0'; if (inSL) { if (c == '\n') inSL = false; continue; } if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } if (c == '/' && n == '/') { inSL = true; i++; continue; } if (c == '/' && n == '*') { inML = true; i++; continue; } if (c == '"') { inStr = true; continue; } if (c == '\'') { inChar = true; continue; } if (c == '{') depth++; else if (c == '}') { depth--; if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } if (depth < 0) { why = "brace underflow in method"; return false; } } } why = "unterminated method block"; return false; } private static int IndexOfTokenWithin(string s, string token, int start, int end) { int idx = s.IndexOf(token, start, StringComparison.Ordinal); return (idx >= 0 && idx < end) ? idx : -1; } private static bool TryFindClassInsertionPoint(string source, int classStart, int classLength, string position, out int insertAt, out string why) { insertAt = 0; why = null; int searchStart = classStart; int searchEnd = Math.Min(source.Length, classStart + classLength); if (position == "start") { // find first '{' after class header, insert just after with a newline int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); if (i < 0) { why = "could not find class opening brace"; return false; } insertAt = i + 1; return true; } else // end { // walk to matching closing brace of class and insert just before it int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); if (i < 0) { why = "could not find class opening brace"; return false; } int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; for (; i < searchEnd; i++) { char c = source[i]; char n = i + 1 < searchEnd ? source[i + 1] : '\0'; if (inSL) { if (c == '\n') inSL = false; continue; } if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } if (c == '/' && n == '/') { inSL = true; i++; continue; } if (c == '/' && n == '*') { inML = true; i++; continue; } if (c == '"') { inStr = true; continue; } if (c == '\'') { inChar = true; continue; } if (c == '{') depth++; else if (c == '}') { depth--; if (depth == 0) { insertAt = i; return true; } if (depth < 0) { why = "brace underflow while scanning class"; return false; } } } why = "could not find class closing brace"; return false; } } private static int IndexOfClassToken(string s, string className) { // simple token search; could be tightened with Regex for word boundaries var pattern = "class " + className; return s.IndexOf(pattern, StringComparison.Ordinal); } private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns) { int from = Math.Max(0, pos - 2000); var slice = s.Substring(from, pos - from); return slice.Contains("namespace " + ns); } /// <summary> /// Generates basic C# script content based on name and type. /// </summary> private static string GenerateDefaultScriptContent( string name, string scriptType, string namespaceName ) { string usingStatements = "using UnityEngine;\nusing System.Collections;\n"; string classDeclaration; string body = "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; string baseClass = ""; if (!string.IsNullOrEmpty(scriptType)) { if (scriptType.Equals("MonoBehaviour", StringComparison.OrdinalIgnoreCase)) baseClass = " : MonoBehaviour"; else if (scriptType.Equals("ScriptableObject", StringComparison.OrdinalIgnoreCase)) { baseClass = " : ScriptableObject"; body = ""; // ScriptableObjects don't usually need Start/Update } else if ( scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase) ) { usingStatements += "using UnityEditor;\n"; if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) baseClass = " : Editor"; else baseClass = " : EditorWindow"; body = ""; // Editor scripts have different structures } // Add more types as needed } classDeclaration = $"public class {name}{baseClass}"; string fullContent = $"{usingStatements}\n"; bool useNamespace = !string.IsNullOrEmpty(namespaceName); if (useNamespace) { fullContent += $"namespace {namespaceName}\n{{\n"; // Indent class and body if using namespace classDeclaration = " " + classDeclaration; body = string.Join("\n", body.Split('\n').Select(line => " " + line)); } fullContent += $"{classDeclaration}\n{{\n{body}\n}}"; if (useNamespace) { fullContent += "\n}"; // Close namespace } return fullContent.Trim() + "\n"; // Ensure a trailing newline } /// <summary> /// Gets the validation level from the GUI settings /// </summary> private static ValidationLevel GetValidationLevelFromGUI() { string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); return savedLevel.ToLower() switch { "basic" => ValidationLevel.Basic, "standard" => ValidationLevel.Standard, "comprehensive" => ValidationLevel.Comprehensive, "strict" => ValidationLevel.Strict, _ => ValidationLevel.Standard // Default fallback }; } /// <summary> /// Validates C# script syntax using multiple validation layers. /// </summary> private static bool ValidateScriptSyntax(string contents) { return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _); } /// <summary> /// Advanced syntax validation with detailed diagnostics and configurable strictness. /// </summary> private static bool ValidateScriptSyntax(string contents, ValidationLevel level, out string[] errors) { var errorList = new System.Collections.Generic.List<string>(); errors = null; if (string.IsNullOrEmpty(contents)) { return true; // Empty content is valid } // Basic structural validation if (!ValidateBasicStructure(contents, errorList)) { errors = errorList.ToArray(); return false; } #if USE_ROSLYN // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors if (level >= ValidationLevel.Standard) { if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) { errors = errorList.ToArray(); return false; } } #endif // Unity-specific validation if (level >= ValidationLevel.Standard) { ValidateScriptSyntaxUnity(contents, errorList); } // Semantic analysis for common issues if (level >= ValidationLevel.Comprehensive) { ValidateSemanticRules(contents, errorList); } #if USE_ROSLYN // Full semantic compilation validation for Strict level if (level == ValidationLevel.Strict) { if (!ValidateScriptSemantics(contents, errorList)) { errors = errorList.ToArray(); return false; // Strict level fails on any semantic errors } } #endif errors = errorList.ToArray(); return errorList.Count == 0 || (level != ValidationLevel.Strict && !errorList.Any(e => e.StartsWith("ERROR:"))); } /// <summary> /// Validation strictness levels /// </summary> private enum ValidationLevel { Basic, // Only syntax errors Standard, // Syntax + Unity best practices Comprehensive, // All checks + semantic analysis Strict // Treat all issues as errors } /// <summary> /// Validates basic code structure (braces, quotes, comments) /// </summary> private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List<string> errors) { bool isValid = true; int braceBalance = 0; int parenBalance = 0; int bracketBalance = 0; bool inStringLiteral = false; bool inCharLiteral = false; bool inSingleLineComment = false; bool inMultiLineComment = false; bool escaped = false; for (int i = 0; i < contents.Length; i++) { char c = contents[i]; char next = i + 1 < contents.Length ? contents[i + 1] : '\0'; // Handle escape sequences if (escaped) { escaped = false; continue; } if (c == '\\' && (inStringLiteral || inCharLiteral)) { escaped = true; continue; } // Handle comments if (!inStringLiteral && !inCharLiteral) { if (c == '/' && next == '/' && !inMultiLineComment) { inSingleLineComment = true; continue; } if (c == '/' && next == '*' && !inSingleLineComment) { inMultiLineComment = true; i++; // Skip next character continue; } if (c == '*' && next == '/' && inMultiLineComment) { inMultiLineComment = false; i++; // Skip next character continue; } } if (c == '\n') { inSingleLineComment = false; continue; } if (inSingleLineComment || inMultiLineComment) continue; // Handle string and character literals if (c == '"' && !inCharLiteral) { inStringLiteral = !inStringLiteral; continue; } if (c == '\'' && !inStringLiteral) { inCharLiteral = !inCharLiteral; continue; } if (inStringLiteral || inCharLiteral) continue; // Count brackets and braces switch (c) { case '{': braceBalance++; break; case '}': braceBalance--; break; case '(': parenBalance++; break; case ')': parenBalance--; break; case '[': bracketBalance++; break; case ']': bracketBalance--; break; } // Check for negative balances (closing without opening) if (braceBalance < 0) { errors.Add("ERROR: Unmatched closing brace '}'"); isValid = false; } if (parenBalance < 0) { errors.Add("ERROR: Unmatched closing parenthesis ')'"); isValid = false; } if (bracketBalance < 0) { errors.Add("ERROR: Unmatched closing bracket ']'"); isValid = false; } } // Check final balances if (braceBalance != 0) { errors.Add($"ERROR: Unbalanced braces (difference: {braceBalance})"); isValid = false; } if (parenBalance != 0) { errors.Add($"ERROR: Unbalanced parentheses (difference: {parenBalance})"); isValid = false; } if (bracketBalance != 0) { errors.Add($"ERROR: Unbalanced brackets (difference: {bracketBalance})"); isValid = false; } if (inStringLiteral) { errors.Add("ERROR: Unterminated string literal"); isValid = false; } if (inCharLiteral) { errors.Add("ERROR: Unterminated character literal"); isValid = false; } if (inMultiLineComment) { errors.Add("WARNING: Unterminated multi-line comment"); } return isValid; } #if USE_ROSLYN /// <summary> /// Cached compilation references for performance /// </summary> private static System.Collections.Generic.List<MetadataReference> _cachedReferences = null; private static DateTime _cacheTime = DateTime.MinValue; private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5); /// <summary> /// Validates syntax using Roslyn compiler services /// </summary> private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List<string> errors) { try { var syntaxTree = CSharpSyntaxTree.ParseText(contents); var diagnostics = syntaxTree.GetDiagnostics(); bool hasErrors = false; foreach (var diagnostic in diagnostics) { string severity = diagnostic.Severity.ToString().ToUpper(); string message = $"{severity}: {diagnostic.GetMessage()}"; if (diagnostic.Severity == DiagnosticSeverity.Error) { hasErrors = true; } // Include warnings in comprehensive mode if (level >= ValidationLevel.Standard || diagnostic.Severity == DiagnosticSeverity.Error) //Also use Standard for now { var location = diagnostic.Location.GetLineSpan(); if (location.IsValid) { message += $" (Line {location.StartLinePosition.Line + 1})"; } errors.Add(message); } } return !hasErrors; } catch (Exception ex) { errors.Add($"ERROR: Roslyn validation failed: {ex.Message}"); return false; } } /// <summary> /// Validates script semantics using full compilation context to catch namespace, type, and method resolution errors /// </summary> private static bool ValidateScriptSemantics(string contents, System.Collections.Generic.List<string> errors) { try { // Get compilation references with caching var references = GetCompilationReferences(); if (references == null || references.Count == 0) { errors.Add("WARNING: Could not load compilation references for semantic validation"); return true; // Don't fail if we can't get references } // Create syntax tree var syntaxTree = CSharpSyntaxTree.ParseText(contents); // Create compilation with full context var compilation = CSharpCompilation.Create( "TempValidation", new[] { syntaxTree }, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); // Get semantic diagnostics - this catches all the issues you mentioned! var diagnostics = compilation.GetDiagnostics(); bool hasErrors = false; foreach (var diagnostic in diagnostics) { if (diagnostic.Severity == DiagnosticSeverity.Error) { hasErrors = true; var location = diagnostic.Location.GetLineSpan(); string locationInfo = location.IsValid ? $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; // Include diagnostic ID for better error identification string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; errors.Add($"ERROR: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); } else if (diagnostic.Severity == DiagnosticSeverity.Warning) { var location = diagnostic.Location.GetLineSpan(); string locationInfo = location.IsValid ? $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; errors.Add($"WARNING: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); } } return !hasErrors; } catch (Exception ex) { errors.Add($"ERROR: Semantic validation failed: {ex.Message}"); return false; } } /// <summary> /// Gets compilation references with caching for performance /// </summary> private static System.Collections.Generic.List<MetadataReference> GetCompilationReferences() { // Check cache validity if (_cachedReferences != null && DateTime.Now - _cacheTime < CacheExpiry) { return _cachedReferences; } try { var references = new System.Collections.Generic.List<MetadataReference>(); // Core .NET assemblies references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); // mscorlib/System.Private.CoreLib references.Add(MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location)); // System.Linq references.Add(MetadataReference.CreateFromFile(typeof(System.Collections.Generic.List<>).Assembly.Location)); // System.Collections // Unity assemblies try { references.Add(MetadataReference.CreateFromFile(typeof(UnityEngine.Debug).Assembly.Location)); // UnityEngine } catch (Exception ex) { Debug.LogWarning($"Could not load UnityEngine assembly: {ex.Message}"); } #if UNITY_EDITOR try { references.Add(MetadataReference.CreateFromFile(typeof(UnityEditor.Editor).Assembly.Location)); // UnityEditor } catch (Exception ex) { Debug.LogWarning($"Could not load UnityEditor assembly: {ex.Message}"); } // Get Unity project assemblies try { var assemblies = CompilationPipeline.GetAssemblies(); foreach (var assembly in assemblies) { if (File.Exists(assembly.outputPath)) { references.Add(MetadataReference.CreateFromFile(assembly.outputPath)); } } } catch (Exception ex) { Debug.LogWarning($"Could not load Unity project assemblies: {ex.Message}"); } #endif // Cache the results _cachedReferences = references; _cacheTime = DateTime.Now; return references; } catch (Exception ex) { Debug.LogError($"Failed to get compilation references: {ex.Message}"); return new System.Collections.Generic.List<MetadataReference>(); } } #else private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List<string> errors) { // Fallback when Roslyn is not available return true; } #endif /// <summary> /// Validates Unity-specific coding rules and best practices /// //TODO: Naive Unity Checks and not really yield any results, need to be improved /// </summary> private static void ValidateScriptSyntaxUnity(string contents, System.Collections.Generic.List<string> errors) { // Check for common Unity anti-patterns if (contents.Contains("FindObjectOfType") && contents.Contains("Update()")) { errors.Add("WARNING: FindObjectOfType in Update() can cause performance issues"); } if (contents.Contains("GameObject.Find") && contents.Contains("Update()")) { errors.Add("WARNING: GameObject.Find in Update() can cause performance issues"); } // Check for proper MonoBehaviour usage if (contents.Contains(": MonoBehaviour") && !contents.Contains("using UnityEngine")) { errors.Add("WARNING: MonoBehaviour requires 'using UnityEngine;'"); } // Check for SerializeField usage if (contents.Contains("[SerializeField]") && !contents.Contains("using UnityEngine")) { errors.Add("WARNING: SerializeField requires 'using UnityEngine;'"); } // Check for proper coroutine usage if (contents.Contains("StartCoroutine") && !contents.Contains("IEnumerator")) { errors.Add("WARNING: StartCoroutine typically requires IEnumerator methods"); } // Check for Update without FixedUpdate for physics if (contents.Contains("Rigidbody") && contents.Contains("Update()") && !contents.Contains("FixedUpdate()")) { errors.Add("WARNING: Consider using FixedUpdate() for Rigidbody operations"); } // Check for missing null checks on Unity objects if (contents.Contains("GetComponent<") && !contents.Contains("!= null")) { errors.Add("WARNING: Consider null checking GetComponent results"); } // Check for proper event function signatures if (contents.Contains("void Start(") && !contents.Contains("void Start()")) { errors.Add("WARNING: Start() should not have parameters"); } if (contents.Contains("void Update(") && !contents.Contains("void Update()")) { errors.Add("WARNING: Update() should not have parameters"); } // Check for inefficient string operations if (contents.Contains("Update()") && contents.Contains("\"") && contents.Contains("+")) { errors.Add("WARNING: String concatenation in Update() can cause garbage collection issues"); } } /// <summary> /// Validates semantic rules and common coding issues /// </summary> private static void ValidateSemanticRules(string contents, System.Collections.Generic.List<string> errors) { // Check for potential memory leaks if (contents.Contains("new ") && contents.Contains("Update()")) { errors.Add("WARNING: Creating objects in Update() may cause memory issues"); } // Check for magic numbers var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); var matches = magicNumberPattern.Matches(contents); if (matches.Count > 5) { errors.Add("WARNING: Consider using named constants instead of magic numbers"); } // Check for long methods (simple line count check) var methodPattern = new Regex(@"(public|private|protected|internal)?\s*(static)?\s*\w+\s+\w+\s*\([^)]*\)\s*{", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); var methodMatches = methodPattern.Matches(contents); foreach (Match match in methodMatches) { int startIndex = match.Index; int braceCount = 0; int lineCount = 0; bool inMethod = false; for (int i = startIndex; i < contents.Length; i++) { if (contents[i] == '{') { braceCount++; inMethod = true; } else if (contents[i] == '}') { braceCount--; if (braceCount == 0 && inMethod) break; } else if (contents[i] == '\n' && inMethod) { lineCount++; } } if (lineCount > 50) { errors.Add("WARNING: Method is very long, consider breaking it into smaller methods"); break; // Only report once } } // Check for proper exception handling if (contents.Contains("catch") && contents.Contains("catch()")) { errors.Add("WARNING: Empty catch blocks should be avoided"); } // Check for proper async/await usage if (contents.Contains("async ") && !contents.Contains("await")) { errors.Add("WARNING: Async method should contain await or return Task"); } // Check for hardcoded tags and layers if (contents.Contains("\"Player\"") || contents.Contains("\"Enemy\"")) { errors.Add("WARNING: Consider using constants for tags instead of hardcoded strings"); } } //TODO: A easier way for users to update incorrect scripts (now duplicated with the updateScript method and need to also update server side, put aside for now) /// <summary> /// Public method to validate script syntax with configurable validation level /// Returns detailed validation results including errors and warnings /// </summary> // public static object ValidateScript(JObject @params) // { // string contents = @params["contents"]?.ToString(); // string validationLevel = @params["validationLevel"]?.ToString() ?? "standard"; // if (string.IsNullOrEmpty(contents)) // { // return Response.Error("Contents parameter is required for validation."); // } // // Parse validation level // ValidationLevel level = ValidationLevel.Standard; // switch (validationLevel.ToLower()) // { // case "basic": level = ValidationLevel.Basic; break; // case "standard": level = ValidationLevel.Standard; break; // case "comprehensive": level = ValidationLevel.Comprehensive; break; // case "strict": level = ValidationLevel.Strict; break; // default: // return Response.Error($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); // } // // Perform validation // bool isValid = ValidateScriptSyntax(contents, level, out string[] validationErrors); // var errors = validationErrors?.Where(e => e.StartsWith("ERROR:")).ToArray() ?? new string[0]; // var warnings = validationErrors?.Where(e => e.StartsWith("WARNING:")).ToArray() ?? new string[0]; // var result = new // { // isValid = isValid, // validationLevel = validationLevel, // errorCount = errors.Length, // warningCount = warnings.Length, // errors = errors, // warnings = warnings, // summary = isValid // ? (warnings.Length > 0 ? $"Validation passed with {warnings.Length} warnings" : "Validation passed with no issues") // : $"Validation failed with {errors.Length} errors and {warnings.Length} warnings" // }; // if (isValid) // { // return Response.Success("Script validation completed successfully.", result); // } // else // { // return Response.Error("Script validation failed.", result); // } // } } } // Debounced refresh/compile scheduler to coalesce bursts of edits static class RefreshDebounce { private static int _pending; private static readonly object _lock = new object(); private static readonly HashSet<string> _paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase); // The timestamp of the most recent schedule request. private static DateTime _lastRequest; // Guard to ensure we only have a single ticking callback running. private static bool _scheduled; public static void Schedule(string relPath, TimeSpan window) { // Record that work is pending and track the path in a threadsafe way. Interlocked.Exchange(ref _pending, 1); lock (_lock) { _paths.Add(relPath); _lastRequest = DateTime.UtcNow; // If a debounce timer is already scheduled it will pick up the new request. if (_scheduled) return; _scheduled = true; } // Kick off a ticking callback that waits until the window has elapsed // from the last request before performing the refresh. EditorApplication.delayCall += () => Tick(window); // Nudge the editor loop so ticks run even if the window is unfocused EditorApplication.QueuePlayerLoopUpdate(); } private static void Tick(TimeSpan window) { bool ready; lock (_lock) { // Only proceed once the debounce window has fully elapsed. ready = (DateTime.UtcNow - _lastRequest) >= window; if (ready) { _scheduled = false; } } if (!ready) { // Window has not yet elapsed; check again on the next editor tick. EditorApplication.delayCall += () => Tick(window); return; } if (Interlocked.Exchange(ref _pending, 0) == 1) { string[] toImport; lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } foreach (var p in toImport) { var sp = ManageScriptRefreshHelpers.SanitizeAssetsPath(p); AssetDatabase.ImportAsset(sp, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); } #if UNITY_EDITOR UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif // Fallback if needed: // AssetDatabase.Refresh(); } } } static class ManageScriptRefreshHelpers { public static string SanitizeAssetsPath(string p) { if (string.IsNullOrEmpty(p)) return p; p = p.Replace('\\', '/').Trim(); if (p.StartsWith("unity://path/", StringComparison.OrdinalIgnoreCase)) p = p.Substring("unity://path/".Length); while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) p = p.Substring("Assets/".Length); if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) p = "Assets/" + p.TrimStart('/'); return p; } public static void ScheduleScriptRefresh(string relPath) { var sp = SanitizeAssetsPath(relPath); RefreshDebounce.Schedule(sp, TimeSpan.FromMilliseconds(200)); } public static void ImportAndRequestCompile(string relPath, bool synchronous = true) { var sp = SanitizeAssetsPath(relPath); var opts = ImportAssetOptions.ForceUpdate; if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport; AssetDatabase.ImportAsset(sp, opts); #if UNITY_EDITOR UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); #endif } } ```