diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2084912 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this package are documented in this file. + +## [0.4.2-preview] - 2026-07-30 + +### Added + +- Cross-platform discovery for stable, preview, portable, PATH, Nix, and custom Zed installations. +- SDK-style Unity project generation with support for analyzers registered by Unity. +- Project-local Roslyn, csharp-ls, OmniSharp, Unity file-type, and scan-exclusion settings. +- Documentation for the Zed Unity Snippets, shader-language, and Unity Debugger extensions. +- A disposable Unity CLI smoke test for real package import, compilation, and domain reload validation. + +### Changed + +- Project synchronization now refreshes package information and tolerates missing or null asset changes. +- Settings synchronization preserves existing values, malformed files, JSONC comments, and read-only projects. +- Warnings from persistent configuration problems are emitted once instead of on every asset import. + +### Fixed + +- Player projects now receive analyzers from the matching player compilation assembly. +- Missing or unsupported files are no longer passed to Zed. +- A failed compatibility cache reset no longer interrupts project synchronization. diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..dbcc27e --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 504f022aabbe4b4a897f99532430430f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ZedDiscovery.cs b/Editor/ZedDiscovery.cs index eceb725..0e89c31 100644 --- a/Editor/ZedDiscovery.cs +++ b/Editor/ZedDiscovery.cs @@ -1,8 +1,9 @@ -using Unity.CodeEditor; +using System; using System.Collections.Generic; -using System.Xml.XPath; using System.Text; +using System.Xml.XPath; using NiceIO; +using Unity.CodeEditor; namespace UnityZed { @@ -12,10 +13,11 @@ public CodeEditor.Installation[] GetInstallations() { var results = new List(); - var candidates = new (NPath path, TryGetVersion tryGetVersion)[] { + var candidates = new List<(NPath path, TryGetVersion tryGetVersion)> { // [MacOS] ("/Applications/Zed.app/Contents/MacOS/cli", TryGetVersionFromPlist), + ("/Applications/Zed Preview.app/Contents/MacOS/cli", TryGetVersionFromPlist), ("/usr/local/bin/zed", null), // [Linux] (Flatpak) @@ -27,33 +29,46 @@ public CodeEditor.Installation[] GetInstallations() // [Linux] (NixOS) ("/run/current-system/sw/bin/zeditor", null), // [Linux] (NixOS HomeManager from Zed Flake) - ("/etc/profiles/per-user/linx/bin/zed", null), + ($"/etc/profiles/per-user/{Environment.UserName}/bin/zed", null), // [Linux] (NixOS HomeManager from NixPkgs) - ("/etc/profiles/per-user/linx/bin/zeditor", null), + ($"/etc/profiles/per-user/{Environment.UserName}/bin/zeditor", null), // [Linux] (Official Website) (NPath.HomeDirectory.Combine(".local/bin/zed"), null), + (NPath.HomeDirectory.Combine(".local/zed.app/bin/zed"), null), }; + AddWindowsCandidates(candidates); + AddPathCandidates(candidates); + foreach (var candidate in candidates) { var candidatePath = candidate.path; var candidateTryGetVersion = candidate.tryGetVersion ?? TryGetVersionFallback; - if (candidatePath.FileExists()) + try { + if (candidatePath.FileExists() == false) + continue; + var name = new StringBuilder("Zed"); if (candidateTryGetVersion(candidatePath, out var version)) name.Append($" [{version}]"); - results.Add(new() + var installation = new CodeEditor.Installation { Name = name.ToString(), Path = candidatePath.MakeAbsolute().ToString(), - }); + }; - break; + if (results.Exists(result => string.Equals(result.Path, installation.Path, StringComparison.OrdinalIgnoreCase)) == false) + results.Add(installation); + } + catch (Exception) + { + // One inaccessible or malformed candidate must not prevent discovery + // in all other standard locations and PATH entries. } } @@ -71,6 +86,33 @@ public bool TryGetInstallationForPath(string editorPath, out CodeEditor.Installa } } + // Unity allows selecting an executable manually. Do not reject a valid custom, + // preview, portable, or future Zed install merely because it is not in our list. + if (string.IsNullOrWhiteSpace(editorPath)) + { + installation = default; + return false; + } + + try + { + var customPath = new NPath(editorPath); + if (customPath.FileExists() && customPath.FileNameWithoutExtension.StartsWith("zed", StringComparison.OrdinalIgnoreCase)) + { + installation = new() + { + Name = "Zed [Custom]", + Path = customPath.MakeAbsolute().ToString(), + }; + return true; + } + } + catch (ArgumentException) + { + // Invalid paths can be stored by older Unity preferences. Treat them as + // unsupported rather than breaking the External Tools preferences UI. + } + installation = default; return false; } @@ -78,7 +120,46 @@ public bool TryGetInstallationForPath(string editorPath, out CodeEditor.Installa // // TryGetVersion implementations // - private delegate bool TryGetVersion(NPath path, out string vertion); + private static void AddWindowsCandidates(List<(NPath path, TryGetVersion tryGetVersion)> candidates) + { + AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs/Zed/Zed.exe"); + AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Zed/Zed.exe"); + AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Zed/Zed.exe"); + } + + private static void AddPathCandidates(List<(NPath path, TryGetVersion tryGetVersion)> candidates) + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) + return; + + foreach (var directory in path.Split(System.IO.Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(directory)) + continue; + + try + { + var candidateDirectory = new NPath(directory.Trim().Trim('"')); + candidates.Add((candidateDirectory.Combine("zed"), null)); + candidates.Add((candidateDirectory.Combine("zeditor"), null)); + candidates.Add((candidateDirectory.Combine("zed.exe"), null)); + } + catch (ArgumentException) + { + // Ignore malformed PATH entries; explicitly configured candidates and + // all remaining entries should still be considered. + } + } + } + + private static void AddCandidate(List<(NPath path, TryGetVersion tryGetVersion)> candidates, string directory, string relativePath) + { + if (string.IsNullOrEmpty(directory) == false) + candidates.Add((new NPath(directory).Combine(relativePath), null)); + } + + private delegate bool TryGetVersion(NPath path, out string version); private static bool TryGetVersionFallback(NPath path, out string version) { diff --git a/Editor/ZedExternalCodeEditor.cs b/Editor/ZedExternalCodeEditor.cs index 6149185..4726177 100644 --- a/Editor/ZedExternalCodeEditor.cs +++ b/Editor/ZedExternalCodeEditor.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using Unity.CodeEditor; using UnityEngine; @@ -28,6 +29,7 @@ private static IGenerator CreateSdkStyleGeneration() private ZedPreferences m_Preferences; private ZedSettings m_Settings; private IGenerator m_Generator; + private bool m_CacheResetWarningLogged; public void Initialize(string editorInstallationPath) { @@ -35,6 +37,10 @@ public void Initialize(string editorInstallationPath) m_Generator = CreateSdkStyleGeneration(); m_Preferences = new(m_Generator); m_Settings = new(); + m_Settings.Sync(); + + if (m_Generator.HasSolutionBeenGenerated() == false) + m_Generator.Sync(); } // @@ -56,9 +62,15 @@ public bool OpenProject(string filePath = "", int line = -1, int column = -1) Assert.IsNotNull(m_Process); Assert.IsNotNull(m_Generator); - if (!string.IsNullOrEmpty(filePath) && !m_Generator.IsSupportedFile(filePath)) + if (!string.IsNullOrEmpty(filePath) && m_Generator.IsSupportedFile(filePath) == false) { - sLogger.Log($"File '{filePath}' is not supported by the generator."); + sLogger.LogWarning($"File '{filePath}' is not supported by the project generator."); + return false; + } + + if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath) == false) + { + sLogger.LogWarning($"File '{filePath}' does not exist."); return false; } @@ -72,6 +84,8 @@ public void SyncAll() { Assert.IsNotNull(m_Generator); + ResetProjectGenerationCache(); + AssetDatabase.Refresh(); m_Generator.Sync(); m_Settings.Sync(); } @@ -80,7 +94,45 @@ public void SyncIfNeeded(string[] addedFiles, string[] deletedFiles, string[] mo { Assert.IsNotNull(m_Generator); - m_Generator.SyncIfNeeded(addedFiles.Union(deletedFiles).Union(movedFiles).Union(movedFromFiles), importedFiles); + ResetProjectGenerationCache(); + m_Generator.SyncIfNeeded( + (addedFiles ?? Array.Empty()) + .Union(deletedFiles ?? Array.Empty()) + .Union(movedFiles ?? Array.Empty()) + .Union(movedFromFiles ?? Array.Empty()), + importedFiles ?? Array.Empty()); + m_Settings.Sync(); + } + + private void ResetProjectGenerationCache() + { + try + { + var provider = m_Generator.AssemblyNameProvider; + for (var type = provider.GetType(); type != null; type = type.BaseType) + { + var method = type.GetMethod( + "ResetPackageInfoCache", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.DeclaredOnly); + if (method == null) + continue; + + method.Invoke(provider, null); + return; + } + } + catch (Exception exception) + { + // This is an optimization for package moves and updates. Project sync must + // continue if a future Visual Studio Editor package changes its internals. + if (m_CacheResetWarningLogged == false) + { + m_CacheResetWarningLogged = true; + sLogger.LogWarning($"Could not reset the project-generation cache: {exception.Message}"); + } + } } // diff --git a/Editor/ZedLogger.cs b/Editor/ZedLogger.cs index 0780092..878f00f 100644 --- a/Editor/ZedLogger.cs +++ b/Editor/ZedLogger.cs @@ -13,9 +13,9 @@ public static ILogger Create([CallerFilePath] string filePath = null) var tag = path.FileNameWithoutExtension; #if UNITY_ZED_DEBUG - var handler = new LogHandler(tag, Debug.unityLogger.logHandler); + var handler = new LogHandler(tag, Debug.unityLogger.logHandler, true); #else - var handler = new LogHandler(tag, null); + var handler = new LogHandler(tag, Debug.unityLogger.logHandler, false); #endif return new Logger(handler); @@ -25,18 +25,23 @@ private class LogHandler : ILogHandler { private readonly ILogHandler m_LogHandler; private readonly string m_Tag; + private readonly bool m_LogMessages; - public LogHandler(string tag, ILogHandler logHandler = null) + public LogHandler(string tag, ILogHandler logHandler, bool logMessages) { m_Tag = tag; m_LogHandler = logHandler; + m_LogMessages = logMessages; } public void LogException(Exception exception, UnityEngine.Object context) => m_LogHandler?.LogException(exception, context); public void LogFormat(LogType logType, UnityEngine.Object context, string format, params object[] args) - => m_LogHandler?.LogFormat(logType, context, $"[{m_Tag}] {format}", args); + { + if (m_LogMessages || logType != LogType.Log) + m_LogHandler.LogFormat(logType, context, $"[{m_Tag}] {format}", args); + } } } } diff --git a/Editor/ZedProjectPostprocessor.cs b/Editor/ZedProjectPostprocessor.cs new file mode 100644 index 0000000..fa3a4d6 --- /dev/null +++ b/Editor/ZedProjectPostprocessor.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security; +using System.Text; +using Unity.CodeEditor; +using UnityEditor; +using UnityEditor.Compilation; + +namespace UnityZed +{ + internal class ZedProjectPostprocessor : AssetPostprocessor + { + private const string kNewLine = "\r\n"; + + private static string OnGeneratedCSProject(string path, string contents) + { + if (CodeEditor.CurrentEditor is ZedExternalCodeEditor == false) + return contents; + + try + { + var assembly = FindAssembly(contents); + var analyzers = GetAnalyzerPaths(assembly).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (analyzers.Length == 0) + return contents; + + return AddItems(contents, "Analyzer", analyzers); + } + catch (Exception exception) + { + ZedLogger.Create().LogWarning($"Could not add analyzers to '{path}': {exception.Message}"); + return contents; + } + } + + private static UnityEditor.Compilation.Assembly FindAssembly(string contents) + { + var assemblyName = GetElementValue(contents, "AssemblyName"); + if (string.IsNullOrEmpty(assemblyName)) + return null; + + const string playerSuffix = ".Player"; + var isPlayerAssembly = assemblyName.EndsWith(playerSuffix, StringComparison.Ordinal); + var unityAssemblyName = isPlayerAssembly + ? assemblyName.Substring(0, assemblyName.Length - playerSuffix.Length) + : assemblyName; + var assemblyType = isPlayerAssembly ? AssembliesType.Player : AssembliesType.Editor; + + return CompilationPipeline.GetAssemblies(assemblyType) + .FirstOrDefault(assembly => assembly.name == unityAssemblyName); + } + + private static IEnumerable GetAnalyzerPaths(UnityEditor.Compilation.Assembly assembly) + { +#if UNITY_2020_2_OR_NEWER + if (assembly == null) + return Array.Empty(); + + return (assembly.compilerOptions.RoslynAnalyzerDllPaths ?? Array.Empty()) + .Where(analyzer => string.IsNullOrEmpty(analyzer) == false && File.Exists(analyzer)) + .Select(Path.GetFullPath); +#else + return Array.Empty(); +#endif + } + + private static string AddItems(string contents, string itemName, IEnumerable paths) + { + var newPaths = paths + .Where(path => contents.IndexOf($"Include=\"{SecurityElement.Escape(path)}\"", StringComparison.OrdinalIgnoreCase) < 0) + .ToArray(); + if (newPaths.Length == 0) + return contents; + + var projectEnd = contents.LastIndexOf("", StringComparison.Ordinal); + if (projectEnd < 0) + return contents; + + var itemGroup = new StringBuilder(); + itemGroup.Append(" ").Append(kNewLine); + foreach (var path in newPaths) + itemGroup.Append(" <").Append(itemName).Append(" Include=\"").Append(SecurityElement.Escape(path)).Append("\" />").Append(kNewLine); + itemGroup.Append(" ").Append(kNewLine); + + return contents.Insert(projectEnd, itemGroup.ToString()); + } + + private static string GetElementValue(string contents, string elementName) + { + var opening = $"<{elementName}>"; + var start = contents.IndexOf(opening, StringComparison.Ordinal); + if (start < 0) + return null; + + start += opening.Length; + var end = contents.IndexOf($"", start, StringComparison.Ordinal); + return end < 0 ? null : contents.Substring(start, end - start).Trim(); + } + } +} diff --git a/Editor/ZedProjectPostprocessor.cs.meta b/Editor/ZedProjectPostprocessor.cs.meta new file mode 100644 index 0000000..1ae6329 --- /dev/null +++ b/Editor/ZedProjectPostprocessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d54a9ac730b4d4ab9d3603fab70a8b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ZedSettings.cs b/Editor/ZedSettings.cs index 88ddb89..a19fb6f 100644 --- a/Editor/ZedSettings.cs +++ b/Editor/ZedSettings.cs @@ -1,92 +1,327 @@ -using UnityEngine; +using System; +using System.Collections.Generic; using NiceIO; using SimpleJSON; -using System; +using UnityEngine; namespace UnityZed { public class ZedSettings { private static readonly ILogger sLogger = ZedLogger.Create(); + private static readonly HashSet sLoggedWarnings = new(); - private readonly NPath m_SettingsPath; + private readonly NPath m_ProjectPath; public ZedSettings() { - m_SettingsPath = new NPath(Application.dataPath).Parent.Combine(".zed/settings.json"); + m_ProjectPath = new NPath(Application.dataPath).Parent; } public void Sync() { - if (m_SettingsPath.FileExists() == false) + // A read-only checkout or one malformed configuration file must never stop + // Unity from opening the requested script. Keep every artifact independent. + SyncSafely("Zed workspace settings", SyncSettings); + SyncSafely("OmniSharp settings", SyncOmniSharpSettings); + } + + private static void SyncSafely(string description, Action sync) + { + try + { + sync(); + } + catch (Exception exception) { - sLogger.Log("Zed settings file not found, creating default settings file."); - m_SettingsPath.CreateFile(); - m_SettingsPath.WriteAllText(JSON.Parse(kDefaultSettings).ToString()); + LogWarningOnce($"{description}:{exception.Message}", $"Could not synchronize {description}: {exception.Message}"); } } - private const string kDefaultSettings = @"{ - ""file_scan_exclusions"": [ - ""**/.*"", - ""**/*~"", - - ""*.csproj"", - ""*.sln"", - - ""**/*.meta"", - ""**/*.booproj"", - ""**/*.pibd"", - ""**/*.suo"", - ""**/*.user"", - ""**/*.userprefs"", - ""**/*.unityproj"", - ""**/*.dll"", - ""**/*.exe"", - ""**/*.pdf"", - ""**/*.mid"", - ""**/*.midi"", - ""**/*.wav"", - ""**/*.gif"", - ""**/*.ico"", - ""**/*.jpg"", - ""**/*.jpeg"", - ""**/*.png"", - ""**/*.psd"", - ""**/*.tga"", - ""**/*.tif"", - ""**/*.tiff"", - ""**/*.3ds"", - ""**/*.3DS"", - ""**/*.fbx"", - ""**/*.FBX"", - ""**/*.lxo"", - ""**/*.LXO"", - ""**/*.ma"", - ""**/*.MA"", - ""**/*.obj"", - ""**/*.OBJ"", - ""**/*.asset"", - ""**/*.cubemap"", - ""**/*.flare"", - ""**/*.mat"", - ""**/*.meta"", - ""**/*.prefab"", - ""**/*.unity"", - - ""build/"", - ""Build/"", - ""library/"", - ""Library/"", - ""obj/"", - ""Obj/"", - ""ProjectSettings/"", - ""UserSettings/"", - ""temp/"", - ""Temp/"", - ""logs"", - ""Logs"", - ] - }"; + private void SyncSettings() + { + var path = m_ProjectPath.Combine(".zed/settings.json"); + var settings = ReadObject(path); + if (settings == null) + return; + + var exclusions = GetOrCreateArray(settings, "file_scan_exclusions", path); + if (exclusions == null) + return; + + foreach (var exclusion in kFileScanExclusions) + AddIfMissing(exclusions, exclusion); + + var fileTypes = GetOrCreateObject(settings, "file_types", path); + if (fileTypes == null) + return; + + foreach (var association in kUnityFileTypes) + { + var extensions = GetOrCreateArray(fileTypes, association.language, path); + if (extensions == null) + continue; + + foreach (var extension in association.extensions) + AddIfMissing(extensions, extension); + } + + var lsp = GetOrCreateObject(settings, "lsp", path); + if (lsp == null) + return; + + var roslyn = GetOrCreateObject(lsp, "roslyn", path); + var roslynSettings = roslyn == null ? null : GetOrCreateObject(roslyn, "settings", path); + if (roslynSettings != null) + { + var backgroundAnalysis = GetOrCreateObject(roslynSettings, "csharp|background_analysis", path); + if (backgroundAnalysis != null) + { + SetIfMissing(backgroundAnalysis, "dotnet_analyzer_diagnostics_scope", "fullSolution"); + SetIfMissing(backgroundAnalysis, "dotnet_compiler_diagnostics_scope", "fullSolution"); + } + + var completion = GetOrCreateObject(roslynSettings, "csharp|completion", path); + if (completion != null) + { + SetIfMissing(completion, "dotnet_show_name_completion_suggestions", true); + SetIfMissing(completion, "dotnet_show_completion_items_from_unimported_namespaces", true); + SetIfMissing(completion, "dotnet_trigger_completion_in_argument_lists", true); + } + + var navigation = GetOrCreateObject(roslynSettings, "csharp|navigation", path); + if (navigation != null) + { + SetIfMissing(navigation, "dotnet_navigate_to_decompiled_sources", true); + SetIfMissing(navigation, "dotnet_navigate_to_source_link_and_embedded_sources", true); + } + } + + var csharpLs = GetOrCreateObject(lsp, "csharp-ls", path); + var csharpLsSettings = csharpLs == null ? null : GetOrCreateObject(csharpLs, "settings", path); + if (csharpLsSettings != null) + SetIfMissing(csharpLsSettings, "analyzersEnabled", true); + + Write(path, settings); + } + + private void SyncOmniSharpSettings() + { + var path = m_ProjectPath.Combine("omnisharp.json"); + var settings = ReadObject(path); + if (settings == null) + return; + + // Unity's generated projects contain references to Unity.Analyzers.dll. OmniSharp + // does not load those analyzers unless Roslyn extension support is enabled. + var roslyn = GetOrCreateObject(settings, "RoslynExtensionsOptions", path); + if (roslyn == null) + return; + + SetIfMissing(roslyn, "enableAnalyzersSupport", true); + SetIfMissing(roslyn, "enableImportCompletion", true); + SetIfMissing(roslyn, "analyzeOpenDocumentsOnly", false); + + var formatting = GetOrCreateObject(settings, "FormattingOptions", path); + if (formatting == null) + return; + + SetIfMissing(formatting, "enableEditorConfigSupport", true); + + Write(path, settings); + } + + private static JSONObject ReadObject(NPath path) + { + if (path.FileExists() == false) + return new JSONObject(); + + try + { + var contents = path.ReadAllText(); + if (HasJsonComments(contents)) + { + LogWarningOnce($"{path}:comments", $"'{path}' contains comments; leaving it unchanged to preserve them."); + return null; + } + + var result = JSON.Parse(contents); + if (result.IsObject) + return result as JSONObject; + + LogWarningOnce($"{path}:object", $"'{path}' must contain a JSON object; leaving it unchanged."); + } + catch (Exception exception) + { + LogWarningOnce($"{path}:{exception.Message}", $"Could not update '{path}': {exception.Message}"); + } + + return null; + } + + private static bool HasJsonComments(string contents) + { + var insideString = false; + var escaped = false; + + for (var index = 0; index < contents.Length - 1; index++) + { + var character = contents[index]; + if (insideString) + { + if (escaped) + escaped = false; + else if (character == '\\') + escaped = true; + else if (character == '"') + insideString = false; + + continue; + } + + if (character == '"') + { + insideString = true; + continue; + } + + if (character == '/' && (contents[index + 1] == '/' || contents[index + 1] == '*')) + return true; + } + + return false; + } + + private static JSONObject GetOrCreateObject(JSONObject parent, string key, NPath path) + { + if (parent.HasKey(key)) + { + if (parent[key].IsObject) + return parent[key] as JSONObject; + + LogWarningOnce($"{path}:{key}:object", $"'{path}' setting '{key}' must be an object; leaving it unchanged."); + return null; + } + + var result = new JSONObject(); + parent[key] = result; + return result; + } + + private static JSONArray GetOrCreateArray(JSONObject parent, string key, NPath path) + { + if (parent.HasKey(key)) + { + if (parent[key].IsArray) + return parent[key] as JSONArray; + + LogWarningOnce($"{path}:{key}:array", $"'{path}' setting '{key}' must be an array; leaving it unchanged."); + return null; + } + + var result = new JSONArray(); + parent[key] = result; + return result; + } + + private static void SetIfMissing(JSONObject parent, string key, bool value) + { + if (parent.HasKey(key) == false) + parent[key] = value; + } + + private static void SetIfMissing(JSONObject parent, string key, string value) + { + if (parent.HasKey(key) == false) + parent[key] = value; + } + + private static void AddIfMissing(JSONArray array, string value) + { + foreach (var child in array.Children) + if (child.Value == value) + return; + + array.Add(value); + } + + private static void LogWarningOnce(string key, string message) + { + if (sLoggedWarnings.Add(key)) + sLogger.LogWarning(message); + } + + private static void Write(NPath path, JSONNode contents) + { + path.Parent.CreateDirectory(); + path.ReplaceAllText(contents.ToString(4) + Environment.NewLine); + } + + private static readonly string[] kFileScanExclusions = + { + "**/.*", + "**/*~", + "*.csproj", + "*.sln", + "**/*.meta", + "**/*.booproj", + "**/*.pibd", + "**/*.suo", + "**/*.user", + "**/*.userprefs", + "**/*.unityproj", + "**/*.dll", + "**/*.exe", + "**/*.pdf", + "**/*.mid", + "**/*.midi", + "**/*.wav", + "**/*.gif", + "**/*.ico", + "**/*.jpg", + "**/*.jpeg", + "**/*.png", + "**/*.psd", + "**/*.tga", + "**/*.tif", + "**/*.tiff", + "**/*.3ds", + "**/*.3DS", + "**/*.fbx", + "**/*.FBX", + "**/*.lxo", + "**/*.LXO", + "**/*.ma", + "**/*.MA", + "**/*.obj", + "**/*.OBJ", + "**/*.asset", + "**/*.cubemap", + "**/*.flare", + "**/*.mat", + "**/*.prefab", + "**/*.unity", + "build/", + "Build/", + "library/", + "Library/", + "obj/", + "Obj/", + "ProjectSettings/", + "UserSettings/", + "temp/", + "Temp/", + "logs/", + "Logs/", + }; + + private static readonly (string language, string[] extensions)[] kUnityFileTypes = + { + ("JSON", new[] { "*.asmdef", "*.asmref" }), + ("HLSL", new[] { "*.shader", "*.compute", "*.cginc", "*.hlsl", "*.raytrace" }), + ("GLSL", new[] { "*.glslinc" }), + ("XML", new[] { "*.uxml" }), + ("CSS", new[] { "*.uss" }), + }; } } diff --git a/README.md b/README.md index 3a444ca..b824851 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,54 @@ # Zed for Unity [![openupm](https://img.shields.io/npm/v/com.maligan.unity-zed?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/com.maligan.unity-zed/) -This is a homemade package to integrate [Zed](https://zed.dev) as Unity external script editor. +First-class [Zed](https://zed.dev) integration for Unity. It connects Unity's +project-generation pipeline to Zed's C# language server and creates the workspace +configuration needed for a productive Unity C# experience. -## Roadmap +## Features -- [x] Discovery of Zed installations -- [x] Register as Unity external tools -- [x] Support C# sln/csproj generation -- [ ] Write Zed extension to deeper integration via IPC +- Discovers stable, preview, portable, PATH, Nix, and standard Zed installations on + Windows, macOS, and Linux and registers them in Unity's + **External Script Editor** preference. +- Opens the project, file, line, and column selected in Unity. +- Generates and incrementally refreshes SDK-style solutions and projects through + Unity's official Visual Studio Editor package. +- Makes Unity assemblies, package sources, defines, references, and analyzers + registered with Unity's compilation pipeline available to Zed's C# language server. +- Enables OmniSharp analyzer diagnostics, import completion, full-solution analysis, + and `.editorconfig` formatting without replacing existing user preferences. +- Configures Zed's default Roslyn server for full-solution compiler/analyzer + diagnostics, unimported-name completion, argument-list completion, decompiled + navigation, and Source Link navigation; it also enables analyzers for `csharp-ls`. +- Works with Zed's marketplace **Unity Snippets** extension to provide Unity message + completions such as `Awake`, `Update`, `OnDestroy`, physics callbacks, rendering + callbacks, serialization callbacks, and state-machine callbacks. +- Associates `.asmdef`, `.asmref`, `.shader`, `.compute`, `.cginc`, `.hlsl`, + `.glslinc`, `.raytrace`, `.uxml`, and `.uss` with their corresponding Zed + languages for Unity-aware syntax coloration. +- Merges Unity file exclusions into an existing `.zed/settings.json` instead of + overwriting the user's Zed configuration. + +## Requirements + +Install Zed's C# extension and a .NET SDK supported by that extension. Select this +package's Zed entry under **Unity > Preferences > External Tools > External Script +Editor**, then press **Regenerate project files** once. Opening a script from Unity +also synchronizes everything automatically. + +The generated integration files are: + +| File | Purpose | +| --- | --- | +| `.sln` and `.csproj` | C# completion, navigation, refactoring, package references, source generators, and project analyzers | +| `.zed/settings.json` | Fast project scanning that ignores Unity-generated and binary files | +| `omnisharp.json` | Analyzer, import-completion, solution-analysis, and EditorConfig support | + +All JSON configuration is idempotent. Existing values win, unknown settings are +preserved, invalid JSON is left untouched, and unchanged files are not rewritten. +JSON-with-comments files are also left untouched so synchronization never +silently removes a user's comments. A read-only or malformed file cannot prevent Zed +from opening a script; Unity reports the affected integration file as a warning and +continues synchronizing the others. ## Installation @@ -15,8 +56,56 @@ This is a homemade package to integrate [Zed](https://zed.dev) as Unity external # 1. Via OpenUPM openupm add com.maligan.unity-zed -# 2. Via PackageManger & GitHub URL +# 2. Via Package Manager & GitHub URL https://github.com/maligan/unity-zed.git # 3. Via copy this repository content into Packages/ folder ``` + +## Required Zed extensions + +Install the following from Zed's Extensions page: + +- **C#** — Roslyn language-server completion, navigation, refactoring, formatting, + and code actions. +- [**Unity Snippets**](https://github.com/Abdallah-Alwarawreh/unity-zed-snippets) — Unity API message and template completions. Zed only loads + snippets from installed extensions or its user configuration directory; project + `.zed/snippets` directories are not supported. +- [**HLSL**](https://github.com/igordreher/zed-hlsl) and **GLSL** — shader syntax highlighting for the file associations this + package adds. +- [**Unity Debugger**](https://github.com/tomires/zed-unity-debugger) — Unity Editor/player debugging through a separately supplied, + Unity-compatible Debug Adapter Protocol implementation. Follow that extension's + setup instructions and use an adapter whose license permits use outside Microsoft + IDEs. + +## C# language-server notes + +The generated Unity projects are the source of truth for semantic completion, +go-to-definition, references, rename, formatting, code actions, and diagnostics. +The default Zed C# server is Roslyn. `omnisharp.json` additionally enables analyzers +when the C# extension is explicitly configured to use OmniSharp. Other Roslyn-based +servers read analyzer and source-generator references from the generated projects. + +This package does not redistribute analyzer binaries or download executable code. +Analyzers and source generators installed through Unity packages or assembly +definitions and exposed by Unity's compilation pipeline are forwarded to the generated +projects automatically. + +This package deliberately does not download or execute a debugger binary. Microsoft's +VS Code Unity debug adapter is not licensed for use in Zed; use the Zed **Unity +Debugger** extension with a compatible independently licensed adapter instead. + +## Unity CLI smoke test + +The package includes a disposable-project smoke test that performs a real Unity +batch-mode import and domain reload, compiles the package against the installed Unity +Editor, and executes settings synchronization and discovery: + +```sh +UNITY_EDITOR=/path/to/Unity Tests~/run-unity-smoke-test.sh +``` + +The script creates its project under the system temporary directory and always removes +it on exit. It never modifies the calling Unity project or adds it to Unity Hub's recent +project list. Use an already activated Unity Editor; the script intentionally never +accepts, stores, or forwards license credentials. diff --git a/Tests~/run-unity-smoke-test.sh b/Tests~/run-unity-smoke-test.sh new file mode 100755 index 0000000..59195ef --- /dev/null +++ b/Tests~/run-unity-smoke-test.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +unity_editor="${UNITY_EDITOR:-}" + +if [[ -z "${unity_editor}" ]]; then + for candidate in unity-editor Unity unity; do + if command -v "${candidate}" >/dev/null 2>&1; then + unity_editor="$(command -v "${candidate}")" + break + fi + done +fi + +if [[ -z "${unity_editor}" || ! -x "${unity_editor}" ]]; then + echo "Unity Editor CLI not found. Set UNITY_EDITOR to the Unity executable." >&2 + exit 2 +fi + +project_path="$(mktemp -d "${TMPDIR:-/tmp}/unity-zed-smoke.XXXXXX")" +trap 'rm -rf "${project_path}"' EXIT + +mkdir -p "${project_path}/Assets/Editor" "${project_path}/Packages" + +python3 - "${repo_root}" "${project_path}/Packages/manifest.json" <<'PY' +import json +import pathlib +import sys + +repository = pathlib.Path(sys.argv[1]).resolve().as_uri() +manifest = { + "dependencies": { + "com.maligan.unity-zed": repository, + "com.unity.ide.visualstudio": "2.0.20", + } +} +pathlib.Path(sys.argv[2]).write_text(json.dumps(manifest, indent=2) + "\n") +PY + +cat > "${project_path}/Assets/Editor/UnityZedSmoke.asmdef" <<'EOF' +{ + "name": "UnityZed.SmokeTests", + "references": ["com.maligan.zed-unity"], + "includePlatforms": ["Editor"], + "autoReferenced": false +} +EOF + +cat > "${project_path}/Assets/Editor/UnityZedSmoke.cs" <<'EOF' +using System; +using UnityZed; + +namespace UnityZedSmoke +{ + public static class Runner + { + public static void Run() + { + // Let exceptions escape. In batch mode Unity reports an execute-method + // exception as a failed process instead of accidentally returning success. + new ZedSettings().Sync(); + new ZedDiscovery().GetInstallations(); + Console.WriteLine("UNITY_ZED_SMOKE_TEST_PASSED"); + } + } +} +EOF + +log_path="${project_path}/unity.log" +if ! "${unity_editor}" \ + -batchmode \ + -nographics \ + -quit \ + -forgetProjectPath \ + -projectPath "${project_path}" \ + -executeMethod UnityZedSmoke.Runner.Run \ + -logFile - 2>&1 | tee "${log_path}"; then + echo "Unity batch-mode smoke test failed." >&2 + exit 1 +fi + +if ! grep -Fq "UNITY_ZED_SMOKE_TEST_PASSED" "${log_path}"; then + echo "Unity exited without executing the smoke-test method." >&2 + exit 1 +fi diff --git a/package.json b/package.json index 4bc32ac..7b97bbd 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "com.maligan.unity-zed", "displayName": "Zed Editor", - "description": "Zed Editor integration for Unity", - "version": "0.2.3-preview", + "description": "First-class Zed Editor integration for Unity with project generation, project analyzers, and Unity message completions", + "version": "0.4.2-preview", "dependencies": { "com.unity.ide.visualstudio": "2.0.20" }