Skip to content

Unity Roslyn REPL v0.7.2

Choose a tag to compare

@djdcks12 djdcks12 released this 12 May 01:27
· 57 commits to main since this release

[0.7.2] - 2026-05-12

Changed (Legacy package-id fallback)

  • The literal com.roslyn-repl folder probe in ReplPackagePaths.ResolvePackageRoot is gone. Path resolution now relies entirely on PackageInfo.FindForAssembly — Unity resolves embedded packages by the name field in package.json, not the containing folder name, so forcing the folder to match the published id would have broken every legitimate embedded-dev case (variants / forks / checkouts under different folder names sharing the same id).
  • IsBundledRoslynAssemblyPath now matches the bundled-DLL location through both PackageInfo.assetPath (AssetDatabase form, e.g. Packages/com.youngchan.roslyn-repl) and PackageInfo.resolvedPath (filesystem form, e.g. …/Library/PackageCache/com.youngchan.roslyn-repl@0.7.2). The first path covers embedded / direct-folder installs; the second covers OpenUPM and any other source where Unity stages the package under Library/PackageCache/. Without the resolvedPath leg, OpenUPM-installed copies — whose Assembly.Location never goes through the AssetDatabase form — would silently fall out of the BundledByUs classification and the setup verifier's "duplicate copy?" branch would lie about what's loaded.
  • The match also tightened from two independent Contains checks (/Editor/Plugins/Roslyn/ AND /<root>) to a single anchored segment <root>/Editor/Plugins/Roslyn/ (with leading / boundary or path-start anchor), so a folder named com.roslyn-repl-old/ no longer substring-matches a root of com.roslyn-repl/. PR-review followup.
  • Soft nudge for embedded checkouts: when PackageInfo.assetPath ends in a folder name other than com.youngchan.roslyn-repl, the resolver emits a single Debug.LogWarning per editor session asking the developer to rename the folder before consuming via OpenUPM. Local development keeps working through PackageInfo in the meantime. Closes #45.

Changed (Output result tree defaults to fields-only)

  • The Output result tree no longer invokes user-defined property getters by default. A property getter can run lazy init, IO, log emission, or state mutation — inspecting a value in Output shouldn't trip any of that for the user. Watch already opted out for the same reason (refreshing every Run multiplies the side effects); Output now matches.
  • New Tools / Roslyn REPL / Output: Include Property Getters toggle flips the behaviour per project. Off (default) walks fields only — instance, private, and inherited. On walks readable instance properties on top of that. The toggle lives in RoslynRepl.Editor.Core.OutputSettings.IncludeProperties so other surfaces can read or flip it programmatically.
  • Existing SimpleObjectSerializer.Options.IncludeProperties default stays at true so direct callers don't quietly change behaviour; the Output panel routes through a new BuildOutputTreeOptions() helper that consults the setting. README updated. Closes #28.

Changed (Apply-to-file backups moved out of Assets/Packages)

  • Apply to file no longer drops Foo.cs.bak next to the edited Foo.cs. Unity used to import the backup as a regular asset (.meta generated, Project window pollution, easy to accidentally commit inside Assets/ or Packages/). Backups now land under <project>/Library/RoslynRepl/Backups/<yyyyMMdd_HHmmss_fff>_<name>.cs.bak — Library/ is the Unity-reserved derived-data folder that the asset importer skips and the stock .gitignore template excludes.
  • The atomic-write pipeline is unchanged in spirit (same File.Replace swap with a same-directory dot-prefixed temp), only the destination of the pre-write backup moved. The Library/ path is passed to File.Replace as its destinationBackupFileName — Replace requires a non-null backup parameter, and the step-1 File.Copy already wrote identical bytes to that path, so Replace's overwrite is content-equivalent and the user's recovery copy stays valid before / during / after the swap. Source / destination / backup are all resolved to absolute filesystem paths first (Path.GetFullPath on the source) so File.Replace doesn't trip on the Unity-style asset paths it would otherwise see mixed with the absolute Library/ path; AssetDatabase.ImportAsset further down still receives the project-relative path it expects. Apply confirm dialog and the status bar message updated to point at the new path; README + README_kr Patches sections updated to call out the trade-off (Unity may delete Library/ on a reimport — copy a backup out if you need long-term recovery). Closes #44.

