This is page 17 of 18. Use http://codebase.md/justinpbarnett/unity-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ManageScript.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Collections.Generic; 5 | using System.Text.RegularExpressions; 6 | using Newtonsoft.Json.Linq; 7 | using UnityEditor; 8 | using UnityEngine; 9 | using MCPForUnity.Editor.Helpers; 10 | using System.Threading; 11 | using System.Security.Cryptography; 12 | 13 | #if USE_ROSLYN 14 | using Microsoft.CodeAnalysis; 15 | using Microsoft.CodeAnalysis.CSharp; 16 | using Microsoft.CodeAnalysis.Formatting; 17 | #endif 18 | 19 | #if UNITY_EDITOR 20 | using UnityEditor.Compilation; 21 | #endif 22 | 23 | 24 | namespace MCPForUnity.Editor.Tools 25 | { 26 | /// <summary> 27 | /// Handles CRUD operations for C# scripts within the Unity project. 28 | /// 29 | /// ROSLYN INSTALLATION GUIDE: 30 | /// To enable advanced syntax validation with Roslyn compiler services: 31 | /// 32 | /// 1. Install Microsoft.CodeAnalysis.CSharp NuGet package: 33 | /// - Open Package Manager in Unity 34 | /// - Follow the instruction on https://github.com/GlitchEnzo/NuGetForUnity 35 | /// 36 | /// 2. Open NuGet Package Manager and Install Microsoft.CodeAnalysis.CSharp: 37 | /// 38 | /// 3. Alternative: Manual DLL installation: 39 | /// - Download Microsoft.CodeAnalysis.CSharp.dll and dependencies 40 | /// - Place in Assets/Plugins/ folder 41 | /// - Ensure .NET compatibility settings are correct 42 | /// 43 | /// 4. Define USE_ROSLYN symbol: 44 | /// - Go to Player Settings > Scripting Define Symbols 45 | /// - Add "USE_ROSLYN" to enable Roslyn-based validation 46 | /// 47 | /// 5. Restart Unity after installation 48 | /// 49 | /// Note: Without Roslyn, the system falls back to basic structural validation. 50 | /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. 51 | /// </summary> 52 | [McpForUnityTool("manage_script")] 53 | public static class ManageScript 54 | { 55 | /// <summary> 56 | /// Resolves a directory under Assets/, preventing traversal and escaping. 57 | /// Returns fullPathDir on disk and canonical 'Assets/...' relative path. 58 | /// </summary> 59 | private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) 60 | { 61 | string assets = Application.dataPath.Replace('\\', '/'); 62 | 63 | // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." 64 | string rel = (relDir ?? "Scripts").Replace('\\', '/').Trim(); 65 | if (string.IsNullOrEmpty(rel)) rel = "Scripts"; 66 | if (rel.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(7); 67 | rel = rel.TrimStart('/'); 68 | 69 | string targetDir = Path.Combine(assets, rel).Replace('\\', '/'); 70 | string full = Path.GetFullPath(targetDir).Replace('\\', '/'); 71 | 72 | bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) 73 | || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); 74 | if (!underAssets) 75 | { 76 | fullPathDir = null; 77 | relPathSafe = null; 78 | return false; 79 | } 80 | 81 | // Best-effort symlink guard: if the directory OR ANY ANCESTOR (up to Assets/) is a reparse point/symlink, reject 82 | try 83 | { 84 | var di = new DirectoryInfo(full); 85 | while (di != null) 86 | { 87 | if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) 88 | { 89 | fullPathDir = null; 90 | relPathSafe = null; 91 | return false; 92 | } 93 | var atAssets = string.Equals( 94 | di.FullName.Replace('\\', '/'), 95 | assets, 96 | StringComparison.OrdinalIgnoreCase 97 | ); 98 | if (atAssets) break; 99 | di = di.Parent; 100 | } 101 | } 102 | catch { /* best effort; proceed */ } 103 | 104 | fullPathDir = full; 105 | string tail = full.Length > assets.Length ? full.Substring(assets.Length).TrimStart('/') : string.Empty; 106 | relPathSafe = ("Assets/" + tail).TrimEnd('/'); 107 | return true; 108 | } 109 | /// <summary> 110 | /// Main handler for script management actions. 111 | /// </summary> 112 | public static object HandleCommand(JObject @params) 113 | { 114 | // Handle null parameters 115 | if (@params == null) 116 | { 117 | return Response.Error("invalid_params", "Parameters cannot be null."); 118 | } 119 | 120 | // Extract parameters 121 | string action = @params["action"]?.ToString()?.ToLower(); 122 | string name = @params["name"]?.ToString(); 123 | string path = @params["path"]?.ToString(); // Relative to Assets/ 124 | string contents = null; 125 | 126 | // Check if we have base64 encoded contents 127 | bool contentsEncoded = @params["contentsEncoded"]?.ToObject<bool>() ?? false; 128 | if (contentsEncoded && @params["encodedContents"] != null) 129 | { 130 | try 131 | { 132 | contents = DecodeBase64(@params["encodedContents"].ToString()); 133 | } 134 | catch (Exception e) 135 | { 136 | return Response.Error($"Failed to decode script contents: {e.Message}"); 137 | } 138 | } 139 | else 140 | { 141 | contents = @params["contents"]?.ToString(); 142 | } 143 | 144 | string scriptType = @params["scriptType"]?.ToString(); // For templates/validation 145 | string namespaceName = @params["namespace"]?.ToString(); // For organizing code 146 | 147 | // Validate required parameters 148 | if (string.IsNullOrEmpty(action)) 149 | { 150 | return Response.Error("Action parameter is required."); 151 | } 152 | if (string.IsNullOrEmpty(name)) 153 | { 154 | return Response.Error("Name parameter is required."); 155 | } 156 | // Basic name validation (alphanumeric, underscores, cannot start with number) 157 | if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) 158 | { 159 | return Response.Error( 160 | $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." 161 | ); 162 | } 163 | 164 | // Resolve and harden target directory under Assets/ 165 | if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) 166 | { 167 | return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); 168 | } 169 | 170 | // Construct file paths 171 | string scriptFileName = $"{name}.cs"; 172 | string fullPath = Path.Combine(fullPathDir, scriptFileName); 173 | string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); 174 | 175 | // Ensure the target directory exists for create/update 176 | if (action == "create" || action == "update") 177 | { 178 | try 179 | { 180 | Directory.CreateDirectory(fullPathDir); 181 | } 182 | catch (Exception e) 183 | { 184 | return Response.Error( 185 | $"Could not create directory '{fullPathDir}': {e.Message}" 186 | ); 187 | } 188 | } 189 | 190 | // Route to specific action handlers 191 | switch (action) 192 | { 193 | case "create": 194 | return CreateScript( 195 | fullPath, 196 | relativePath, 197 | name, 198 | contents, 199 | scriptType, 200 | namespaceName 201 | ); 202 | case "read": 203 | McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); 204 | return ReadScript(fullPath, relativePath); 205 | case "update": 206 | McpLog.Warn("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); 207 | return UpdateScript(fullPath, relativePath, name, contents); 208 | case "delete": 209 | return DeleteScript(fullPath, relativePath); 210 | case "apply_text_edits": 211 | { 212 | var textEdits = @params["edits"] as JArray; 213 | string precondition = @params["precondition_sha256"]?.ToString(); 214 | // Respect optional options 215 | string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); 216 | string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); 217 | return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); 218 | } 219 | case "validate": 220 | { 221 | string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; 222 | var chosen = level switch 223 | { 224 | "basic" => ValidationLevel.Basic, 225 | "standard" => ValidationLevel.Standard, 226 | "strict" => ValidationLevel.Strict, 227 | "comprehensive" => ValidationLevel.Comprehensive, 228 | _ => ValidationLevel.Standard 229 | }; 230 | string fileText; 231 | try { fileText = File.ReadAllText(fullPath); } 232 | catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } 233 | 234 | bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); 235 | var diags = (diagsRaw ?? Array.Empty<string>()).Select(s => 236 | { 237 | var m = Regex.Match( 238 | s, 239 | @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", 240 | RegexOptions.CultureInvariant | RegexOptions.Multiline, 241 | TimeSpan.FromMilliseconds(250) 242 | ); 243 | string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; 244 | string message = m.Success ? m.Groups[2].Value : s; 245 | int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; 246 | return new { line = lineNum, col = 0, severity, message }; 247 | }).ToArray(); 248 | 249 | var result = new { diagnostics = diags }; 250 | return ok ? Response.Success("Validation completed.", result) 251 | : Response.Error("Validation failed.", result); 252 | } 253 | case "edit": 254 | Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); 255 | var structEdits = @params["edits"] as JArray; 256 | var options = @params["options"] as JObject; 257 | return EditScript(fullPath, relativePath, name, structEdits, options); 258 | case "get_sha": 259 | { 260 | try 261 | { 262 | if (!File.Exists(fullPath)) 263 | return Response.Error($"Script not found at '{relativePath}'."); 264 | 265 | string text = File.ReadAllText(fullPath); 266 | string sha = ComputeSha256(text); 267 | var fi = new FileInfo(fullPath); 268 | long lengthBytes; 269 | try { lengthBytes = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetByteCount(text); } 270 | catch { lengthBytes = fi.Exists ? fi.Length : 0; } 271 | var data = new 272 | { 273 | uri = $"unity://path/{relativePath}", 274 | path = relativePath, 275 | sha256 = sha, 276 | lengthBytes, 277 | lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty 278 | }; 279 | return Response.Success($"SHA computed for '{relativePath}'.", data); 280 | } 281 | catch (Exception ex) 282 | { 283 | return Response.Error($"Failed to compute SHA: {ex.Message}"); 284 | } 285 | } 286 | default: 287 | return Response.Error( 288 | $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." 289 | ); 290 | } 291 | } 292 | 293 | /// <summary> 294 | /// Decode base64 string to normal text 295 | /// </summary> 296 | private static string DecodeBase64(string encoded) 297 | { 298 | byte[] data = Convert.FromBase64String(encoded); 299 | return System.Text.Encoding.UTF8.GetString(data); 300 | } 301 | 302 | /// <summary> 303 | /// Encode text to base64 string 304 | /// </summary> 305 | private static string EncodeBase64(string text) 306 | { 307 | byte[] data = System.Text.Encoding.UTF8.GetBytes(text); 308 | return Convert.ToBase64String(data); 309 | } 310 | 311 | private static object CreateScript( 312 | string fullPath, 313 | string relativePath, 314 | string name, 315 | string contents, 316 | string scriptType, 317 | string namespaceName 318 | ) 319 | { 320 | // Check if script already exists 321 | if (File.Exists(fullPath)) 322 | { 323 | return Response.Error( 324 | $"Script already exists at '{relativePath}'. Use 'update' action to modify." 325 | ); 326 | } 327 | 328 | // Generate default content if none provided 329 | if (string.IsNullOrEmpty(contents)) 330 | { 331 | contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); 332 | } 333 | 334 | // Validate syntax with detailed error reporting using GUI setting 335 | ValidationLevel validationLevel = GetValidationLevelFromGUI(); 336 | bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); 337 | if (!isValid) 338 | { 339 | return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty<string>() }); 340 | } 341 | else if (validationErrors != null && validationErrors.Length > 0) 342 | { 343 | // Log warnings but don't block creation 344 | Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); 345 | } 346 | 347 | try 348 | { 349 | // Atomic create without BOM; schedule refresh after reply 350 | var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); 351 | var tmp = fullPath + ".tmp"; 352 | File.WriteAllText(tmp, contents, enc); 353 | try 354 | { 355 | File.Move(tmp, fullPath); 356 | } 357 | catch (IOException) 358 | { 359 | File.Copy(tmp, fullPath, overwrite: true); 360 | try { File.Delete(tmp); } catch { } 361 | } 362 | 363 | var uri = $"unity://path/{relativePath}"; 364 | var ok = Response.Success( 365 | $"Script '{name}.cs' created successfully at '{relativePath}'.", 366 | new { uri, scheduledRefresh = false } 367 | ); 368 | 369 | ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); 370 | 371 | return ok; 372 | } 373 | catch (Exception e) 374 | { 375 | return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); 376 | } 377 | } 378 | 379 | private static object ReadScript(string fullPath, string relativePath) 380 | { 381 | if (!File.Exists(fullPath)) 382 | { 383 | return Response.Error($"Script not found at '{relativePath}'."); 384 | } 385 | 386 | try 387 | { 388 | string contents = File.ReadAllText(fullPath); 389 | 390 | // Return both normal and encoded contents for larger files 391 | bool isLarge = contents.Length > 10000; // If content is large, include encoded version 392 | var uri = $"unity://path/{relativePath}"; 393 | var responseData = new 394 | { 395 | uri, 396 | path = relativePath, 397 | contents = contents, 398 | // For large files, also include base64-encoded version 399 | encodedContents = isLarge ? EncodeBase64(contents) : null, 400 | contentsEncoded = isLarge, 401 | }; 402 | 403 | return Response.Success( 404 | $"Script '{Path.GetFileName(relativePath)}' read successfully.", 405 | responseData 406 | ); 407 | } 408 | catch (Exception e) 409 | { 410 | return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); 411 | } 412 | } 413 | 414 | private static object UpdateScript( 415 | string fullPath, 416 | string relativePath, 417 | string name, 418 | string contents 419 | ) 420 | { 421 | if (!File.Exists(fullPath)) 422 | { 423 | return Response.Error( 424 | $"Script not found at '{relativePath}'. Use 'create' action to add a new script." 425 | ); 426 | } 427 | if (string.IsNullOrEmpty(contents)) 428 | { 429 | return Response.Error("Content is required for the 'update' action."); 430 | } 431 | 432 | // Validate syntax with detailed error reporting using GUI setting 433 | ValidationLevel validationLevel = GetValidationLevelFromGUI(); 434 | bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); 435 | if (!isValid) 436 | { 437 | return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty<string>() }); 438 | } 439 | else if (validationErrors != null && validationErrors.Length > 0) 440 | { 441 | // Log warnings but don't block update 442 | Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); 443 | } 444 | 445 | try 446 | { 447 | // Safe write with atomic replace when available, without BOM 448 | var encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); 449 | string tempPath = fullPath + ".tmp"; 450 | File.WriteAllText(tempPath, contents, encoding); 451 | 452 | string backupPath = fullPath + ".bak"; 453 | try 454 | { 455 | File.Replace(tempPath, fullPath, backupPath); 456 | try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } 457 | } 458 | catch (PlatformNotSupportedException) 459 | { 460 | File.Copy(tempPath, fullPath, true); 461 | try { File.Delete(tempPath); } catch { } 462 | try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } 463 | } 464 | catch (IOException) 465 | { 466 | File.Copy(tempPath, fullPath, true); 467 | try { File.Delete(tempPath); } catch { } 468 | try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } 469 | } 470 | 471 | // Prepare success response BEFORE any operation that can trigger a domain reload 472 | var uri = $"unity://path/{relativePath}"; 473 | var ok = Response.Success( 474 | $"Script '{name}.cs' updated successfully at '{relativePath}'.", 475 | new { uri, path = relativePath, scheduledRefresh = true } 476 | ); 477 | 478 | // Schedule a debounced import/compile on next editor tick to avoid stalling the reply 479 | ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); 480 | 481 | return ok; 482 | } 483 | catch (Exception e) 484 | { 485 | return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); 486 | } 487 | } 488 | 489 | /// <summary> 490 | /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. 491 | /// </summary> 492 | private const int MaxEditPayloadBytes = 64 * 1024; 493 | 494 | private static object ApplyTextEdits( 495 | string fullPath, 496 | string relativePath, 497 | string name, 498 | JArray edits, 499 | string preconditionSha256, 500 | string refreshModeFromCaller = null, 501 | string validateMode = null) 502 | { 503 | if (!File.Exists(fullPath)) 504 | return Response.Error($"Script not found at '{relativePath}'."); 505 | // Refuse edits if the target or any ancestor is a symlink 506 | try 507 | { 508 | var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? ""); 509 | while (di != null && !string.Equals(di.FullName.Replace('\\', '/'), Application.dataPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)) 510 | { 511 | if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) 512 | return Response.Error("Refusing to edit a symlinked script path."); 513 | di = di.Parent; 514 | } 515 | } 516 | catch 517 | { 518 | // If checking attributes fails, proceed without the symlink guard 519 | } 520 | if (edits == null || edits.Count == 0) 521 | return Response.Error("No edits provided."); 522 | 523 | string original; 524 | try { original = File.ReadAllText(fullPath); } 525 | catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } 526 | 527 | // Require precondition to avoid drift on large files 528 | string currentSha = ComputeSha256(original); 529 | if (string.IsNullOrEmpty(preconditionSha256)) 530 | return Response.Error("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); 531 | if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) 532 | return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); 533 | 534 | // Convert edits to absolute index ranges 535 | var spans = new List<(int start, int end, string text)>(); 536 | long totalBytes = 0; 537 | foreach (var e in edits) 538 | { 539 | try 540 | { 541 | int sl = Math.Max(1, e.Value<int>("startLine")); 542 | int sc = Math.Max(1, e.Value<int>("startCol")); 543 | int el = Math.Max(1, e.Value<int>("endLine")); 544 | int ec = Math.Max(1, e.Value<int>("endCol")); 545 | string newText = e.Value<string>("newText") ?? string.Empty; 546 | 547 | if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) 548 | return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); 549 | if (!TryIndexFromLineCol(original, el, ec, out int eidx)) 550 | return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); 551 | if (eidx < sidx) (sidx, eidx) = (eidx, sidx); 552 | 553 | spans.Add((sidx, eidx, newText)); 554 | checked 555 | { 556 | totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); 557 | } 558 | } 559 | catch (Exception ex) 560 | { 561 | return Response.Error($"Invalid edit payload: {ex.Message}"); 562 | } 563 | } 564 | 565 | // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption 566 | int headerBoundary = (original.Length > 0 && original[0] == '\uFEFF') ? 1 : 0; // skip BOM once if present 567 | // Find first top-level using (supports alias, static, and dotted namespaces) 568 | var mUsing = System.Text.RegularExpressions.Regex.Match( 569 | original, 570 | @"(?m)^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*;", 571 | System.Text.RegularExpressions.RegexOptions.CultureInvariant, 572 | TimeSpan.FromSeconds(2) 573 | ); 574 | if (mUsing.Success) 575 | { 576 | headerBoundary = Math.Min(Math.Max(headerBoundary, mUsing.Index), original.Length); 577 | } 578 | foreach (var sp in spans) 579 | { 580 | if (sp.start < headerBoundary) 581 | { 582 | 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." }); 583 | } 584 | } 585 | 586 | // Attempt auto-upgrade: if a single edit targets a method header/body, re-route as structured replace_method 587 | if (spans.Count == 1) 588 | { 589 | var sp = spans[0]; 590 | // Heuristic: around the start of the edit, try to match a method header in original 591 | int searchStart = Math.Max(0, sp.start - 200); 592 | int searchEnd = Math.Min(original.Length, sp.start + 200); 593 | string slice = original.Substring(searchStart, searchEnd - searchStart); 594 | 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*\("); 595 | var mh = rx.Match(slice); 596 | if (mh.Success) 597 | { 598 | string methodName = mh.Groups[1].Value; 599 | // Find class span containing the edit 600 | if (TryComputeClassSpan(original, name, null, out var clsStart, out var clsLen, out _)) 601 | { 602 | if (TryComputeMethodSpan(original, clsStart, clsLen, methodName, null, null, null, out var mStart, out var mLen, out _)) 603 | { 604 | // If the edit overlaps the method span significantly, treat as replace_method 605 | if (sp.start <= mStart + 2 && sp.end >= mStart + 1) 606 | { 607 | var structEdits = new JArray(); 608 | 609 | // Apply the edit to get a candidate string, then recompute method span on the edited text 610 | string candidate = original.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); 611 | string replacementText; 612 | if (TryComputeClassSpan(candidate, name, null, out var cls2Start, out var cls2Len, out _) 613 | && TryComputeMethodSpan(candidate, cls2Start, cls2Len, methodName, null, null, null, out var m2Start, out var m2Len, out _)) 614 | { 615 | replacementText = candidate.Substring(m2Start, m2Len); 616 | } 617 | else 618 | { 619 | // Fallback: adjust method start by the net delta if the edit was before the method 620 | int delta = (sp.text?.Length ?? 0) - (sp.end - sp.start); 621 | int adjustedStart = mStart + (sp.start <= mStart ? delta : 0); 622 | adjustedStart = Math.Max(0, Math.Min(adjustedStart, candidate.Length)); 623 | 624 | // If the edit was within the original method span, adjust the length by the delta within-method 625 | int withinMethodDelta = 0; 626 | if (sp.start >= mStart && sp.start <= mStart + mLen) 627 | { 628 | withinMethodDelta = delta; 629 | } 630 | int adjustedLen = mLen + withinMethodDelta; 631 | adjustedLen = Math.Max(0, Math.Min(candidate.Length - adjustedStart, adjustedLen)); 632 | replacementText = candidate.Substring(adjustedStart, adjustedLen); 633 | } 634 | 635 | var op = new JObject 636 | { 637 | ["mode"] = "replace_method", 638 | ["className"] = name, 639 | ["methodName"] = methodName, 640 | ["replacement"] = replacementText 641 | }; 642 | structEdits.Add(op); 643 | // Reuse structured path 644 | return EditScript(fullPath, relativePath, name, structEdits, new JObject { ["refresh"] = "immediate", ["validate"] = "standard" }); 645 | } 646 | } 647 | } 648 | } 649 | } 650 | 651 | if (totalBytes > MaxEditPayloadBytes) 652 | { 653 | return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); 654 | } 655 | 656 | // Ensure non-overlap and apply from back to front 657 | spans = spans.OrderByDescending(t => t.start).ToList(); 658 | for (int i = 1; i < spans.Count; i++) 659 | { 660 | if (spans[i].end > spans[i - 1].start) 661 | { 662 | var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; 663 | return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); 664 | } 665 | } 666 | 667 | string working = original; 668 | bool relaxed = string.Equals(validateMode, "relaxed", StringComparison.OrdinalIgnoreCase); 669 | bool syntaxOnly = string.Equals(validateMode, "syntax", StringComparison.OrdinalIgnoreCase); 670 | foreach (var sp in spans) 671 | { 672 | string next = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); 673 | if (relaxed) 674 | { 675 | // Scoped balance check: validate just around the changed region to avoid false positives 676 | int originalLength = sp.end - sp.start; 677 | int newLength = sp.text?.Length ?? 0; 678 | int endPos = sp.start + newLength; 679 | if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 500))) 680 | { 681 | return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); 682 | } 683 | } 684 | working = next; 685 | } 686 | 687 | // No-op guard: if resulting text is identical, avoid writes and return explicit no-op 688 | if (string.Equals(working, original, StringComparison.Ordinal)) 689 | { 690 | string noChangeSha = ComputeSha256(original); 691 | return Response.Success( 692 | $"No-op: contents unchanged for '{relativePath}'.", 693 | new 694 | { 695 | uri = $"unity://path/{relativePath}", 696 | path = relativePath, 697 | editsApplied = 0, 698 | no_op = true, 699 | sha256 = noChangeSha, 700 | evidence = new { reason = "identical_content" } 701 | } 702 | ); 703 | } 704 | 705 | // Always check final structural balance regardless of relaxed mode 706 | if (!CheckBalancedDelimiters(working, out int line, out char expected)) 707 | { 708 | int startLine = Math.Max(1, line - 5); 709 | int endLine = line + 5; 710 | string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; 711 | return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); 712 | } 713 | 714 | #if USE_ROSLYN 715 | if (!syntaxOnly) 716 | { 717 | var tree = CSharpSyntaxTree.ParseText(working); 718 | var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) 719 | .Select(d => new { 720 | line = d.Location.GetLineSpan().StartLinePosition.Line + 1, 721 | col = d.Location.GetLineSpan().StartLinePosition.Character + 1, 722 | code = d.Id, 723 | message = d.GetMessage() 724 | }).ToArray(); 725 | if (diagnostics.Length > 0) 726 | { 727 | int firstLine = diagnostics[0].line; 728 | int startLineRos = Math.Max(1, firstLine - 5); 729 | int endLineRos = firstLine + 5; 730 | return Response.Error("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); 731 | } 732 | 733 | // Optional formatting 734 | try 735 | { 736 | var root = tree.GetRoot(); 737 | var workspace = new AdhocWorkspace(); 738 | root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); 739 | working = root.ToFullString(); 740 | } 741 | catch { } 742 | } 743 | #endif 744 | 745 | string newSha = ComputeSha256(working); 746 | 747 | // Atomic write and schedule refresh 748 | try 749 | { 750 | var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); 751 | var tmp = fullPath + ".tmp"; 752 | File.WriteAllText(tmp, working, enc); 753 | string backup = fullPath + ".bak"; 754 | try 755 | { 756 | File.Replace(tmp, fullPath, backup); 757 | try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } 758 | } 759 | catch (PlatformNotSupportedException) 760 | { 761 | File.Copy(tmp, fullPath, true); 762 | try { File.Delete(tmp); } catch { } 763 | try { if (File.Exists(backup)) File.Delete(backup); } catch { } 764 | } 765 | catch (IOException) 766 | { 767 | File.Copy(tmp, fullPath, true); 768 | try { File.Delete(tmp); } catch { } 769 | try { if (File.Exists(backup)) File.Delete(backup); } catch { } 770 | } 771 | 772 | // Respect refresh mode: immediate vs debounced 773 | bool immediate = string.Equals(refreshModeFromCaller, "immediate", StringComparison.OrdinalIgnoreCase) || 774 | string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); 775 | if (immediate) 776 | { 777 | McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); 778 | AssetDatabase.ImportAsset( 779 | relativePath, 780 | ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate 781 | ); 782 | #if UNITY_EDITOR 783 | UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); 784 | #endif 785 | } 786 | else 787 | { 788 | McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); 789 | ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); 790 | } 791 | 792 | return Response.Success( 793 | $"Applied {spans.Count} text edit(s) to '{relativePath}'.", 794 | new 795 | { 796 | uri = $"unity://path/{relativePath}", 797 | path = relativePath, 798 | editsApplied = spans.Count, 799 | sha256 = newSha, 800 | scheduledRefresh = !immediate 801 | } 802 | ); 803 | } 804 | catch (Exception ex) 805 | { 806 | return Response.Error($"Failed to write edits: {ex.Message}"); 807 | } 808 | } 809 | 810 | private static bool TryIndexFromLineCol(string text, int line1, int col1, out int index) 811 | { 812 | // 1-based line/col to absolute index (0-based), col positions are counted in code points 813 | int line = 1, col = 1; 814 | for (int i = 0; i <= text.Length; i++) 815 | { 816 | if (line == line1 && col == col1) 817 | { 818 | index = i; 819 | return true; 820 | } 821 | if (i == text.Length) break; 822 | char c = text[i]; 823 | if (c == '\r') 824 | { 825 | // Treat CRLF as a single newline; skip the LF if present 826 | if (i + 1 < text.Length && text[i + 1] == '\n') 827 | i++; 828 | line++; 829 | col = 1; 830 | } 831 | else if (c == '\n') 832 | { 833 | line++; 834 | col = 1; 835 | } 836 | else 837 | { 838 | col++; 839 | } 840 | } 841 | index = -1; 842 | return false; 843 | } 844 | 845 | private static string ComputeSha256(string contents) 846 | { 847 | using (var sha = SHA256.Create()) 848 | { 849 | var bytes = System.Text.Encoding.UTF8.GetBytes(contents); 850 | var hash = sha.ComputeHash(bytes); 851 | return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); 852 | } 853 | } 854 | 855 | private static bool CheckBalancedDelimiters(string text, out int line, out char expected) 856 | { 857 | var braceStack = new Stack<int>(); 858 | var parenStack = new Stack<int>(); 859 | var bracketStack = new Stack<int>(); 860 | bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false; 861 | line = 1; expected = '\0'; 862 | 863 | for (int i = 0; i < text.Length; i++) 864 | { 865 | char c = text[i]; 866 | char next = i + 1 < text.Length ? text[i + 1] : '\0'; 867 | 868 | if (c == '\n') { line++; if (inSingle) inSingle = false; } 869 | 870 | if (escape) { escape = false; continue; } 871 | 872 | if (inString) 873 | { 874 | if (c == '\\') { escape = true; } 875 | else if (c == '"') inString = false; 876 | continue; 877 | } 878 | if (inChar) 879 | { 880 | if (c == '\\') { escape = true; } 881 | else if (c == '\'') inChar = false; 882 | continue; 883 | } 884 | if (inSingle) continue; 885 | if (inMulti) 886 | { 887 | if (c == '*' && next == '/') { inMulti = false; i++; } 888 | continue; 889 | } 890 | 891 | if (c == '"') { inString = true; continue; } 892 | if (c == '\'') { inChar = true; continue; } 893 | if (c == '/' && next == '/') { inSingle = true; i++; continue; } 894 | if (c == '/' && next == '*') { inMulti = true; i++; continue; } 895 | 896 | switch (c) 897 | { 898 | case '{': braceStack.Push(line); break; 899 | case '}': 900 | if (braceStack.Count == 0) { expected = '{'; return false; } 901 | braceStack.Pop(); 902 | break; 903 | case '(': parenStack.Push(line); break; 904 | case ')': 905 | if (parenStack.Count == 0) { expected = '('; return false; } 906 | parenStack.Pop(); 907 | break; 908 | case '[': bracketStack.Push(line); break; 909 | case ']': 910 | if (bracketStack.Count == 0) { expected = '['; return false; } 911 | bracketStack.Pop(); 912 | break; 913 | } 914 | } 915 | 916 | if (braceStack.Count > 0) { line = braceStack.Peek(); expected = '}'; return false; } 917 | if (parenStack.Count > 0) { line = parenStack.Peek(); expected = ')'; return false; } 918 | if (bracketStack.Count > 0) { line = bracketStack.Peek(); expected = ']'; return false; } 919 | 920 | return true; 921 | } 922 | 923 | // Lightweight scoped balance: checks delimiters within a substring, ignoring outer context 924 | private static bool CheckScopedBalance(string text, int start, int end) 925 | { 926 | start = Math.Max(0, Math.Min(text.Length, start)); 927 | end = Math.Max(start, Math.Min(text.Length, end)); 928 | int brace = 0, paren = 0, bracket = 0; 929 | bool inStr = false, inChr = false, esc = false; 930 | for (int i = start; i < end; i++) 931 | { 932 | char c = text[i]; 933 | char n = (i + 1 < end) ? text[i + 1] : '\0'; 934 | if (inStr) 935 | { 936 | if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; 937 | } 938 | if (inChr) 939 | { 940 | if (!esc && c == '\'') inChr = false; esc = (!esc && c == '\\'); continue; 941 | } 942 | if (c == '"') { inStr = true; esc = false; continue; } 943 | if (c == '\'') { inChr = true; esc = false; continue; } 944 | if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; } 945 | if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; } 946 | if (c == '{') brace++; 947 | else if (c == '}') brace--; 948 | else if (c == '(') paren++; 949 | else if (c == ')') paren--; 950 | else if (c == '[') bracket++; else if (c == ']') bracket--; 951 | // Allow temporary negative balance - will check tolerance at end 952 | } 953 | return brace >= -3 && paren >= -3 && bracket >= -3; // tolerate more context from outside region 954 | } 955 | 956 | private static object DeleteScript(string fullPath, string relativePath) 957 | { 958 | if (!File.Exists(fullPath)) 959 | { 960 | return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); 961 | } 962 | 963 | try 964 | { 965 | // Use AssetDatabase.MoveAssetToTrash for safer deletion (allows undo) 966 | bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); 967 | if (deleted) 968 | { 969 | AssetDatabase.Refresh(); 970 | return Response.Success( 971 | $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", 972 | new { deleted = true } 973 | ); 974 | } 975 | else 976 | { 977 | // Fallback or error if MoveAssetToTrash fails 978 | return Response.Error( 979 | $"Failed to move script '{relativePath}' to trash. It might be locked or in use." 980 | ); 981 | } 982 | } 983 | catch (Exception e) 984 | { 985 | return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); 986 | } 987 | } 988 | 989 | /// <summary> 990 | /// Structured edits (AST-backed where available) on existing scripts. 991 | /// Supports class-level replace/delete with Roslyn span computation if USE_ROSLYN is defined, 992 | /// otherwise falls back to a conservative balanced-brace scan. 993 | /// </summary> 994 | private static object EditScript( 995 | string fullPath, 996 | string relativePath, 997 | string name, 998 | JArray edits, 999 | JObject options) 1000 | { 1001 | if (!File.Exists(fullPath)) 1002 | return Response.Error($"Script not found at '{relativePath}'."); 1003 | // Refuse edits if the target is a symlink 1004 | try 1005 | { 1006 | var attrs = File.GetAttributes(fullPath); 1007 | if ((attrs & FileAttributes.ReparsePoint) != 0) 1008 | return Response.Error("Refusing to edit a symlinked script path."); 1009 | } 1010 | catch 1011 | { 1012 | // ignore failures checking attributes and proceed 1013 | } 1014 | if (edits == null || edits.Count == 0) 1015 | return Response.Error("No edits provided."); 1016 | 1017 | string original; 1018 | try { original = File.ReadAllText(fullPath); } 1019 | catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } 1020 | 1021 | string working = original; 1022 | 1023 | try 1024 | { 1025 | var replacements = new List<(int start, int length, string text)>(); 1026 | int appliedCount = 0; 1027 | 1028 | // Apply mode: atomic (default) computes all spans against original and applies together. 1029 | // Sequential applies each edit immediately to the current working text (useful for dependent edits). 1030 | string applyMode = options?["applyMode"]?.ToString()?.ToLowerInvariant(); 1031 | bool applySequentially = applyMode == "sequential"; 1032 | 1033 | foreach (var e in edits) 1034 | { 1035 | var op = (JObject)e; 1036 | var mode = (op.Value<string>("mode") ?? op.Value<string>("op") ?? string.Empty).ToLowerInvariant(); 1037 | 1038 | switch (mode) 1039 | { 1040 | case "replace_class": 1041 | { 1042 | string className = op.Value<string>("className"); 1043 | string ns = op.Value<string>("namespace"); 1044 | string replacement = ExtractReplacement(op); 1045 | 1046 | if (string.IsNullOrWhiteSpace(className)) 1047 | return Response.Error("replace_class requires 'className'."); 1048 | if (replacement == null) 1049 | return Response.Error("replace_class requires 'replacement' (inline or base64)."); 1050 | 1051 | if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) 1052 | return Response.Error($"replace_class failed: {why}"); 1053 | 1054 | if (!ValidateClassSnippet(replacement, className, out var vErr)) 1055 | return Response.Error($"Replacement snippet invalid: {vErr}"); 1056 | 1057 | if (applySequentially) 1058 | { 1059 | working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); 1060 | appliedCount++; 1061 | } 1062 | else 1063 | { 1064 | replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); 1065 | } 1066 | break; 1067 | } 1068 | 1069 | case "delete_class": 1070 | { 1071 | string className = op.Value<string>("className"); 1072 | string ns = op.Value<string>("namespace"); 1073 | if (string.IsNullOrWhiteSpace(className)) 1074 | return Response.Error("delete_class requires 'className'."); 1075 | 1076 | if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) 1077 | return Response.Error($"delete_class failed: {why}"); 1078 | 1079 | if (applySequentially) 1080 | { 1081 | working = working.Remove(s, l); 1082 | appliedCount++; 1083 | } 1084 | else 1085 | { 1086 | replacements.Add((s, l, string.Empty)); 1087 | } 1088 | break; 1089 | } 1090 | 1091 | case "replace_method": 1092 | { 1093 | string className = op.Value<string>("className"); 1094 | string ns = op.Value<string>("namespace"); 1095 | string methodName = op.Value<string>("methodName"); 1096 | string replacement = ExtractReplacement(op); 1097 | string returnType = op.Value<string>("returnType"); 1098 | string parametersSignature = op.Value<string>("parametersSignature"); 1099 | string attributesContains = op.Value<string>("attributesContains"); 1100 | 1101 | if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); 1102 | if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); 1103 | if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); 1104 | 1105 | if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) 1106 | return Response.Error($"replace_method failed to locate class: {whyClass}"); 1107 | 1108 | if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) 1109 | { 1110 | bool hasDependentInsert = edits.Any(j => j is JObject jo && 1111 | string.Equals(jo.Value<string>("className"), className, StringComparison.Ordinal) && 1112 | string.Equals(jo.Value<string>("methodName"), methodName, StringComparison.Ordinal) && 1113 | ((jo.Value<string>("mode") ?? jo.Value<string>("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); 1114 | string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; 1115 | return Response.Error($"replace_method failed: {whyMethod}.{hint}"); 1116 | } 1117 | 1118 | if (applySequentially) 1119 | { 1120 | working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); 1121 | appliedCount++; 1122 | } 1123 | else 1124 | { 1125 | replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); 1126 | } 1127 | break; 1128 | } 1129 | 1130 | case "delete_method": 1131 | { 1132 | string className = op.Value<string>("className"); 1133 | string ns = op.Value<string>("namespace"); 1134 | string methodName = op.Value<string>("methodName"); 1135 | string returnType = op.Value<string>("returnType"); 1136 | string parametersSignature = op.Value<string>("parametersSignature"); 1137 | string attributesContains = op.Value<string>("attributesContains"); 1138 | 1139 | if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); 1140 | if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); 1141 | 1142 | if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) 1143 | return Response.Error($"delete_method failed to locate class: {whyClass}"); 1144 | 1145 | if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) 1146 | { 1147 | bool hasDependentInsert = edits.Any(j => j is JObject jo && 1148 | string.Equals(jo.Value<string>("className"), className, StringComparison.Ordinal) && 1149 | string.Equals(jo.Value<string>("methodName"), methodName, StringComparison.Ordinal) && 1150 | ((jo.Value<string>("mode") ?? jo.Value<string>("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); 1151 | string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; 1152 | return Response.Error($"delete_method failed: {whyMethod}.{hint}"); 1153 | } 1154 | 1155 | if (applySequentially) 1156 | { 1157 | working = working.Remove(mStart, mLen); 1158 | appliedCount++; 1159 | } 1160 | else 1161 | { 1162 | replacements.Add((mStart, mLen, string.Empty)); 1163 | } 1164 | break; 1165 | } 1166 | 1167 | case "insert_method": 1168 | { 1169 | string className = op.Value<string>("className"); 1170 | string ns = op.Value<string>("namespace"); 1171 | string position = (op.Value<string>("position") ?? "end").ToLowerInvariant(); 1172 | string afterMethodName = op.Value<string>("afterMethodName"); 1173 | string afterReturnType = op.Value<string>("afterReturnType"); 1174 | string afterParameters = op.Value<string>("afterParametersSignature"); 1175 | string afterAttributesContains = op.Value<string>("afterAttributesContains"); 1176 | string snippet = ExtractReplacement(op); 1177 | // Harden: refuse empty replacement for inserts 1178 | if (snippet == null || snippet.Trim().Length == 0) 1179 | return Response.Error("insert_method requires a non-empty 'replacement' text."); 1180 | 1181 | if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); 1182 | if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); 1183 | 1184 | if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) 1185 | return Response.Error($"insert_method failed to locate class: {whyClass}"); 1186 | 1187 | if (position == "after") 1188 | { 1189 | if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); 1190 | if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) 1191 | return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); 1192 | int insAt = aStart + aLen; 1193 | string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); 1194 | if (applySequentially) 1195 | { 1196 | working = working.Insert(insAt, text); 1197 | appliedCount++; 1198 | } 1199 | else 1200 | { 1201 | replacements.Add((insAt, 0, text)); 1202 | } 1203 | } 1204 | else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) 1205 | return Response.Error($"insert_method failed: {whyIns}"); 1206 | else 1207 | { 1208 | string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); 1209 | if (applySequentially) 1210 | { 1211 | working = working.Insert(insAt, text); 1212 | appliedCount++; 1213 | } 1214 | else 1215 | { 1216 | replacements.Add((insAt, 0, text)); 1217 | } 1218 | } 1219 | break; 1220 | } 1221 | 1222 | case "anchor_insert": 1223 | { 1224 | string anchor = op.Value<string>("anchor"); 1225 | string position = (op.Value<string>("position") ?? "before").ToLowerInvariant(); 1226 | string text = op.Value<string>("text") ?? ExtractReplacement(op); 1227 | if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex)."); 1228 | if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'."); 1229 | 1230 | try 1231 | { 1232 | var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); 1233 | var m = rx.Match(working); 1234 | if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}"); 1235 | int insAt = position == "after" ? m.Index + m.Length : m.Index; 1236 | string norm = NormalizeNewlines(text); 1237 | if (!norm.EndsWith("\n")) 1238 | { 1239 | norm += "\n"; 1240 | } 1241 | 1242 | // Duplicate guard: if identical snippet already exists within this class, skip insert 1243 | if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _)) 1244 | { 1245 | string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); 1246 | if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) 1247 | { 1248 | // Do not insert duplicate; treat as no-op 1249 | break; 1250 | } 1251 | } 1252 | if (applySequentially) 1253 | { 1254 | working = working.Insert(insAt, norm); 1255 | appliedCount++; 1256 | } 1257 | else 1258 | { 1259 | replacements.Add((insAt, 0, norm)); 1260 | } 1261 | } 1262 | catch (Exception ex) 1263 | { 1264 | return Response.Error($"anchor_insert failed: {ex.Message}"); 1265 | } 1266 | break; 1267 | } 1268 | 1269 | case "anchor_delete": 1270 | { 1271 | string anchor = op.Value<string>("anchor"); 1272 | if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); 1273 | try 1274 | { 1275 | var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); 1276 | var m = rx.Match(working); 1277 | if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); 1278 | int delAt = m.Index; 1279 | int delLen = m.Length; 1280 | if (applySequentially) 1281 | { 1282 | working = working.Remove(delAt, delLen); 1283 | appliedCount++; 1284 | } 1285 | else 1286 | { 1287 | replacements.Add((delAt, delLen, string.Empty)); 1288 | } 1289 | } 1290 | catch (Exception ex) 1291 | { 1292 | return Response.Error($"anchor_delete failed: {ex.Message}"); 1293 | } 1294 | break; 1295 | } 1296 | 1297 | case "anchor_replace": 1298 | { 1299 | string anchor = op.Value<string>("anchor"); 1300 | string replacement = op.Value<string>("text") ?? op.Value<string>("replacement") ?? ExtractReplacement(op) ?? string.Empty; 1301 | if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); 1302 | try 1303 | { 1304 | var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); 1305 | var m = rx.Match(working); 1306 | if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); 1307 | int at = m.Index; 1308 | int len = m.Length; 1309 | string norm = NormalizeNewlines(replacement); 1310 | if (applySequentially) 1311 | { 1312 | working = working.Remove(at, len).Insert(at, norm); 1313 | appliedCount++; 1314 | } 1315 | else 1316 | { 1317 | replacements.Add((at, len, norm)); 1318 | } 1319 | } 1320 | catch (Exception ex) 1321 | { 1322 | return Response.Error($"anchor_replace failed: {ex.Message}"); 1323 | } 1324 | break; 1325 | } 1326 | 1327 | default: 1328 | return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); 1329 | } 1330 | } 1331 | 1332 | if (!applySequentially) 1333 | { 1334 | if (HasOverlaps(replacements)) 1335 | { 1336 | var ordered = replacements.OrderByDescending(r => r.start).ToList(); 1337 | for (int i = 1; i < ordered.Count; i++) 1338 | { 1339 | if (ordered[i].start + ordered[i].length > ordered[i - 1].start) 1340 | { 1341 | 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 } }; 1342 | return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); 1343 | } 1344 | } 1345 | return Response.Error("overlap", new { status = "overlap" }); 1346 | } 1347 | 1348 | foreach (var r in replacements.OrderByDescending(r => r.start)) 1349 | working = working.Remove(r.start, r.length).Insert(r.start, r.text); 1350 | appliedCount = replacements.Count; 1351 | } 1352 | 1353 | // Guard against structural imbalance before validation 1354 | if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal)) 1355 | return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); 1356 | 1357 | // No-op guard for structured edits: if text unchanged, return explicit no-op 1358 | if (string.Equals(working, original, StringComparison.Ordinal)) 1359 | { 1360 | var sameSha = ComputeSha256(original); 1361 | return Response.Success( 1362 | $"No-op: contents unchanged for '{relativePath}'.", 1363 | new 1364 | { 1365 | path = relativePath, 1366 | uri = $"unity://path/{relativePath}", 1367 | editsApplied = 0, 1368 | no_op = true, 1369 | sha256 = sameSha, 1370 | evidence = new { reason = "identical_content" } 1371 | } 1372 | ); 1373 | } 1374 | 1375 | // Validate result using override from options if provided; otherwise GUI strictness 1376 | var level = GetValidationLevelFromGUI(); 1377 | try 1378 | { 1379 | var validateOpt = options?["validate"]?.ToString()?.ToLowerInvariant(); 1380 | if (!string.IsNullOrEmpty(validateOpt)) 1381 | { 1382 | level = validateOpt switch 1383 | { 1384 | "basic" => ValidationLevel.Basic, 1385 | "standard" => ValidationLevel.Standard, 1386 | "comprehensive" => ValidationLevel.Comprehensive, 1387 | "strict" => ValidationLevel.Strict, 1388 | _ => level 1389 | }; 1390 | } 1391 | } 1392 | catch { /* ignore option parsing issues */ } 1393 | if (!ValidateScriptSyntax(working, level, out var errors)) 1394 | return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty<string>() }); 1395 | else if (errors != null && errors.Length > 0) 1396 | Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); 1397 | 1398 | // Atomic write with backup; schedule refresh 1399 | // Decide refresh behavior 1400 | string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); 1401 | bool immediate = refreshMode == "immediate" || refreshMode == "sync"; 1402 | 1403 | // Persist changes atomically (no BOM), then compute/return new file SHA 1404 | var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); 1405 | var tmp = fullPath + ".tmp"; 1406 | File.WriteAllText(tmp, working, enc); 1407 | var backup = fullPath + ".bak"; 1408 | try 1409 | { 1410 | File.Replace(tmp, fullPath, backup); 1411 | try { if (File.Exists(backup)) File.Delete(backup); } catch { } 1412 | } 1413 | catch (PlatformNotSupportedException) 1414 | { 1415 | File.Copy(tmp, fullPath, true); 1416 | try { File.Delete(tmp); } catch { } 1417 | try { if (File.Exists(backup)) File.Delete(backup); } catch { } 1418 | } 1419 | catch (IOException) 1420 | { 1421 | File.Copy(tmp, fullPath, true); 1422 | try { File.Delete(tmp); } catch { } 1423 | try { if (File.Exists(backup)) File.Delete(backup); } catch { } 1424 | } 1425 | 1426 | var newSha = ComputeSha256(working); 1427 | var ok = Response.Success( 1428 | $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", 1429 | new 1430 | { 1431 | path = relativePath, 1432 | uri = $"unity://path/{relativePath}", 1433 | editsApplied = appliedCount, 1434 | scheduledRefresh = !immediate, 1435 | sha256 = newSha 1436 | } 1437 | ); 1438 | 1439 | if (immediate) 1440 | { 1441 | McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false); 1442 | ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); 1443 | } 1444 | else 1445 | { 1446 | ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); 1447 | } 1448 | return ok; 1449 | } 1450 | catch (Exception ex) 1451 | { 1452 | return Response.Error($"Edit failed: {ex.Message}"); 1453 | } 1454 | } 1455 | 1456 | private static bool HasOverlaps(IEnumerable<(int start, int length, string text)> list) 1457 | { 1458 | var arr = list.OrderBy(x => x.start).ToArray(); 1459 | for (int i = 1; i < arr.Length; i++) 1460 | { 1461 | if (arr[i - 1].start + arr[i - 1].length > arr[i].start) 1462 | return true; 1463 | } 1464 | return false; 1465 | } 1466 | 1467 | private static string ExtractReplacement(JObject op) 1468 | { 1469 | var inline = op.Value<string>("replacement"); 1470 | if (!string.IsNullOrEmpty(inline)) return inline; 1471 | 1472 | var b64 = op.Value<string>("replacementBase64"); 1473 | if (!string.IsNullOrEmpty(b64)) 1474 | { 1475 | try { return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } 1476 | catch { return null; } 1477 | } 1478 | return null; 1479 | } 1480 | 1481 | private static string NormalizeNewlines(string t) 1482 | { 1483 | if (string.IsNullOrEmpty(t)) return t; 1484 | return t.Replace("\r\n", "\n").Replace("\r", "\n"); 1485 | } 1486 | 1487 | private static bool ValidateClassSnippet(string snippet, string expectedName, out string err) 1488 | { 1489 | #if USE_ROSLYN 1490 | try 1491 | { 1492 | var tree = CSharpSyntaxTree.ParseText(snippet); 1493 | var root = tree.GetRoot(); 1494 | var classes = root.DescendantNodes().OfType<Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax>().ToList(); 1495 | if (classes.Count != 1) { err = "snippet must contain exactly one class declaration"; return false; } 1496 | // Optional: enforce expected name 1497 | // if (classes[0].Identifier.ValueText != expectedName) { err = $"snippet declares '{classes[0].Identifier.ValueText}', expected '{expectedName}'"; return false; } 1498 | err = null; return true; 1499 | } 1500 | catch (Exception ex) { err = ex.Message; return false; } 1501 | #else 1502 | if (string.IsNullOrWhiteSpace(snippet) || !snippet.Contains("class ")) { err = "no 'class' keyword found in snippet"; return false; } 1503 | err = null; return true; 1504 | #endif 1505 | } 1506 | 1507 | private static bool TryComputeClassSpan(string source, string className, string ns, out int start, out int length, out string why) 1508 | { 1509 | #if USE_ROSLYN 1510 | try 1511 | { 1512 | var tree = CSharpSyntaxTree.ParseText(source); 1513 | var root = tree.GetRoot(); 1514 | var classes = root.DescendantNodes() 1515 | .OfType<Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax>() 1516 | .Where(c => c.Identifier.ValueText == className); 1517 | 1518 | if (!string.IsNullOrEmpty(ns)) 1519 | { 1520 | classes = classes.Where(c => 1521 | (c.FirstAncestorOrSelf<Microsoft.CodeAnalysis.CSharp.Syntax.NamespaceDeclarationSyntax>()?.Name?.ToString() ?? "") == ns 1522 | || (c.FirstAncestorOrSelf<Microsoft.CodeAnalysis.CSharp.Syntax.FileScopedNamespaceDeclarationSyntax>()?.Name?.ToString() ?? "") == ns); 1523 | } 1524 | 1525 | var list = classes.ToList(); 1526 | if (list.Count == 0) { start = length = 0; why = $"class '{className}' not found" + (ns != null ? $" in namespace '{ns}'" : ""); return false; } 1527 | if (list.Count > 1) { start = length = 0; why = $"class '{className}' matched {list.Count} declarations (partial/nested?). Disambiguate."; return false; } 1528 | 1529 | var cls = list[0]; 1530 | var span = cls.FullSpan; // includes attributes & leading trivia 1531 | start = span.Start; length = span.Length; why = null; return true; 1532 | } 1533 | catch 1534 | { 1535 | // fall back below 1536 | } 1537 | #endif 1538 | return TryComputeClassSpanBalanced(source, className, ns, out start, out length, out why); 1539 | } 1540 | 1541 | private static bool TryComputeClassSpanBalanced(string source, string className, string ns, out int start, out int length, out string why) 1542 | { 1543 | start = length = 0; why = null; 1544 | var idx = IndexOfClassToken(source, className); 1545 | if (idx < 0) { why = $"class '{className}' not found (balanced scan)"; return false; } 1546 | 1547 | if (!string.IsNullOrEmpty(ns) && !AppearsWithinNamespaceHeader(source, idx, ns)) 1548 | { why = $"class '{className}' not under namespace '{ns}' (balanced scan)"; return false; } 1549 | 1550 | // Include modifiers/attributes on the same line: back up to the start of line 1551 | int lineStart = idx; 1552 | while (lineStart > 0 && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; 1553 | 1554 | int i = idx; 1555 | while (i < source.Length && source[i] != '{') i++; 1556 | if (i >= source.Length) { why = "no opening brace after class header"; return false; } 1557 | 1558 | int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; 1559 | int startSpan = lineStart; 1560 | for (; i < source.Length; i++) 1561 | { 1562 | char c = source[i]; 1563 | char n = i + 1 < source.Length ? source[i + 1] : '\0'; 1564 | 1565 | if (inSL) { if (c == '\n') inSL = false; continue; } 1566 | if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } 1567 | if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } 1568 | if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } 1569 | 1570 | if (c == '/' && n == '/') { inSL = true; i++; continue; } 1571 | if (c == '/' && n == '*') { inML = true; i++; continue; } 1572 | if (c == '"') { inStr = true; continue; } 1573 | if (c == '\'') { inChar = true; continue; } 1574 | 1575 | if (c == '{') { depth++; } 1576 | else if (c == '}') 1577 | { 1578 | depth--; 1579 | if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } 1580 | if (depth < 0) { why = "brace underflow"; return false; } 1581 | } 1582 | } 1583 | why = "unterminated class block"; return false; 1584 | } 1585 | 1586 | private static bool TryComputeMethodSpan( 1587 | string source, 1588 | int classStart, 1589 | int classLength, 1590 | string methodName, 1591 | string returnType, 1592 | string parametersSignature, 1593 | string attributesContains, 1594 | out int start, 1595 | out int length, 1596 | out string why) 1597 | { 1598 | start = length = 0; why = null; 1599 | int searchStart = classStart; 1600 | int searchEnd = Math.Min(source.Length, classStart + classLength); 1601 | 1602 | // 1) Find the method header using a stricter regex (allows optional attributes above) 1603 | string rtPattern = string.IsNullOrEmpty(returnType) ? @"[^\s]+" : Regex.Escape(returnType).Replace("\\ ", "\\s+"); 1604 | string namePattern = Regex.Escape(methodName); 1605 | // If a parametersSignature is provided, it may include surrounding parentheses. Strip them so 1606 | // we can safely embed the signature inside our own parenthesis group without duplicating. 1607 | string paramsPattern; 1608 | if (string.IsNullOrEmpty(parametersSignature)) 1609 | { 1610 | paramsPattern = @"[\s\S]*?"; // permissive when not specified 1611 | } 1612 | else 1613 | { 1614 | string ps = parametersSignature.Trim(); 1615 | if (ps.StartsWith("(") && ps.EndsWith(")") && ps.Length >= 2) 1616 | { 1617 | ps = ps.Substring(1, ps.Length - 2); 1618 | } 1619 | // Escape literal text of the signature 1620 | paramsPattern = Regex.Escape(ps); 1621 | } 1622 | string pattern = 1623 | @"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*" + 1624 | @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + 1625 | rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; 1626 | 1627 | string slice = source.Substring(searchStart, searchEnd - searchStart); 1628 | var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); 1629 | if (!headerMatch.Success) 1630 | { 1631 | why = $"method '{methodName}' header not found in class"; return false; 1632 | } 1633 | int headerIndex = searchStart + headerMatch.Index; 1634 | 1635 | // Optional attributes filter: look upward from headerIndex for contiguous attribute lines 1636 | if (!string.IsNullOrEmpty(attributesContains)) 1637 | { 1638 | int attrScanStart = headerIndex; 1639 | while (attrScanStart > searchStart) 1640 | { 1641 | int prevNl = source.LastIndexOf('\n', attrScanStart - 1); 1642 | if (prevNl < 0 || prevNl < searchStart) break; 1643 | string prevLine = source.Substring(prevNl + 1, attrScanStart - (prevNl + 1)); 1644 | if (prevLine.TrimStart().StartsWith("[")) { attrScanStart = prevNl; continue; } 1645 | break; 1646 | } 1647 | string attrBlock = source.Substring(attrScanStart, headerIndex - attrScanStart); 1648 | if (attrBlock.IndexOf(attributesContains, StringComparison.Ordinal) < 0) 1649 | { 1650 | why = $"method '{methodName}' found but attributes filter did not match"; return false; 1651 | } 1652 | } 1653 | 1654 | // backtrack to the very start of header/attributes to include in span 1655 | int lineStart = headerIndex; 1656 | while (lineStart > searchStart && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; 1657 | // If previous lines are attributes, include them 1658 | int attrStart = lineStart; 1659 | int probe = lineStart - 1; 1660 | while (probe > searchStart) 1661 | { 1662 | int prevNl = source.LastIndexOf('\n', probe); 1663 | if (prevNl < 0 || prevNl < searchStart) break; 1664 | string prev = source.Substring(prevNl + 1, attrStart - (prevNl + 1)); 1665 | if (prev.TrimStart().StartsWith("[")) { attrStart = prevNl + 1; probe = prevNl - 1; } 1666 | else break; 1667 | } 1668 | 1669 | // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end 1670 | // Find the '(' that belongs to the method signature, not attributes 1671 | int nameTokenIdx = IndexOfTokenWithin(source, methodName, headerIndex, searchEnd); 1672 | if (nameTokenIdx < 0) { why = $"method '{methodName}' token not found after header"; return false; } 1673 | int sigOpenParen = IndexOfTokenWithin(source, "(", nameTokenIdx, searchEnd); 1674 | if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; } 1675 | 1676 | int i = sigOpenParen; 1677 | int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; 1678 | for (; i < searchEnd; i++) 1679 | { 1680 | char c = source[i]; 1681 | char n = i + 1 < searchEnd ? source[i + 1] : '\0'; 1682 | if (inSL) { if (c == '\n') inSL = false; continue; } 1683 | if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } 1684 | if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } 1685 | if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } 1686 | 1687 | if (c == '/' && n == '/') { inSL = true; i++; continue; } 1688 | if (c == '/' && n == '*') { inML = true; i++; continue; } 1689 | if (c == '"') { inStr = true; continue; } 1690 | if (c == '\'') { inChar = true; continue; } 1691 | 1692 | if (c == '(') parenDepth++; 1693 | if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } } 1694 | } 1695 | 1696 | // After params: detect expression-bodied or block-bodied 1697 | // Skip whitespace/comments 1698 | for (; i < searchEnd; i++) 1699 | { 1700 | char c = source[i]; 1701 | char n = i + 1 < searchEnd ? source[i + 1] : '\0'; 1702 | if (char.IsWhiteSpace(c)) continue; 1703 | if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } 1704 | if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } 1705 | break; 1706 | } 1707 | 1708 | // Tolerate generic constraints between params and body: multiple 'where T : ...' 1709 | for (; ; ) 1710 | { 1711 | // Skip whitespace/comments before checking for 'where' 1712 | for (; i < searchEnd; i++) 1713 | { 1714 | char c = source[i]; 1715 | char n = i + 1 < searchEnd ? source[i + 1] : '\0'; 1716 | if (char.IsWhiteSpace(c)) continue; 1717 | if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } 1718 | if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } 1719 | break; 1720 | } 1721 | 1722 | // Check word-boundary 'where' 1723 | bool hasWhere = false; 1724 | if (i + 5 <= searchEnd) 1725 | { 1726 | hasWhere = source[i] == 'w' && source[i + 1] == 'h' && source[i + 2] == 'e' && source[i + 3] == 'r' && source[i + 4] == 'e'; 1727 | if (hasWhere) 1728 | { 1729 | // Left boundary 1730 | if (i - 1 >= 0) 1731 | { 1732 | char lb = source[i - 1]; 1733 | if (char.IsLetterOrDigit(lb) || lb == '_') hasWhere = false; 1734 | } 1735 | // Right boundary 1736 | if (hasWhere && i + 5 < searchEnd) 1737 | { 1738 | char rb = source[i + 5]; 1739 | if (char.IsLetterOrDigit(rb) || rb == '_') hasWhere = false; 1740 | } 1741 | } 1742 | } 1743 | if (!hasWhere) break; 1744 | 1745 | // Advance past the entire where-constraint clause until we hit '{' or '=>' or ';' 1746 | i += 5; // past 'where' 1747 | while (i < searchEnd) 1748 | { 1749 | char c = source[i]; 1750 | char n = i + 1 < searchEnd ? source[i + 1] : '\0'; 1751 | if (c == '{' || c == ';' || (c == '=' && n == '>')) break; 1752 | // Skip comments inline 1753 | if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } 1754 | if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } 1755 | i++; 1756 | } 1757 | } 1758 | 1759 | // Re-check for expression-bodied after constraints 1760 | if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') 1761 | { 1762 | // expression-bodied method: seek to terminating semicolon 1763 | int j = i; 1764 | bool done = false; 1765 | while (j < searchEnd) 1766 | { 1767 | char c = source[j]; 1768 | if (c == ';') { done = true; break; } 1769 | j++; 1770 | } 1771 | if (!done) { why = "unterminated expression-bodied method"; return false; } 1772 | start = attrStart; length = (j - attrStart) + 1; return true; 1773 | } 1774 | 1775 | if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; } 1776 | 1777 | int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false; 1778 | int startSpan = attrStart; 1779 | for (; i < searchEnd; i++) 1780 | { 1781 | char c = source[i]; 1782 | char n = i + 1 < searchEnd ? source[i + 1] : '\0'; 1783 | if (inSL) { if (c == '\n') inSL = false; continue; } 1784 | if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } 1785 | if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } 1786 | if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } 1787 | 1788 | if (c == '/' && n == '/') { inSL = true; i++; continue; } 1789 | if (c == '/' && n == '*') { inML = true; i++; continue; } 1790 | if (c == '"') { inStr = true; continue; } 1791 | if (c == '\'') { inChar = true; continue; } 1792 | 1793 | if (c == '{') depth++; 1794 | else if (c == '}') 1795 | { 1796 | depth--; 1797 | if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } 1798 | if (depth < 0) { why = "brace underflow in method"; return false; } 1799 | } 1800 | } 1801 | why = "unterminated method block"; return false; 1802 | } 1803 | 1804 | private static int IndexOfTokenWithin(string s, string token, int start, int end) 1805 | { 1806 | int idx = s.IndexOf(token, start, StringComparison.Ordinal); 1807 | return (idx >= 0 && idx < end) ? idx : -1; 1808 | } 1809 | 1810 | private static bool TryFindClassInsertionPoint(string source, int classStart, int classLength, string position, out int insertAt, out string why) 1811 | { 1812 | insertAt = 0; why = null; 1813 | int searchStart = classStart; 1814 | int searchEnd = Math.Min(source.Length, classStart + classLength); 1815 | 1816 | if (position == "start") 1817 | { 1818 | // find first '{' after class header, insert just after with a newline 1819 | int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); 1820 | if (i < 0) { why = "could not find class opening brace"; return false; } 1821 | insertAt = i + 1; return true; 1822 | } 1823 | else // end 1824 | { 1825 | // walk to matching closing brace of class and insert just before it 1826 | int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); 1827 | if (i < 0) { why = "could not find class opening brace"; return false; } 1828 | int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; 1829 | for (; i < searchEnd; i++) 1830 | { 1831 | char c = source[i]; 1832 | char n = i + 1 < searchEnd ? source[i + 1] : '\0'; 1833 | if (inSL) { if (c == '\n') inSL = false; continue; } 1834 | if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } 1835 | if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } 1836 | if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } 1837 | 1838 | if (c == '/' && n == '/') { inSL = true; i++; continue; } 1839 | if (c == '/' && n == '*') { inML = true; i++; continue; } 1840 | if (c == '"') { inStr = true; continue; } 1841 | if (c == '\'') { inChar = true; continue; } 1842 | 1843 | if (c == '{') depth++; 1844 | else if (c == '}') 1845 | { 1846 | depth--; 1847 | if (depth == 0) { insertAt = i; return true; } 1848 | if (depth < 0) { why = "brace underflow while scanning class"; return false; } 1849 | } 1850 | } 1851 | why = "could not find class closing brace"; return false; 1852 | } 1853 | } 1854 | 1855 | private static int IndexOfClassToken(string s, string className) 1856 | { 1857 | // simple token search; could be tightened with Regex for word boundaries 1858 | var pattern = "class " + className; 1859 | return s.IndexOf(pattern, StringComparison.Ordinal); 1860 | } 1861 | 1862 | private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns) 1863 | { 1864 | int from = Math.Max(0, pos - 2000); 1865 | var slice = s.Substring(from, pos - from); 1866 | return slice.Contains("namespace " + ns); 1867 | } 1868 | 1869 | /// <summary> 1870 | /// Generates basic C# script content based on name and type. 1871 | /// </summary> 1872 | private static string GenerateDefaultScriptContent( 1873 | string name, 1874 | string scriptType, 1875 | string namespaceName 1876 | ) 1877 | { 1878 | string usingStatements = "using UnityEngine;\nusing System.Collections;\n"; 1879 | string classDeclaration; 1880 | string body = 1881 | "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; 1882 | 1883 | string baseClass = ""; 1884 | if (!string.IsNullOrEmpty(scriptType)) 1885 | { 1886 | if (scriptType.Equals("MonoBehaviour", StringComparison.OrdinalIgnoreCase)) 1887 | baseClass = " : MonoBehaviour"; 1888 | else if (scriptType.Equals("ScriptableObject", StringComparison.OrdinalIgnoreCase)) 1889 | { 1890 | baseClass = " : ScriptableObject"; 1891 | body = ""; // ScriptableObjects don't usually need Start/Update 1892 | } 1893 | else if ( 1894 | scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) 1895 | || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase) 1896 | ) 1897 | { 1898 | usingStatements += "using UnityEditor;\n"; 1899 | if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) 1900 | baseClass = " : Editor"; 1901 | else 1902 | baseClass = " : EditorWindow"; 1903 | body = ""; // Editor scripts have different structures 1904 | } 1905 | // Add more types as needed 1906 | } 1907 | 1908 | classDeclaration = $"public class {name}{baseClass}"; 1909 | 1910 | string fullContent = $"{usingStatements}\n"; 1911 | bool useNamespace = !string.IsNullOrEmpty(namespaceName); 1912 | 1913 | if (useNamespace) 1914 | { 1915 | fullContent += $"namespace {namespaceName}\n{{\n"; 1916 | // Indent class and body if using namespace 1917 | classDeclaration = " " + classDeclaration; 1918 | body = string.Join("\n", body.Split('\n').Select(line => " " + line)); 1919 | } 1920 | 1921 | fullContent += $"{classDeclaration}\n{{\n{body}\n}}"; 1922 | 1923 | if (useNamespace) 1924 | { 1925 | fullContent += "\n}"; // Close namespace 1926 | } 1927 | 1928 | return fullContent.Trim() + "\n"; // Ensure a trailing newline 1929 | } 1930 | 1931 | /// <summary> 1932 | /// Gets the validation level from the GUI settings 1933 | /// </summary> 1934 | private static ValidationLevel GetValidationLevelFromGUI() 1935 | { 1936 | string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); 1937 | return savedLevel.ToLower() switch 1938 | { 1939 | "basic" => ValidationLevel.Basic, 1940 | "standard" => ValidationLevel.Standard, 1941 | "comprehensive" => ValidationLevel.Comprehensive, 1942 | "strict" => ValidationLevel.Strict, 1943 | _ => ValidationLevel.Standard // Default fallback 1944 | }; 1945 | } 1946 | 1947 | /// <summary> 1948 | /// Validates C# script syntax using multiple validation layers. 1949 | /// </summary> 1950 | private static bool ValidateScriptSyntax(string contents) 1951 | { 1952 | return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _); 1953 | } 1954 | 1955 | /// <summary> 1956 | /// Advanced syntax validation with detailed diagnostics and configurable strictness. 1957 | /// </summary> 1958 | private static bool ValidateScriptSyntax(string contents, ValidationLevel level, out string[] errors) 1959 | { 1960 | var errorList = new System.Collections.Generic.List<string>(); 1961 | errors = null; 1962 | 1963 | if (string.IsNullOrEmpty(contents)) 1964 | { 1965 | return true; // Empty content is valid 1966 | } 1967 | 1968 | // Basic structural validation 1969 | if (!ValidateBasicStructure(contents, errorList)) 1970 | { 1971 | errors = errorList.ToArray(); 1972 | return false; 1973 | } 1974 | 1975 | #if USE_ROSLYN 1976 | // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors 1977 | if (level >= ValidationLevel.Standard) 1978 | { 1979 | if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) 1980 | { 1981 | errors = errorList.ToArray(); 1982 | return false; 1983 | } 1984 | } 1985 | #endif 1986 | 1987 | // Unity-specific validation 1988 | if (level >= ValidationLevel.Standard) 1989 | { 1990 | ValidateScriptSyntaxUnity(contents, errorList); 1991 | } 1992 | 1993 | // Semantic analysis for common issues 1994 | if (level >= ValidationLevel.Comprehensive) 1995 | { 1996 | ValidateSemanticRules(contents, errorList); 1997 | } 1998 | 1999 | #if USE_ROSLYN 2000 | // Full semantic compilation validation for Strict level 2001 | if (level == ValidationLevel.Strict) 2002 | { 2003 | if (!ValidateScriptSemantics(contents, errorList)) 2004 | { 2005 | errors = errorList.ToArray(); 2006 | return false; // Strict level fails on any semantic errors 2007 | } 2008 | } 2009 | #endif 2010 | 2011 | errors = errorList.ToArray(); 2012 | return errorList.Count == 0 || (level != ValidationLevel.Strict && !errorList.Any(e => e.StartsWith("ERROR:"))); 2013 | } 2014 | 2015 | /// <summary> 2016 | /// Validation strictness levels 2017 | /// </summary> 2018 | private enum ValidationLevel 2019 | { 2020 | Basic, // Only syntax errors 2021 | Standard, // Syntax + Unity best practices 2022 | Comprehensive, // All checks + semantic analysis 2023 | Strict // Treat all issues as errors 2024 | } 2025 | 2026 | /// <summary> 2027 | /// Validates basic code structure (braces, quotes, comments) 2028 | /// </summary> 2029 | private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List<string> errors) 2030 | { 2031 | bool isValid = true; 2032 | int braceBalance = 0; 2033 | int parenBalance = 0; 2034 | int bracketBalance = 0; 2035 | bool inStringLiteral = false; 2036 | bool inCharLiteral = false; 2037 | bool inSingleLineComment = false; 2038 | bool inMultiLineComment = false; 2039 | bool escaped = false; 2040 | 2041 | for (int i = 0; i < contents.Length; i++) 2042 | { 2043 | char c = contents[i]; 2044 | char next = i + 1 < contents.Length ? contents[i + 1] : '\0'; 2045 | 2046 | // Handle escape sequences 2047 | if (escaped) 2048 | { 2049 | escaped = false; 2050 | continue; 2051 | } 2052 | 2053 | if (c == '\\' && (inStringLiteral || inCharLiteral)) 2054 | { 2055 | escaped = true; 2056 | continue; 2057 | } 2058 | 2059 | // Handle comments 2060 | if (!inStringLiteral && !inCharLiteral) 2061 | { 2062 | if (c == '/' && next == '/' && !inMultiLineComment) 2063 | { 2064 | inSingleLineComment = true; 2065 | continue; 2066 | } 2067 | if (c == '/' && next == '*' && !inSingleLineComment) 2068 | { 2069 | inMultiLineComment = true; 2070 | i++; // Skip next character 2071 | continue; 2072 | } 2073 | if (c == '*' && next == '/' && inMultiLineComment) 2074 | { 2075 | inMultiLineComment = false; 2076 | i++; // Skip next character 2077 | continue; 2078 | } 2079 | } 2080 | 2081 | if (c == '\n') 2082 | { 2083 | inSingleLineComment = false; 2084 | continue; 2085 | } 2086 | 2087 | if (inSingleLineComment || inMultiLineComment) 2088 | continue; 2089 | 2090 | // Handle string and character literals 2091 | if (c == '"' && !inCharLiteral) 2092 | { 2093 | inStringLiteral = !inStringLiteral; 2094 | continue; 2095 | } 2096 | if (c == '\'' && !inStringLiteral) 2097 | { 2098 | inCharLiteral = !inCharLiteral; 2099 | continue; 2100 | } 2101 | 2102 | if (inStringLiteral || inCharLiteral) 2103 | continue; 2104 | 2105 | // Count brackets and braces 2106 | switch (c) 2107 | { 2108 | case '{': braceBalance++; break; 2109 | case '}': braceBalance--; break; 2110 | case '(': parenBalance++; break; 2111 | case ')': parenBalance--; break; 2112 | case '[': bracketBalance++; break; 2113 | case ']': bracketBalance--; break; 2114 | } 2115 | 2116 | // Check for negative balances (closing without opening) 2117 | if (braceBalance < 0) 2118 | { 2119 | errors.Add("ERROR: Unmatched closing brace '}'"); 2120 | isValid = false; 2121 | } 2122 | if (parenBalance < 0) 2123 | { 2124 | errors.Add("ERROR: Unmatched closing parenthesis ')'"); 2125 | isValid = false; 2126 | } 2127 | if (bracketBalance < 0) 2128 | { 2129 | errors.Add("ERROR: Unmatched closing bracket ']'"); 2130 | isValid = false; 2131 | } 2132 | } 2133 | 2134 | // Check final balances 2135 | if (braceBalance != 0) 2136 | { 2137 | errors.Add($"ERROR: Unbalanced braces (difference: {braceBalance})"); 2138 | isValid = false; 2139 | } 2140 | if (parenBalance != 0) 2141 | { 2142 | errors.Add($"ERROR: Unbalanced parentheses (difference: {parenBalance})"); 2143 | isValid = false; 2144 | } 2145 | if (bracketBalance != 0) 2146 | { 2147 | errors.Add($"ERROR: Unbalanced brackets (difference: {bracketBalance})"); 2148 | isValid = false; 2149 | } 2150 | if (inStringLiteral) 2151 | { 2152 | errors.Add("ERROR: Unterminated string literal"); 2153 | isValid = false; 2154 | } 2155 | if (inCharLiteral) 2156 | { 2157 | errors.Add("ERROR: Unterminated character literal"); 2158 | isValid = false; 2159 | } 2160 | if (inMultiLineComment) 2161 | { 2162 | errors.Add("WARNING: Unterminated multi-line comment"); 2163 | } 2164 | 2165 | return isValid; 2166 | } 2167 | 2168 | #if USE_ROSLYN 2169 | /// <summary> 2170 | /// Cached compilation references for performance 2171 | /// </summary> 2172 | private static System.Collections.Generic.List<MetadataReference> _cachedReferences = null; 2173 | private static DateTime _cacheTime = DateTime.MinValue; 2174 | private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5); 2175 | 2176 | /// <summary> 2177 | /// Validates syntax using Roslyn compiler services 2178 | /// </summary> 2179 | private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List<string> errors) 2180 | { 2181 | try 2182 | { 2183 | var syntaxTree = CSharpSyntaxTree.ParseText(contents); 2184 | var diagnostics = syntaxTree.GetDiagnostics(); 2185 | 2186 | bool hasErrors = false; 2187 | foreach (var diagnostic in diagnostics) 2188 | { 2189 | string severity = diagnostic.Severity.ToString().ToUpper(); 2190 | string message = $"{severity}: {diagnostic.GetMessage()}"; 2191 | 2192 | if (diagnostic.Severity == DiagnosticSeverity.Error) 2193 | { 2194 | hasErrors = true; 2195 | } 2196 | 2197 | // Include warnings in comprehensive mode 2198 | if (level >= ValidationLevel.Standard || diagnostic.Severity == DiagnosticSeverity.Error) //Also use Standard for now 2199 | { 2200 | var location = diagnostic.Location.GetLineSpan(); 2201 | if (location.IsValid) 2202 | { 2203 | message += $" (Line {location.StartLinePosition.Line + 1})"; 2204 | } 2205 | errors.Add(message); 2206 | } 2207 | } 2208 | 2209 | return !hasErrors; 2210 | } 2211 | catch (Exception ex) 2212 | { 2213 | errors.Add($"ERROR: Roslyn validation failed: {ex.Message}"); 2214 | return false; 2215 | } 2216 | } 2217 | 2218 | /// <summary> 2219 | /// Validates script semantics using full compilation context to catch namespace, type, and method resolution errors 2220 | /// </summary> 2221 | private static bool ValidateScriptSemantics(string contents, System.Collections.Generic.List<string> errors) 2222 | { 2223 | try 2224 | { 2225 | // Get compilation references with caching 2226 | var references = GetCompilationReferences(); 2227 | if (references == null || references.Count == 0) 2228 | { 2229 | errors.Add("WARNING: Could not load compilation references for semantic validation"); 2230 | return true; // Don't fail if we can't get references 2231 | } 2232 | 2233 | // Create syntax tree 2234 | var syntaxTree = CSharpSyntaxTree.ParseText(contents); 2235 | 2236 | // Create compilation with full context 2237 | var compilation = CSharpCompilation.Create( 2238 | "TempValidation", 2239 | new[] { syntaxTree }, 2240 | references, 2241 | new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) 2242 | ); 2243 | 2244 | // Get semantic diagnostics - this catches all the issues you mentioned! 2245 | var diagnostics = compilation.GetDiagnostics(); 2246 | 2247 | bool hasErrors = false; 2248 | foreach (var diagnostic in diagnostics) 2249 | { 2250 | if (diagnostic.Severity == DiagnosticSeverity.Error) 2251 | { 2252 | hasErrors = true; 2253 | var location = diagnostic.Location.GetLineSpan(); 2254 | string locationInfo = location.IsValid ? 2255 | $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; 2256 | 2257 | // Include diagnostic ID for better error identification 2258 | string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; 2259 | errors.Add($"ERROR: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); 2260 | } 2261 | else if (diagnostic.Severity == DiagnosticSeverity.Warning) 2262 | { 2263 | var location = diagnostic.Location.GetLineSpan(); 2264 | string locationInfo = location.IsValid ? 2265 | $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; 2266 | 2267 | string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; 2268 | errors.Add($"WARNING: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); 2269 | } 2270 | } 2271 | 2272 | return !hasErrors; 2273 | } 2274 | catch (Exception ex) 2275 | { 2276 | errors.Add($"ERROR: Semantic validation failed: {ex.Message}"); 2277 | return false; 2278 | } 2279 | } 2280 | 2281 | /// <summary> 2282 | /// Gets compilation references with caching for performance 2283 | /// </summary> 2284 | private static System.Collections.Generic.List<MetadataReference> GetCompilationReferences() 2285 | { 2286 | // Check cache validity 2287 | if (_cachedReferences != null && DateTime.Now - _cacheTime < CacheExpiry) 2288 | { 2289 | return _cachedReferences; 2290 | } 2291 | 2292 | try 2293 | { 2294 | var references = new System.Collections.Generic.List<MetadataReference>(); 2295 | 2296 | // Core .NET assemblies 2297 | references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); // mscorlib/System.Private.CoreLib 2298 | references.Add(MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location)); // System.Linq 2299 | references.Add(MetadataReference.CreateFromFile(typeof(System.Collections.Generic.List<>).Assembly.Location)); // System.Collections 2300 | 2301 | // Unity assemblies 2302 | try 2303 | { 2304 | references.Add(MetadataReference.CreateFromFile(typeof(UnityEngine.Debug).Assembly.Location)); // UnityEngine 2305 | } 2306 | catch (Exception ex) 2307 | { 2308 | Debug.LogWarning($"Could not load UnityEngine assembly: {ex.Message}"); 2309 | } 2310 | 2311 | #if UNITY_EDITOR 2312 | try 2313 | { 2314 | references.Add(MetadataReference.CreateFromFile(typeof(UnityEditor.Editor).Assembly.Location)); // UnityEditor 2315 | } 2316 | catch (Exception ex) 2317 | { 2318 | Debug.LogWarning($"Could not load UnityEditor assembly: {ex.Message}"); 2319 | } 2320 | 2321 | // Get Unity project assemblies 2322 | try 2323 | { 2324 | var assemblies = CompilationPipeline.GetAssemblies(); 2325 | foreach (var assembly in assemblies) 2326 | { 2327 | if (File.Exists(assembly.outputPath)) 2328 | { 2329 | references.Add(MetadataReference.CreateFromFile(assembly.outputPath)); 2330 | } 2331 | } 2332 | } 2333 | catch (Exception ex) 2334 | { 2335 | Debug.LogWarning($"Could not load Unity project assemblies: {ex.Message}"); 2336 | } 2337 | #endif 2338 | 2339 | // Cache the results 2340 | _cachedReferences = references; 2341 | _cacheTime = DateTime.Now; 2342 | 2343 | return references; 2344 | } 2345 | catch (Exception ex) 2346 | { 2347 | Debug.LogError($"Failed to get compilation references: {ex.Message}"); 2348 | return new System.Collections.Generic.List<MetadataReference>(); 2349 | } 2350 | } 2351 | #else 2352 | private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List<string> errors) 2353 | { 2354 | // Fallback when Roslyn is not available 2355 | return true; 2356 | } 2357 | #endif 2358 | 2359 | /// <summary> 2360 | /// Validates Unity-specific coding rules and best practices 2361 | /// //TODO: Naive Unity Checks and not really yield any results, need to be improved 2362 | /// </summary> 2363 | private static void ValidateScriptSyntaxUnity(string contents, System.Collections.Generic.List<string> errors) 2364 | { 2365 | // Check for common Unity anti-patterns 2366 | if (contents.Contains("FindObjectOfType") && contents.Contains("Update()")) 2367 | { 2368 | errors.Add("WARNING: FindObjectOfType in Update() can cause performance issues"); 2369 | } 2370 | 2371 | if (contents.Contains("GameObject.Find") && contents.Contains("Update()")) 2372 | { 2373 | errors.Add("WARNING: GameObject.Find in Update() can cause performance issues"); 2374 | } 2375 | 2376 | // Check for proper MonoBehaviour usage 2377 | if (contents.Contains(": MonoBehaviour") && !contents.Contains("using UnityEngine")) 2378 | { 2379 | errors.Add("WARNING: MonoBehaviour requires 'using UnityEngine;'"); 2380 | } 2381 | 2382 | // Check for SerializeField usage 2383 | if (contents.Contains("[SerializeField]") && !contents.Contains("using UnityEngine")) 2384 | { 2385 | errors.Add("WARNING: SerializeField requires 'using UnityEngine;'"); 2386 | } 2387 | 2388 | // Check for proper coroutine usage 2389 | if (contents.Contains("StartCoroutine") && !contents.Contains("IEnumerator")) 2390 | { 2391 | errors.Add("WARNING: StartCoroutine typically requires IEnumerator methods"); 2392 | } 2393 | 2394 | // Check for Update without FixedUpdate for physics 2395 | if (contents.Contains("Rigidbody") && contents.Contains("Update()") && !contents.Contains("FixedUpdate()")) 2396 | { 2397 | errors.Add("WARNING: Consider using FixedUpdate() for Rigidbody operations"); 2398 | } 2399 | 2400 | // Check for missing null checks on Unity objects 2401 | if (contents.Contains("GetComponent<") && !contents.Contains("!= null")) 2402 | { 2403 | errors.Add("WARNING: Consider null checking GetComponent results"); 2404 | } 2405 | 2406 | // Check for proper event function signatures 2407 | if (contents.Contains("void Start(") && !contents.Contains("void Start()")) 2408 | { 2409 | errors.Add("WARNING: Start() should not have parameters"); 2410 | } 2411 | 2412 | if (contents.Contains("void Update(") && !contents.Contains("void Update()")) 2413 | { 2414 | errors.Add("WARNING: Update() should not have parameters"); 2415 | } 2416 | 2417 | // Check for inefficient string operations 2418 | if (contents.Contains("Update()") && contents.Contains("\"") && contents.Contains("+")) 2419 | { 2420 | errors.Add("WARNING: String concatenation in Update() can cause garbage collection issues"); 2421 | } 2422 | } 2423 | 2424 | /// <summary> 2425 | /// Validates semantic rules and common coding issues 2426 | /// </summary> 2427 | private static void ValidateSemanticRules(string contents, System.Collections.Generic.List<string> errors) 2428 | { 2429 | // Check for potential memory leaks 2430 | if (contents.Contains("new ") && contents.Contains("Update()")) 2431 | { 2432 | errors.Add("WARNING: Creating objects in Update() may cause memory issues"); 2433 | } 2434 | 2435 | // Check for magic numbers 2436 | var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); 2437 | var matches = magicNumberPattern.Matches(contents); 2438 | if (matches.Count > 5) 2439 | { 2440 | errors.Add("WARNING: Consider using named constants instead of magic numbers"); 2441 | } 2442 | 2443 | // Check for long methods (simple line count check) 2444 | var methodPattern = new Regex(@"(public|private|protected|internal)?\s*(static)?\s*\w+\s+\w+\s*\([^)]*\)\s*{", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); 2445 | var methodMatches = methodPattern.Matches(contents); 2446 | foreach (Match match in methodMatches) 2447 | { 2448 | int startIndex = match.Index; 2449 | int braceCount = 0; 2450 | int lineCount = 0; 2451 | bool inMethod = false; 2452 | 2453 | for (int i = startIndex; i < contents.Length; i++) 2454 | { 2455 | if (contents[i] == '{') 2456 | { 2457 | braceCount++; 2458 | inMethod = true; 2459 | } 2460 | else if (contents[i] == '}') 2461 | { 2462 | braceCount--; 2463 | if (braceCount == 0 && inMethod) 2464 | break; 2465 | } 2466 | else if (contents[i] == '\n' && inMethod) 2467 | { 2468 | lineCount++; 2469 | } 2470 | } 2471 | 2472 | if (lineCount > 50) 2473 | { 2474 | errors.Add("WARNING: Method is very long, consider breaking it into smaller methods"); 2475 | break; // Only report once 2476 | } 2477 | } 2478 | 2479 | // Check for proper exception handling 2480 | if (contents.Contains("catch") && contents.Contains("catch()")) 2481 | { 2482 | errors.Add("WARNING: Empty catch blocks should be avoided"); 2483 | } 2484 | 2485 | // Check for proper async/await usage 2486 | if (contents.Contains("async ") && !contents.Contains("await")) 2487 | { 2488 | errors.Add("WARNING: Async method should contain await or return Task"); 2489 | } 2490 | 2491 | // Check for hardcoded tags and layers 2492 | if (contents.Contains("\"Player\"") || contents.Contains("\"Enemy\"")) 2493 | { 2494 | errors.Add("WARNING: Consider using constants for tags instead of hardcoded strings"); 2495 | } 2496 | } 2497 | 2498 | //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) 2499 | /// <summary> 2500 | /// Public method to validate script syntax with configurable validation level 2501 | /// Returns detailed validation results including errors and warnings 2502 | /// </summary> 2503 | // public static object ValidateScript(JObject @params) 2504 | // { 2505 | // string contents = @params["contents"]?.ToString(); 2506 | // string validationLevel = @params["validationLevel"]?.ToString() ?? "standard"; 2507 | 2508 | // if (string.IsNullOrEmpty(contents)) 2509 | // { 2510 | // return Response.Error("Contents parameter is required for validation."); 2511 | // } 2512 | 2513 | // // Parse validation level 2514 | // ValidationLevel level = ValidationLevel.Standard; 2515 | // switch (validationLevel.ToLower()) 2516 | // { 2517 | // case "basic": level = ValidationLevel.Basic; break; 2518 | // case "standard": level = ValidationLevel.Standard; break; 2519 | // case "comprehensive": level = ValidationLevel.Comprehensive; break; 2520 | // case "strict": level = ValidationLevel.Strict; break; 2521 | // default: 2522 | // return Response.Error($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); 2523 | // } 2524 | 2525 | // // Perform validation 2526 | // bool isValid = ValidateScriptSyntax(contents, level, out string[] validationErrors); 2527 | 2528 | // var errors = validationErrors?.Where(e => e.StartsWith("ERROR:")).ToArray() ?? new string[0]; 2529 | // var warnings = validationErrors?.Where(e => e.StartsWith("WARNING:")).ToArray() ?? new string[0]; 2530 | 2531 | // var result = new 2532 | // { 2533 | // isValid = isValid, 2534 | // validationLevel = validationLevel, 2535 | // errorCount = errors.Length, 2536 | // warningCount = warnings.Length, 2537 | // errors = errors, 2538 | // warnings = warnings, 2539 | // summary = isValid 2540 | // ? (warnings.Length > 0 ? $"Validation passed with {warnings.Length} warnings" : "Validation passed with no issues") 2541 | // : $"Validation failed with {errors.Length} errors and {warnings.Length} warnings" 2542 | // }; 2543 | 2544 | // if (isValid) 2545 | // { 2546 | // return Response.Success("Script validation completed successfully.", result); 2547 | // } 2548 | // else 2549 | // { 2550 | // return Response.Error("Script validation failed.", result); 2551 | // } 2552 | // } 2553 | } 2554 | } 2555 | 2556 | // Debounced refresh/compile scheduler to coalesce bursts of edits 2557 | static class RefreshDebounce 2558 | { 2559 | private static int _pending; 2560 | private static readonly object _lock = new object(); 2561 | private static readonly HashSet<string> _paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase); 2562 | 2563 | // The timestamp of the most recent schedule request. 2564 | private static DateTime _lastRequest; 2565 | 2566 | // Guard to ensure we only have a single ticking callback running. 2567 | private static bool _scheduled; 2568 | 2569 | public static void Schedule(string relPath, TimeSpan window) 2570 | { 2571 | // Record that work is pending and track the path in a threadsafe way. 2572 | Interlocked.Exchange(ref _pending, 1); 2573 | lock (_lock) 2574 | { 2575 | _paths.Add(relPath); 2576 | _lastRequest = DateTime.UtcNow; 2577 | 2578 | // If a debounce timer is already scheduled it will pick up the new request. 2579 | if (_scheduled) 2580 | return; 2581 | 2582 | _scheduled = true; 2583 | } 2584 | 2585 | // Kick off a ticking callback that waits until the window has elapsed 2586 | // from the last request before performing the refresh. 2587 | EditorApplication.delayCall += () => Tick(window); 2588 | // Nudge the editor loop so ticks run even if the window is unfocused 2589 | EditorApplication.QueuePlayerLoopUpdate(); 2590 | } 2591 | 2592 | private static void Tick(TimeSpan window) 2593 | { 2594 | bool ready; 2595 | lock (_lock) 2596 | { 2597 | // Only proceed once the debounce window has fully elapsed. 2598 | ready = (DateTime.UtcNow - _lastRequest) >= window; 2599 | if (ready) 2600 | { 2601 | _scheduled = false; 2602 | } 2603 | } 2604 | 2605 | if (!ready) 2606 | { 2607 | // Window has not yet elapsed; check again on the next editor tick. 2608 | EditorApplication.delayCall += () => Tick(window); 2609 | return; 2610 | } 2611 | 2612 | if (Interlocked.Exchange(ref _pending, 0) == 1) 2613 | { 2614 | string[] toImport; 2615 | lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } 2616 | foreach (var p in toImport) 2617 | { 2618 | var sp = ManageScriptRefreshHelpers.SanitizeAssetsPath(p); 2619 | AssetDatabase.ImportAsset(sp, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); 2620 | } 2621 | #if UNITY_EDITOR 2622 | UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); 2623 | #endif 2624 | // Fallback if needed: 2625 | // AssetDatabase.Refresh(); 2626 | } 2627 | } 2628 | } 2629 | 2630 | static class ManageScriptRefreshHelpers 2631 | { 2632 | public static string SanitizeAssetsPath(string p) 2633 | { 2634 | if (string.IsNullOrEmpty(p)) return p; 2635 | p = p.Replace('\\', '/').Trim(); 2636 | if (p.StartsWith("unity://path/", StringComparison.OrdinalIgnoreCase)) 2637 | p = p.Substring("unity://path/".Length); 2638 | while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) 2639 | p = p.Substring("Assets/".Length); 2640 | if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) 2641 | p = "Assets/" + p.TrimStart('/'); 2642 | return p; 2643 | } 2644 | 2645 | public static void ScheduleScriptRefresh(string relPath) 2646 | { 2647 | var sp = SanitizeAssetsPath(relPath); 2648 | RefreshDebounce.Schedule(sp, TimeSpan.FromMilliseconds(200)); 2649 | } 2650 | 2651 | public static void ImportAndRequestCompile(string relPath, bool synchronous = true) 2652 | { 2653 | var sp = SanitizeAssetsPath(relPath); 2654 | var opts = ImportAssetOptions.ForceUpdate; 2655 | if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport; 2656 | AssetDatabase.ImportAsset(sp, opts); 2657 | #if UNITY_EDITOR 2658 | UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); 2659 | #endif 2660 | } 2661 | } 2662 | ```