Changed (User data moved from EditorPrefs to project-local files)

  • Patch bodies, snippets, run history, and watch expressions now live in <project>/UserSettings/RoslynRepl/*.json instead of EditorPrefs. On Windows EditorPrefs is registry-backed, so the historical store leaked beyond project deletion and bloated as patches and snippets grew. The new files tie the data to the project folder — deleting the project reclaims everything, and UserSettings/ is the same place Unity ships its own per-user / per-project layout files.
  • Breaking for any pre-release 0.7.1 install: there is no migration from the old EditorPrefs keys. The package has not had a public consumer release yet, so preserving the historical blob would only carry forward dead code on every Load. Any 0.7.1 EditorPrefs entry stays orphaned in the registry; remove it by hand if you need to (the keys started with RoslynRepl.).
  • The first time the package writes into the folder it also drops a UserSettings/RoslynRepl/.gitignore containing a single * rule, so files in here never show up in git status — even on consumer projects whose top-level .gitignore doesn't already cover UserSettings/. This is defense-in-depth: patch bodies and history entries can carry server URLs / auth tokens / account values from a mid-debug paste, and a stock template gitignore is not a guarantee. Caught in PR review.
  • New RoslynRepl.Editor.Core.UserSettingsStorage is the single I/O helper every store routes through. Writes go through the same atomic File.Replace pipeline the source export uses (write to a dot-prefixed temp in the destination directory, then swap) so a crash mid-save can't leave a half-written blob the next session reads back as garbage.
  • UserSettingsStorage.Delete returns bool indicating whether the post-call state is "file does not exist", and routes any failure (locked file, missing permission) through Debug.LogWarning with the path. The four file-backed Clear() methods (SnippetStore, RunHistoryStore, WatchStore, PatchPersistence — and PatchRegistry.Clear which delegates to it) now propagate that flag, and Tools / Roslyn REPL / Reset Project Data aggregates the four results: any survivor surfaces a "partial failure" dialog listing the stuck files, the folder path, and the remediation (close any external editor, retry, or delete by hand) instead of the previous unconditional "Cleared N items" success. Without this the next Load() would silently resurrect the supposedly-cleared sensitive data and the user had no idea. PR-review followup.
  • SnippetStore.HasAny / RunHistoryStore.HasAny / WatchStore.HasAny mirror the existing PatchPersistence.HasAny so Reset Project Data's scope decision counts file existence separately from successful Load. Without those, a corrupt or unreadable JSON would collapse to an empty list and trip the early-return "Nothing to clear" — leaving the actual sensitive file untouched on disk. The confirm dialog now phrases those rows as "the on-disk <store> file (currently unreadable / cannot be decoded — wiped to recover)" so the user sees what the wipe is actually targeting. PR-review followup.
  • The Run History popup's Clear button now consumes the bool from RunHistoryStore.Clear. On failure the visible list refreshes from disk (so it matches the actual surviving file state instead of looking empty) and an inline error dialog points at the cause + the Console for the exact path. Previously a locked runHistory.json would leave the UI blank while the file — and its sensitive contents — sat untouched, ready to resurface the next time the popup opened. PR-review followup.
  • File schema is JSON with a version field so future format bumps can be detected; payload shapes are {version, items} for the three list stores and a SpecDto mirror of MethodPatchSpec (with an explicit HasOriginalBody flag so null vs "" source snapshots stay distinct across reload) for patches. README persistence + safety sections (EN + KR) updated to point at the new location and to call out the per-folder .gitignore. Closes #27.

Fixed (Generic parameter types in patch specs)

  • MethodPatchSpec.ParameterTypes now uses ; as the per-parameter separator instead of ,. Closed-generic CLR Type.FullName output embeds commas inside the assembly-qualified inner type list:

    List`1[[System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=…]]
    

    so the historic comma split in PatchEngine.ResolveParamTypes shredded any generic parameter into garbage and the spec failed to resolve at Apply / reload / source export — even though the form picker would happily list the method as patchable. Semicolon is illegal in CLR full type names, so it can't collide with anything inside a single parameter's name.

  • New MethodPatchSpec.JoinParamTypes / SplitParamTypes helpers centralize the encoding. Browse → Fill form and the manual form field both go through the join helper; the engine resolver, the diff, and the active-list display all go through the split helper. Legacy comma-joined data still loads (split falls back to , when the value contains no ; and no [), so plain non-generic specs persisted on 0.7.1 keep working. Legacy data that had embedded brackets (i.e. it was already broken) surfaces a clean "type not found" error from the type resolver instead of silently picking the wrong overload.

  • New MethodPatchSpec.NormalizeParamTypes(string) runs at every registry boundary — Keyed, AddOrUpdate, and LoadFromPersistence — so a legacy comma-joined entry persisted on 0.7.1 lands at the same registry slot as a freshly Browsed semicolon-joined entry for the same method. Without it Apply couldn't see the previously-installed Harmony prefix and would stack a second one. Caught in PR review for #41.

  • The Params field tooltip and the active-list row label now reflect the new semicolon-separated form. The row label still renders with , between entries for readability — the raw semicolon form is only the on-disk encoding. Closes #41.

Fixed (Generic-aware C# type renderer for patch wrappers)

  • New RoslynRepl.Editor.Patches.CSharpTypeName.Render(Type) is the single renderer the patch wrapper uses for both the Prefix declaring-type / parameter-type emission and every typeof() reference inside generated helper bodies. Handles closed generics (List<int> / Dictionary<string, int>), nested types (the historic + → .), nested generics with per-level introduced-arity attribution (Outer<int>.Inner / Outer<int>.Inner<string>), arrays of any rank, and Nullable<T> (rendered as the T? short form). The historic target.DeclaringType.FullName.Replace('+', '.') only worked for plain non-nested non-generic types — anything with a backtick / bracket in the FullName produced CLR-internal arity markers in the wrapper source and the Roslyn compile failed even after the spec resolved correctly to a real method.
  • PatchSyntaxRewriter.TypeRef and PatchSyntaxRewriter.RenderCSharpTypeWithSubstitution now route through CSharpTypeName.Render too. The rewriter previously carried its own copy of the renderer with the same backtick-truncation bug — so even after the wrapper signature was fixed, a body that read a private Outer<int>.Inner<string> member through __get<T> / __call<T> / typeof(...) would still emit invalid C# for that member's type and the wrapper would fail to compile on the next Apply. CSharpTypeName.Render gained an internal (Type, Func<Type, string>) overload so the rewriter's substitution callback (method-level generic argument → user TypeSyntax) can plug into the same nested-chain walk; the duplicate buggy renderer is gone. Reported in PR review.
  • Open generic types (generic type definitions, generic parameters) raise NotSupportedException at the renderer; PatchEngine.ResolveTargetMethod catches the upstream form (type.IsGenericTypeDefinition) and rejects with a friendly "specify a closed type, e.g. Foo<...>" message before the wrapper renderer ever runs. Closes #42.

Fixed (Watch fallback discovery cache)

  • Watch evaluation no longer re-runs the global instance discovery sweep for every fallback expression in a Refresh. The earlier shape made each unqualified or owner-qualified fallback call InstanceLocator.Find(InstanceCategory.All, …) directly, and Find rebuilt the pool from scratch every time — Resources.FindObjectsOfTypeAll<MonoBehaviour>() + Resources.FindObjectsOfTypeAll<ScriptableObject>() + SingletonScanner.Find() for each call, twice per fallback expression (qualified pass + unqualified pass). N fallback watches paid the discovery cost 2N times in a single Run, which felt slow on projects with many loaded scenes.
  • New WatchEvaluationScope opens once per WatchEvaluator.RefreshAll and lazily snapshots the global pool the first time any fallback expression needs it. Every later fallback call in the same refresh reuses the snapshot through InstanceLocator.FindFromSnapshot, which keeps the per-call filter + cap behaviour identical to the original Find path. The scope is disposed in a finally so destroyed Unity objects don't get pinned past the refresh — references are only held for the lifetime of one Refresh, never across refreshes. EvaluateOne called outside a Refresh (one-off API surface) opens its own throwaway scope so the standalone semantics survive. Closes #43.