chore(bridge): 删除桥接包的提交记录和Git属性配置

删除了 .codely-bridge-commit 文件中存储的提交哈希值
移除了 .gitattributes 文件中的行结束符配置规则
```
This commit is contained in:
huadexuan committed 2026-09-24 21:41:14 +08:00
1 parent d26d323912
commit 13d0c80335
392 files changed
+1814 -1895

No files matched your search

@@ -0,0 +1,747 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
namespace UnityTcp.Editor.Tools
{
public static partial class Repl
{
// Ported from cn.tuanjie.csharp-repl's ApiHelper (TuanJie.CSharpRepl.Editor.ApiHelper),
// nested here so it rides Repl's existing unconditional-imports injection instead of needing
// its own. Reflecting a package's full member set on every query is the slow path this class
// exists to avoid, so results are indexed once and kept -- in memory for the session, and on
// disk (unlike every other REPL cache in this file, which is deliberately memory-only and
// relies on a domain reload to invalidate it) because building the index is expensive enough
// (reflecting every type in every matching assembly in the AppDomain) that it's worth
// surviving a reload or an Editor restart. The on-disk root moves from the original
// Packages/cn.tuanjie.csharp-repl/Agents~/References/ApiDocs (a directory convention this
// package doesn't have) to Library/CodelyBridge/ApiDocs -- Unity's standard per-project,
// git-ignored, freely-regenerable-cache location.
public static class ApiHelper
{
internal static readonly string ApiDocsRoot = Path.Combine("Library", "CodelyBridge", "ApiDocs");
static readonly Dictionary<string, PackageIndex> s_indexes = new Dictionary<string, PackageIndex>(StringComparer.OrdinalIgnoreCase);
static readonly HashSet<string> s_fallbackBuilt = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public struct MemberRef
{
public string type, name, kind, acc;
public bool isStatic;
public string Describe() => $"{(isStatic ? "static " : "")}{kind} {type}.{name} ({acc})";
}
class PackageIndex
{
public Dictionary<string, List<MemberRef>> members;
public Dictionary<string, string> types;
public Dictionary<string, List<DetailEntry>> details;
public string detailPath;
public bool detailLoaded;
public string version; // stored version from manifest
}
public struct DetailEntry { public string acc; public bool isStatic; public string sig; }
// Chainable query builder. Defaults: kind="method", access="pub". .Get() -> human-readable.
// .Json() -> structured.
public class QueryBuilder
{
internal string pkg, kw, kind, acc;
internal bool asJson;
const int MaxResults = 200;
internal QueryBuilder(string p, string k) { pkg = p; kw = k; kind = "method"; acc = "pub"; }
public QueryBuilder Kind(string k) { kind = k; return this; }
public QueryBuilder Access(string a) { acc = a; return this; }
public QueryBuilder Methods() => Kind("method");
public QueryBuilder Props() => Kind("prop");
public QueryBuilder Fields() => Kind("field");
public QueryBuilder Public() => Access("pub");
public QueryBuilder Internal() => Access("int");
public QueryBuilder Private() => Access("pri");
public QueryBuilder AllKinds() { kind = null; return this; }
public QueryBuilder AllAccess() { acc = null; return this; }
public QueryBuilder Json() { asJson = true; return this; }
public string Get()
{
if (pkg == null) return GetAllPackages();
var idx = GetIndex(pkg);
if (idx == null) idx = BuildFallbackIndex(pkg);
if (idx == null) return asJson ? "{\"error\":\"Package not found: " + pkg + "\"}" : "[ApiHelper] Package not found: " + pkg;
return SearchIndex(idx, pkg);
}
string GetAllPackages()
{
var allPkgs = new List<string>();
if (Directory.Exists(ApiDocsRoot))
allPkgs = Directory.GetDirectories(ApiDocsRoot).Select(Path.GetFileName).Where(n => n != "_archive" && File.Exists(Path.Combine(ApiDocsRoot, n, "api-index.json"))).ToList();
if (allPkgs.Count == 0) return asJson ? "{\"count\":0,\"error\":\"No indexed packages\"}" : "[ApiHelper] No indexed packages found.";
var norm = Normalize(kw.ToLowerInvariant());
if (asJson)
{
var jsb = new StringBuilder(); jsb.Append("{\"count\":0,\"packages\":{");
var firstPkg = true; var total = 0;
foreach (var p in allPkgs.OrderBy(n => n))
{
var i = GetIndex(p); if (i == null) continue;
var cnt = 0; foreach (var kv in i.members) { if (!Normalize(kv.Key).Contains(norm)) continue; cnt += kv.Value.Count(r => (kind == null || r.kind == kind) && (acc == null || r.acc == acc)); } total += cnt;
if (!firstPkg) jsb.Append(","); firstPkg = false;
jsb.Append("\"" + p + "\":" + cnt);
}
jsb.Append("},\"total\":" + total + "}"); return jsb.ToString();
}
var sb = new StringBuilder();
sb.AppendLine("Searching " + allPkgs.Count + " packages for '" + kw + "':");
var tot = 0; var pkgCount = 0;
foreach (var p in allPkgs.OrderBy(n => n))
{
var i = GetIndex(p); if (i == null) continue;
var cnt = 0; foreach (var kv in i.members) { if (!Normalize(kv.Key).Contains(norm)) continue; cnt += kv.Value.Count(r => (kind == null || r.kind == kind) && (acc == null || r.acc == acc)); }
if (cnt > 0) { sb.AppendLine(" " + p + ": " + cnt + " match(es)"); tot += cnt; pkgCount++; }
}
if (tot == 0) return "[ApiHelper] No match for '" + kw + "' across " + allPkgs.Count + " packages.";
sb.AppendLine("Total: " + tot + " match(es) across " + pkgCount + " packages. Use ApiHelper.Find(package, \"" + kw + "\").Get() to see details.");
return sb.ToString();
}
string SearchIndex(PackageIndex idx, string pkgName)
{
var results = new List<MemberRef>();
var lower = kw.ToLowerInvariant();
var normalized = Normalize(lower);
foreach (var kv in idx.members)
{
var nk = Normalize(kv.Key);
if (!nk.Contains(normalized)) continue;
foreach (var r in kv.Value)
{
if (kind != null && r.kind != kind) continue;
if (acc != null && r.acc != acc) continue;
results.Add(r);
if (results.Count >= MaxResults) break;
}
if (results.Count >= MaxResults) break;
}
if (results.Count == 0)
{
var msg = "[ApiHelper] No " + (kind ?? "member") + " matching '" + kw + "' in " + pkgName + (acc != null ? " (access=" + acc + ")" : "") + ". Try .AllKinds() or .AllAccess().";
return asJson ? "{\"count\":0,\"hint\":\"" + msg.Replace("\"", "\\\"") + "\"}" : msg;
}
if (asJson) return FormatJson(results);
return FormatText(results);
}
string FormatText(List<MemberRef> results)
{
var sb = new StringBuilder();
sb.AppendLine(results.Count + " match(es):");
var shown = new HashSet<string>();
foreach (var r in results)
{
sb.AppendLine(" " + r.Describe());
var key = r.type + "." + r.name;
if (shown.Add(key))
sb.AppendLine(" => Detail(\"" + r.type + "\", \"" + r.name + "\")");
}
return sb.ToString();
}
string FormatJson(List<MemberRef> results)
{
var sb = new StringBuilder();
sb.Append("{\"count\":" + results.Count + ",\"results\":[");
for (int i = 0; i < results.Count; i++)
{
var r = results[i];
sb.Append("{\"type\":\"" + r.type + "\",\"name\":\"" + r.name + "\",\"kind\":\"" + r.kind + "\",\"access\":\"" + r.acc + "\",\"static\":" + (r.isStatic ? "1" : "0") + "}");
if (i < results.Count - 1) sb.Append(",");
}
sb.Append("]}");
return sb.ToString();
}
static string Normalize(string s)
{
var sb = new StringBuilder(s.Length);
foreach (var c in s) { if (c != '_' && c != '-' && c != '.') sb.Append(c); }
return sb.ToString();
}
}
// -- Public API --
public static QueryBuilder Find(string package, string memberNamePart)
=> new QueryBuilder(package, memberNamePart);
// Search ALL indexed packages for a member keyword. Returns QueryBuilder (needs .Get()).
public static QueryBuilder SearchAll(string keyword)
=> new QueryBuilder(null, keyword);
static string NormalizeAll(string s)
{
var sb = new StringBuilder(s.Length);
foreach (var c in s) { if (c != '_' && c != '-' && c != '.') sb.Append(c); }
return sb.ToString();
}
public static string Detail(string typeFullName, string memberName)
{
var key = typeFullName + "." + memberName;
foreach (var pkg in s_indexes.Keys.ToArray())
{
var idx2 = s_indexes[pkg];
if (!idx2.detailLoaded) LoadDetails(pkg, idx2);
if (idx2.details.TryGetValue(key, out var entries))
return FormatDetailList(key, entries);
}
if (Directory.Exists(ApiDocsRoot))
{
foreach (var dir in Directory.GetDirectories(ApiDocsRoot))
{
var pkgName = Path.GetFileName(dir);
if (pkgName == "_archive") continue;
var idx3 = GetIndex(pkgName);
if (idx3 == null) continue;
if (!idx3.detailLoaded) LoadDetails(pkgName, idx3);
if (idx3.details.TryGetValue(key, out var entries))
return FormatDetailList(key, entries);
}
}
return "[ApiHelper] No detail for " + key;
}
public static string ListType(string typeFullName)
{
foreach (var pkg in s_indexes.Keys.ToArray())
if (s_indexes[pkg].types.TryGetValue(typeFullName, out var mn))
return typeFullName + " members: " + mn;
if (Directory.Exists(ApiDocsRoot))
{
foreach (var dir in Directory.GetDirectories(ApiDocsRoot))
{
var pkgName = Path.GetFileName(dir);
if (pkgName == "_archive") continue;
var idx2 = GetIndex(pkgName);
if (idx2 != null && idx2.types.TryGetValue(typeFullName, out var mn))
return typeFullName + " members: " + mn;
}
}
return "[ApiHelper] Type not found: " + typeFullName;
}
public static string Browse(string package, string keyword = null)
{
var idx = GetIndex(package);
if (idx == null) idx = BuildFallbackIndex(package);
if (idx == null) return "[ApiHelper] Package not found: " + package;
if (string.IsNullOrEmpty(keyword))
{
var ns = new SortedDictionary<string, int>();
foreach (var tn in idx.types.Keys)
{
var ld = tn.LastIndexOf('.');
var n = ld > 0 ? tn.Substring(0, ld) : "(root)";
if (!ns.ContainsKey(n)) ns[n] = 0;
ns[n]++;
}
var sb = new StringBuilder();
sb.AppendLine(package + ": " + ns.Count + " namespaces, " + idx.types.Count + " types");
foreach (var kv in ns) sb.AppendLine(" " + kv.Key + ": " + kv.Value);
return sb.ToString();
}
else
{
var lower = keyword.ToLowerInvariant();
var norm = NormalizeAll(lower);
var matched = new List<string>();
foreach (var tn in idx.types.Keys)
{
if (NormalizeAll(tn.ToLowerInvariant()).Contains(norm))
{
matched.Add(tn);
if (matched.Count >= 100) break;
}
}
if (matched.Count == 0) return "[ApiHelper] No type matching '" + keyword + "' in " + package;
var sb2 = new StringBuilder();
sb2.AppendLine(matched.Count + " type(s) matching '" + keyword + "':");
foreach (var t in matched)
{
var mc = idx.types.TryGetValue(t, out var ms) ? ms.Split(',').Length : 0;
sb2.AppendLine(" " + t + " (" + mc + " members)");
}
return sb2.ToString();
}
}
public static string ClearCache() { s_indexes.Clear(); s_fallbackBuilt.Clear(); return "Cache cleared."; }
// Not part of the original ApiHelper: staleness there is judged purely by comparing the
// manifest's stored version against PackageInfo.FindForPackageName(package).version, so
// editing a package's code without bumping its version leaves the cached index (memory
// or disk) looking valid forever -- ClearCache() alone doesn't fix this either, since the
// next query just reloads that same still-version-matching disk file. This bypasses the
// version check entirely: drop both the in-memory entry and the on-disk folder for this
// one package, so the next query is a guaranteed real rebuild, regardless of version.
public static string Refresh(string package)
{
s_indexes.Remove(package);
s_fallbackBuilt.Remove(package);
var dir = Path.Combine(ApiDocsRoot, package);
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
return "Refreshed " + package + ".";
}
public static string Packages()
{
var sb = new StringBuilder();
sb.AppendLine("Documented packages:");
var dirs = Directory.Exists(ApiDocsRoot)
? Directory.GetDirectories(ApiDocsRoot).Select(Path.GetFileName).Where(n => n != "_archive").OrderBy(n => n).ToArray()
: new string[0];
foreach (var d in dirs)
{
var has = File.Exists(Path.Combine(ApiDocsRoot, d, "api-index.json"));
sb.AppendLine(" " + d + (has ? " [indexed]" : ""));
}
return sb.ToString();
}
// -- Reflection fallback (with version tracking) --
// Not the original ApiHelper's algorithm: that version matched a package id against
// loaded assembly names by fuzzy per-segment substring (e.g. "com.unity.burst" ->
// requires "com"/"unity"/"burst" all as substrings somewhere in the assembly name).
// Tested against every package actually installed in this project: every real UPM
// package id (com.unity.*, cn.tuanjie.*) matched zero assemblies, because assembly
// names come from each package's own .asmdef "name" field (chosen independently by the
// package author) and have no reliable string relationship to the reverse-DNS package
// id -- e.g. cn.tuanjie.codely.bridge's four asmdefs are named UnityTcp.Editor /
// Codely.Common / Cn.Tuanjie.Codely.Editor / UnityTcp.Editor.Tests, none containing
// "bridge". Worse, when the fuzzy match did hit, it silently hit the *wrong* assembly:
// both "com.unity.burst" and "com.unity.collections" matched the same unrelated helper
// assembly Unity.Collections.BurstCompatibilityGen (its name happens to contain both
// "unity"+"burst" and "unity"+"collections" as substrings) -- a false positive that
// returns another package's signatures instead of reporting "not found", strictly worse
// than a miss. Replaced with two deterministic layers and no substring guessing: (1)
// resolve the package's own .asmdef-declared assembly names via PackageInfo, matched
// exactly; (2) exact assembly-name equality, for callers who pass a bare assembly name
// instead of a package id. If neither finds anything, this is genuinely not found.
static Assembly[] ResolvePackageAssemblies(string package)
{
string pkgAssetPath = null;
try
{
var pkgInfoType = Type.GetType("UnityEditor.PackageManager.PackageInfo, UnityEditor.CoreModule");
if (pkgInfoType != null)
{
var findMethod = pkgInfoType.GetMethod("FindForPackageName", BindingFlags.Public | BindingFlags.Static);
if (findMethod != null)
{
var info = findMethod.Invoke(null, new object[] { package }) as UnityEditor.PackageManager.PackageInfo;
if (info != null && !string.IsNullOrEmpty(info.assetPath))
pkgAssetPath = info.assetPath;
}
}
}
catch { }
if (string.IsNullOrEmpty(pkgAssetPath))
pkgAssetPath = ResolvePackageDirectory(package);
if (!string.IsNullOrEmpty(pkgAssetPath) && Directory.Exists(pkgAssetPath))
{
var asmNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var asmdefPath in Directory.GetFiles(pkgAssetPath, "*.asmdef", SearchOption.AllDirectories))
{
try
{
var name = ExtractAsmdefName(File.ReadAllText(asmdefPath));
if (!string.IsNullOrEmpty(name))
asmNames.Add(name);
}
catch { }
}
if (asmNames.Count > 0)
{
var viaAsmdef = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => asmNames.Contains(a.GetName().Name))
.ToArray();
if (viaAsmdef.Length > 0)
return viaAsmdef;
}
}
return AppDomain.CurrentDomain.GetAssemblies()
.Where(a => string.Equals(a.GetName().Name, package, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
static string ResolvePackageDirectory(string package)
{
var projectRoot = Directory.GetCurrentDirectory();
var standardPath = Path.Combine(projectRoot, "Packages", package);
if (Directory.Exists(standardPath))
return standardPath;
var cacheDir = Path.Combine(projectRoot, "Library", "PackageCache");
try
{
if (Directory.Exists(cacheDir))
{
var matchedDir = Directory.EnumerateDirectories(cacheDir, package + "@*").FirstOrDefault();
if (matchedDir != null)
return matchedDir;
}
}
catch { }
return null;
}
static string ExtractPackageJsonVersion(string json)
{
var m = Regex.Match(json, @"(?m)^\s*""version""\s*:\s*""([^""]+)""");
return m.Success ? m.Groups[1].Value : null;
}
// Deliberately not ExtractJsonValue (below): that helper's exact-substring search
// ("\"name\":\"") assumes the compact, no-whitespace JSON this class writes for its own
// persisted cache. A real .asmdef file is pretty-printed by Unity with a space after the
// colon ("name": "UnityTcp.Editor"), which silently misses that search and falls through
// to ExtractJsonValue's non-quoted-value branch, returning garbage (quote characters and
// all) instead of failing loudly. Asmdef JSON is externally authored, not this class's
// own output, so it gets its own whitespace-tolerant parse.
static string ExtractAsmdefName(string json)
{
var m = Regex.Match(json, "\"name\"\\s*:\\s*\"([^\"]*)\"");
return m.Success ? m.Groups[1].Value : null;
}
static PackageIndex BuildFallbackIndex(string package)
{
if (!s_fallbackBuilt.Add(package)) return null;
var asms = ResolvePackageAssemblies(package).Distinct().ToArray();
if (asms.Length == 0) return null;
var idx = new PackageIndex { members = new Dictionary<string, List<MemberRef>>(StringComparer.OrdinalIgnoreCase), types = new Dictionary<string, string>(), details = new Dictionary<string, List<DetailEntry>>(), detailPath = null, detailLoaded = true };
var bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
var di = new Dictionary<string, List<DetailEntry>>();
foreach (var asm in asms)
{
try { foreach (var t in asm.GetTypes()) { if (t.IsNested || t.Name.StartsWith("<")) continue;
var isP = t.IsPublic || t.IsNestedPublic; var a = isP ? "pub" : "int";
var mn = new List<string>();
try { foreach (var m in t.GetMethods(bf)) { if (m.IsSpecialName) continue;
var n = m.Name; var st = m.IsStatic; var sig = m.Name + "(" + string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name)) + ") : " + m.ReturnType.Name;
var lo = n.ToLowerInvariant(); var rf = new MemberRef { type = t.FullName, name = n, kind = "method", acc = a, isStatic = st };
if (!idx.members.ContainsKey(lo)) idx.members[lo] = new List<MemberRef>(); idx.members[lo].Add(rf); mn.Add(n);
var dk = t.FullName + "." + n; if (!di.ContainsKey(dk)) di[dk] = new List<DetailEntry>();
di[dk].Add(new DetailEntry { acc = a, isStatic = st, sig = sig });
} } catch { }
try { foreach (var p in t.GetProperties(bf)) { if (p.GetIndexParameters().Length > 0) continue;
var n = p.Name; var st = p.GetAccessors(true).Length > 0 && p.GetAccessors(true)[0].IsStatic; var sig = p.Name + " : " + p.PropertyType.Name + (p.CanWrite ? " rw" : " ro");
var lo = n.ToLowerInvariant(); var rf = new MemberRef { type = t.FullName, name = n, kind = "prop", acc = a, isStatic = st };
if (!idx.members.ContainsKey(lo)) idx.members[lo] = new List<MemberRef>(); idx.members[lo].Add(rf); mn.Add(n);
var dk = t.FullName + "." + n; if (!di.ContainsKey(dk)) di[dk] = new List<DetailEntry>();
di[dk].Add(new DetailEntry { acc = a, isStatic = st, sig = sig });
} } catch { }
try { foreach (var f2 in t.GetFields(bf)) {
var n = f2.Name; var st = f2.IsStatic; var sig = f2.Name + " : " + f2.FieldType.Name;
var lo = n.ToLowerInvariant(); var rf = new MemberRef { type = t.FullName, name = n, kind = "field", acc = a, isStatic = st };
if (!idx.members.ContainsKey(lo)) idx.members[lo] = new List<MemberRef>(); idx.members[lo].Add(rf); mn.Add(n);
var dk = t.FullName + "." + n; if (!di.ContainsKey(dk)) di[dk] = new List<DetailEntry>();
di[dk].Add(new DetailEntry { acc = a, isStatic = st, sig = sig });
} } catch { }
if (mn.Count > 0) idx.types[t.FullName] = string.Join(",", mn);
} } catch (ReflectionTypeLoadException) { } catch { }
}
idx.details = di;
// Get installed version
var version = "";
try
{
var pkgInfoType = Type.GetType("UnityEditor.PackageManager.PackageInfo, UnityEditor.CoreModule");
if (pkgInfoType != null)
{
var findMethod = pkgInfoType.GetMethod("FindForPackageName", BindingFlags.Public | BindingFlags.Static);
if (findMethod != null)
{
var info = findMethod.Invoke(null, new object[] { package });
if (info != null)
{
var verProp = info.GetType().GetProperty("version");
if (verProp != null) version = verProp.GetValue(info)?.ToString() ?? "";
}
}
}
}
catch { }
// Persist to disk with version
try
{
var dir = Path.Combine(ApiDocsRoot, package);
Directory.CreateDirectory(dir);
var sch = "$schema";
var isb = new StringBuilder();
isb.Append("{\"" + sch + "\":\"api-index/v1\",\"pkg\":\"" + package + "\",\"types\":{");
var tks = new List<string>(idx.types.Keys); tks.Sort();
for (int i = 0; i < tks.Count; i++) { isb.Append("\"" + tks[i] + "\":{\"ms\":\"" + idx.types[tks[i]] + "\"}"); if (i < tks.Count - 1) isb.Append(","); }
isb.Append("},\"mem\":{");
var mks = new List<string>(idx.members.Keys); mks.Sort();
for (int i = 0; i < mks.Count; i++) { isb.Append("\"" + mks[i] + "\":["); var list = idx.members[mks[i]]; for (int j = 0; j < list.Count; j++) { var r = list[j]; isb.Append("{\"t\":\"" + r.type + "\",\"n\":\"" + r.name + "\",\"k\":\"" + r.kind + "\",\"a\":\"" + r.acc + "\",\"s\":" + (r.isStatic ? "1" : "0") + "}"); if (j < list.Count - 1) isb.Append(","); } isb.Append("]"); if (i < mks.Count - 1) isb.Append(","); }
isb.Append("}}");
File.WriteAllText(Path.Combine(dir, "api-index.json"), isb.ToString());
var dsb = new StringBuilder();
dsb.Append("{\"" + sch + "\":\"api-detail/v1\",\"pkg\":\"" + package + "\",\"sig\":{");
var dks = new List<string>(di.Keys); dks.Sort();
for (int i = 0; i < dks.Count; i++) { dsb.Append("\"" + dks[i] + "\":["); var el = di[dks[i]]; for (int j = 0; j < el.Count; j++) { dsb.Append("{\"a\":\"" + el[j].acc + "\",\"s\":" + (el[j].isStatic ? "1" : "0") + ",\"v\":\"" + el[j].sig.Replace("\\","\\\\").Replace("\"","") + "\"}"); if (j < el.Count - 1) dsb.Append(","); } dsb.Append("]"); if (i < dks.Count - 1) dsb.Append(","); }
dsb.Append("}}");
File.WriteAllText(Path.Combine(dir, "api-detail.json"), dsb.ToString());
File.WriteAllText(Path.Combine(dir, "manifest.json"),
"{\"$schema\":\"api-docs/v1\",\"package\":\"" + package + "\",\"version\":\"" + version + "\",\"description\":\"Auto-generated from reflection fallback.\"}");
}
catch { }
idx.version = version;
s_indexes[package] = idx;
return idx;
}
// -- Index loading (with schema validation + stale detection) --
static PackageIndex GetIndex(string package)
{
if (s_indexes.TryGetValue(package, out var idx)) return idx;
var dir = Path.Combine(ApiDocsRoot, package);
var indexFile = Path.Combine(dir, "api-index.json");
if (!File.Exists(indexFile)) return null;
idx = new PackageIndex { members = new Dictionary<string, List<MemberRef>>(StringComparer.OrdinalIgnoreCase), types = new Dictionary<string, string>(), details = new Dictionary<string, List<DetailEntry>>(), detailPath = Path.Combine(dir, "api-detail.json"), detailLoaded = false };
try
{
var json = File.ReadAllText(indexFile);
// Schema validation
if (!json.Contains("\"mem\":{") || !json.Contains("\"types\":{"))
{
// Malformed -- rebuild
s_fallbackBuilt.Remove(package);
return BuildFallbackIndex(package);
}
var memStart = json.IndexOf("\"mem\":{") + 7;
ParseMembers(json, memStart, idx);
var typesStart = json.IndexOf("\"types\":{") + 9;
ParseTypes(json, typesStart, idx);
// Version check: if manifest has a version, compare with installed
var mfPath = Path.Combine(dir, "manifest.json");
if (File.Exists(mfPath))
{
var mf = File.ReadAllText(mfPath);
var verIdx = mf.IndexOf("\"version\":\"");
if (verIdx > 0)
{
var verStart = verIdx + 11;
var verEnd = mf.IndexOf('"', verStart);
idx.version = mf.Substring(verStart, verEnd - verStart);
// Check if still installed with same version
try
{
var pkgInfoType = Type.GetType("UnityEditor.PackageManager.PackageInfo, UnityEditor.CoreModule");
var findMethod = pkgInfoType?.GetMethod("FindForPackageName", BindingFlags.Public | BindingFlags.Static);
if (findMethod != null)
{
var info = findMethod.Invoke(null, new object[] { package });
if (info != null)
{
var verProp = info.GetType().GetProperty("version");
var currentVer = verProp?.GetValue(info)?.ToString() ?? "";
if (!string.IsNullOrEmpty(currentVer) && currentVer != idx.version)
{
// Version mismatch -- stale, rebuild
s_fallbackBuilt.Remove(package);
return BuildFallbackIndex(package);
}
}
else
{
// Package not installed but index exists -- stale, attempt rebuild
s_fallbackBuilt.Remove(package);
return BuildFallbackIndex(package);
}
}
else
{
// FindForPackageName unavailable -- fall back to directory check
var pkgDir = ResolvePackageDirectory(package);
if (pkgDir == null)
{
s_fallbackBuilt.Remove(package);
return BuildFallbackIndex(package);
}
// Directory exists -- also check package.json version
var pkgJsonPath = Path.Combine(pkgDir, "package.json");
if (File.Exists(pkgJsonPath))
{
try
{
var pkgVer = ExtractPackageJsonVersion(File.ReadAllText(pkgJsonPath));
if (!string.IsNullOrEmpty(pkgVer) && pkgVer != idx.version)
{
s_fallbackBuilt.Remove(package);
return BuildFallbackIndex(package);
}
}
catch { }
}
}
}
catch { }
}
}
}
catch (Exception)
{
s_fallbackBuilt.Remove(package);
return BuildFallbackIndex(package);
}
s_indexes[package] = idx;
return idx;
}
static void ParseMembers(string json, int start, PackageIndex idx)
{
var pos = start;
while (pos < json.Length)
{
if (json[pos] == '}') break;
if (json[pos] != '"') { pos++; continue; }
pos++;
var keyEnd = json.IndexOf('"', pos);
if (keyEnd < 0) break;
var key = json.Substring(pos, keyEnd - pos);
pos = keyEnd + 1;
while (pos < json.Length && json[pos] != '[') pos++;
if (pos >= json.Length) break;
pos++;
var list = new List<MemberRef>();
while (pos < json.Length)
{
if (json[pos] == ']') { pos++; break; }
if (json[pos] == '{')
{
var objEnd = json.IndexOf('}', pos);
if (objEnd < 0) break;
var obj = json.Substring(pos + 1, objEnd - pos - 1);
list.Add(new MemberRef { type = ExtractJsonValue(obj, "t"), name = ExtractJsonValue(obj, "n"), kind = ExtractJsonValue(obj, "k"), acc = ExtractJsonValue(obj, "a"), isStatic = obj.Contains("\"s\":1") || obj.Contains("\"s\":true") });
pos = objEnd + 1;
}
else pos++;
}
if (list.Count > 0) idx.members[key] = list;
}
}
static void ParseTypes(string json, int start, PackageIndex idx)
{
var pos = start;
while (pos < json.Length)
{
if (json[pos] == '}') { pos++; break; }
if (json[pos] != '"') { pos++; continue; }
pos++;
var keyEnd = json.IndexOf('"', pos);
if (keyEnd < 0) break;
var typeName = json.Substring(pos, keyEnd - pos);
pos = keyEnd + 1;
while (pos < json.Length && json[pos] != ':') pos++;
pos++;
if (pos < json.Length && json[pos] == '{') pos++;
var valStart = json.IndexOf("\"ms\":\"", pos);
if (valStart < 0) { pos++; continue; }
valStart += 6;
var valEnd = json.IndexOf('"', valStart);
if (valEnd < 0) break;
idx.types[typeName] = json.Substring(valStart, valEnd - valStart);
pos = valEnd + 1;
if (pos < json.Length && json[pos] == '"') pos++;
if (pos < json.Length && json[pos] == '}') pos++;
if (pos < json.Length && json[pos] == ',') pos++;
}
}
static string ExtractJsonValue(string json, string key)
{
var search = "\"" + key + "\":\"";
var i = json.IndexOf(search);
if (i < 0) { search = "\"" + key + "\":"; i = json.IndexOf(search); if (i < 0) return ""; i += search.Length; return json.Substring(i, json.IndexOfAny(new[] { ',', '}' }, i) - i); }
i += search.Length;
var end = json.IndexOf('"', i);
return end > i ? json.Substring(i, end - i) : "";
}
static void LoadDetails(string package, PackageIndex idx)
{
if (idx.detailLoaded || !File.Exists(idx.detailPath)) return;
try
{
var json = File.ReadAllText(idx.detailPath);
var sigStart = json.IndexOf("\"sig\":{");
if (sigStart < 0) return;
sigStart += 7;
var pos = sigStart;
while (pos < json.Length)
{
if (json[pos] == '}') break;
if (json[pos] != '"') { pos++; continue; }
pos++;
var keyEnd = json.IndexOf('"', pos);
if (keyEnd < 0) break;
var key = json.Substring(pos, keyEnd - pos);
pos = keyEnd + 1;
while (pos < json.Length && json[pos] != '[') pos++;
if (pos >= json.Length) break;
pos++;
var entries = new List<DetailEntry>();
while (pos < json.Length)
{
if (json[pos] == ']') { pos++; break; }
if (json[pos] == '{')
{
var objEnd = json.IndexOf('}', pos);
if (objEnd < 0) break;
var obj = json.Substring(pos + 1, objEnd - pos - 1);
entries.Add(new DetailEntry { acc = ExtractJsonValue(obj, "a"), isStatic = obj.Contains("\"s\":1") || obj.Contains("\"s\":true"), sig = ExtractJsonValue(obj, "v") });
pos = objEnd + 1;
}
else pos++;
}
if (entries.Count > 0) idx.details[key] = entries;
}
}
catch (Exception) { }
idx.detailLoaded = true;
}
static string FormatDetailList(string key, List<DetailEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(key + " (" + entries.Count + " overload(s)):");
foreach (var e in entries)
{
var access = e.acc == "pub" ? "public" : e.acc == "int" ? "internal" : e.acc == "pri" ? "private" : e.acc == "pro" ? "protected" : e.acc;
sb.AppendLine(" [" + access + "] " + (e.isStatic ? "static " : "") + e.sig);
}
return sb.ToString();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c1a07a2a500b6928daa04d87f4346c51
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,214 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using Codely.Microsoft.CodeAnalysis.Scripting;
using UnityEditor;
using UnityEngine;
namespace UnityTcp.Editor.Tools
{
// Static host class exposed to script code via an unconditional imports entry (HandleCommand
// always adds this namespace, even when the caller overrides `imports`). No globals/preamble
// injection is used -- the API is stateless (Id/ById) or reads shared static session state
// (Vars), so a plain static class is sufficient and has zero injection cost. A script declaring
// its own `var Repl = ...;` shadows this class for the rest of the session; that's an accepted,
// documented trade-off rather than something worth guarding against.
// partial: the ApiHelper nested class (ported from cn.tuanjie.csharp-repl, reflection signature
// lookup with a disk-cached index) lives in Repl.ApiHelper.cs to keep this file to the
// Id/ById/Vars surface.
public static partial class Repl
{
// Stashes a cross-domain-reload handle for `obj`, built on GlobalObjectId. A domain reload
// wipes every `var` a REPL session holds, so a script can call Repl.Id(x) before a
// reload-inducing operation (unlocking ReplGuard, entering Play Mode) and hand the token to
// Repl.ById() afterward to get a live reference back, instead of re-resolving by name/path
// (which breaks on duplicate names or objects with no stable path).
public static string Id(UnityEngine.Object obj)
{
if (obj == null)
return "OBJECT <null> NOT FOUND (missing or destroyed)";
// "Unsaved" cannot be determined from GlobalObjectId.targetObjectId == 0 alone: a
// brand-new GameObject in an already-saved scene gets a nonzero targetObjectId
// immediately (predictive local-file-id assignment) and round-trips fine even before
// its own save. Two checks instead: (1) a scene GameObject/Component whose containing
// scene has never been saved has nothing on disk to resolve against after a real
// scene close/reopen, even though it round-trips within the current in-memory session;
// (2) a round-trip through the same resolver ById() uses, which also catches a pure
// in-memory object with no scene and no asset path (e.g. an unsaved ScriptableObject).
if (!EditorUtility.IsPersistent(obj))
{
var transform = AsTransform(obj);
if (transform != null && string.IsNullOrEmpty(transform.gameObject.scene.path))
return "UNSAVED " + PathFor(obj) + " (id does not survive reload -- save the scene first)";
}
var gid = GlobalObjectId.GetGlobalObjectIdSlow(obj);
var resolved = GlobalObjectId.GlobalObjectIdentifierToObjectSlow(gid);
if (resolved != obj)
return "UNSAVED " + PathFor(obj) + " (id does not survive reload -- save the scene first)";
return gid.ToString();
}
// Returns null and logs a Debug.LogWarning with the reason on failure (missing/malformed
// token, scene not loaded, object deleted). Deliberately Debug.LogWarning rather than the
// internal CodelyLogger -- this bridge's log capture surfaces Application.logMessageReceived
// output in the response's `logs` field, so the reason for a null comes back with the call
// instead of requiring a separate console read.
public static UnityEngine.Object ById(string token)
{
if (string.IsNullOrEmpty(token))
{
Debug.LogWarning("Repl.ById('') NOT FOUND: empty token");
return null;
}
if (!GlobalObjectId.TryParse(token, out var gid))
{
Debug.LogWarning($"Repl.ById('{token}') NOT FOUND: malformed token");
return null;
}
var obj = GlobalObjectId.GlobalObjectIdentifierToObjectSlow(gid);
if (obj == null)
{
Debug.LogWarning(
$"Repl.ById('{token}') NOT FOUND: object not resolvable (its scene may not be loaded, or it was deleted)");
return null;
}
return obj;
}
// Read-only snapshot of the active session's top-level variables, taken from the
// ScriptState left behind by the last successfully completed submission (a script calling
// Repl.Vars mid-submission only sees variables from earlier submissions, same as a real
// REPL). Empty during a one-shot execution (enable_repl:false) even if a session happens to
// exist in the background -- one-shot must not read session state, only bypass it.
public static ReplVars Vars
{
get
{
var vars = new ReplVars();
var state = ExecuteCSharpScript.CurrentSessionStateForVars;
if (state == null)
return vars;
foreach (var variable in state.Variables)
vars.Set(variable.Name, variable.Type, variable.Value);
return vars;
}
}
static Transform AsTransform(UnityEngine.Object obj)
{
switch (obj)
{
case GameObject go:
return go.transform;
case Component comp:
return comp.transform;
default:
return null;
}
}
static string PathFor(UnityEngine.Object obj)
{
if (EditorUtility.IsPersistent(obj))
{
var path = AssetDatabase.GetAssetPath(obj);
return string.IsNullOrEmpty(path) ? obj.name : path;
}
var transform = AsTransform(obj);
return transform != null ? GetPath(transform) : obj.name;
}
static string GetPath(Transform transform)
{
if (transform == null)
return string.Empty;
var stack = new Stack<string>();
var cur = transform;
while (cur != null)
{
stack.Push(cur.name);
cur = cur.parent;
}
return string.Join("/", stack.ToArray());
}
}
// Read-only view over a REPL session's top-level variables. Not a raw forward of
// ScriptState.Variables: it collapses same-named redeclarations across submissions down to the
// last one (matching what a real REPL session sees -- a later `var x = ...;` shadows an
// earlier one), and it renders as a Name/Type/value table via ToString() instead of the default
// dictionary/collection output, so submitting a bare `Repl.Vars` line is a session-state
// overview. Deliberately no write API: unlike Python's globals(), there's no scenario here where
// the caller isn't already in scope to just assign the variable directly, and ScriptVariable's
// setter throws for variables declared with `readonly`/const-like semantics -- exposing writes
// would only add an error surface with no real use case.
public sealed class ReplVars : IReadOnlyDictionary<string, object>
{
readonly List<string> m_Order = new List<string>();
readonly Dictionary<string, object> m_Values = new Dictionary<string, object>();
readonly Dictionary<string, Type> m_Types = new Dictionary<string, Type>();
internal ReplVars() { }
internal void Set(string name, Type type, object value)
{
if (!m_Values.ContainsKey(name))
m_Order.Add(name);
m_Values[name] = value;
m_Types[name] = type;
}
public object this[string key] => m_Values[key];
public IEnumerable<string> Keys => m_Order;
public IEnumerable<object> Values
{
get
{
foreach (var name in m_Order)
yield return m_Values[name];
}
}
public int Count => m_Order.Count;
public bool ContainsKey(string key) => m_Values.ContainsKey(key);
public bool TryGetValue(string key, out object value) => m_Values.TryGetValue(key, out value);
public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
{
foreach (var name in m_Order)
yield return new KeyValuePair<string, object>(name, m_Values[name]);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
// Only variables are visible here (methods/types declared in the session have no
// ScriptVariable entry -- a boundary of ScriptState.Variables itself, not something this
// wrapper can fill in).
public override string ToString()
{
if (m_Order.Count == 0)
return "(no session variables)";
var sb = new StringBuilder();
sb.Append("Name".PadRight(24)).Append(' ').Append("Type".PadRight(24)).Append(' ').Append("Value").Append('\n');
foreach (var name in m_Order)
{
var typeName = m_Types[name]?.FullName ?? "?";
var preview = ExecuteCSharpScript.FormatResultValue(m_Values[name]) ?? "null";
sb.Append(name.PadRight(24)).Append(' ').Append(typeName.PadRight(24)).Append(' ').Append(preview).Append('\n');
}
return sb.ToString();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 40dd46d8aac23467677f277b97ca4869
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Codely.Microsoft.CodeAnalysis;
using Codely.Microsoft.CodeAnalysis.CSharp;
using Codely.Microsoft.CodeAnalysis.CSharp.Syntax;
namespace UnityTcp.Editor.Tools
{
// Detects domain-reload-triggering calls in a REPL session submission. Reload silently wipes
// every static session field (ScriptState, declared variables, imports) -- the point of this
// scan is not to forbid reloads but to turn that implicit, session-destroying side effect into
// an explicit, acknowledged step. Purely syntactic (matches real invocation expressions by
// identifier text, so tokens inside comments/string literals never false-positive); the caller
// owns the exemption policy (empty-session first submission, `unlock_domain_reload`).
static class ReplGuard
{
static readonly string[] s_CsWriteMethodNames =
{
"WriteAllText", "WriteAllLines", "AppendAllText", "AppendAllLines", "CreateText"
};
// Returns null if `code` is safe to run, or a descriptive error (with recovery options)
// if it contains a call that would trigger a domain reload.
public static string FindReloadTrigger(string code)
{
if (string.IsNullOrWhiteSpace(code))
return null;
CompilationUnitSyntax root;
try
{
var tree = CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(kind: SourceCodeKind.Script));
root = tree.GetCompilationUnitRoot();
}
catch
{
// If it won't even parse, let the compiler surface the real error.
return null;
}
var invocations = root.DescendantNodes().OfType<InvocationExpressionSyntax>().ToList();
if (invocations.Any(inv => IsMemberCall(inv, "AssetDatabase", "Refresh")))
return BuildBlockedMessage("AssetDatabase.Refresh() triggers a domain reload");
if (invocations.Any(inv => IsMemberCall(inv, "AssetDatabase", "SaveAssets")))
return BuildBlockedMessage("AssetDatabase.SaveAssets() can trigger a domain reload");
if (invocations.Any(inv => IsMemberCall(inv, "AssetDatabase", "ImportAsset")))
return BuildBlockedMessage(
"AssetDatabase.ImportAsset(...) triggers a domain reload when the imported asset is a script");
if (invocations.Any(inv => IsMemberCall(inv, "CompilationPipeline", "RequestScriptCompilation")))
return BuildBlockedMessage("CompilationPipeline.RequestScriptCompilation() triggers a domain reload");
if (invocations.Any(inv => IsMemberCall(inv, "EditorUtility", "RequestScriptReload")))
return BuildBlockedMessage("EditorUtility.RequestScriptReload() triggers a domain reload");
if (WritesCsFile(root))
return BuildBlockedMessage("writing a .cs file triggers script compilation and a domain reload");
return null;
}
// Appends actionable hints to a failure's error message when it matches one of a handful of
// recurring patterns that ScriptFix cannot fix (compile-time residue) or that only show up at
// runtime. Never removes or replaces the original error text -- the hint is additive so the
// raw diagnostic/exception message is always still there for anyone who wants it verbatim.
// `possibleSessionRebuild` is passed by the caller (it needs session context this method
// doesn't have): true when this execute silently created a new session instead of continuing
// one the caller didn't explicitly ask to discard -- i.e. a domain reload may have just wiped
// the previous session's variables out from under it.
// Takes the actual exception (not just its message) so the AmbiguousMatchException check
// below can match on the real runtime type -- AmbiguousMatchException.Message is just the
// literal string "Ambiguous match found." and never contains its own type name, so a
// substring check against the message text alone can never fire.
public static string EnhanceError(Exception exception, bool possibleSessionRebuild = false)
{
string error = exception?.Message;
if (string.IsNullOrEmpty(error))
return error;
var hints = new List<string>();
if (error.Contains("does not contain a definition for") ||
error.Contains("does not exist in the current context"))
{
hints.Add("The member or name you used may not exist. Don't guess -- query the real " +
"signature with Repl.ApiHelper.Find(package, \"name\").Get() (indexed, faster and " +
"more reliable than reflection), or fall back to typeof(T).GetMembers() if the " +
"package isn't indexed, then retry with the correct name.");
}
if (error.Contains("Cannot modify a value type return value") ||
error.Contains("Cannot modify the return value of"))
{
hints.Add("You are assigning into a nested struct property chain. Take a local " +
"variable first: `var m = ps.main; m.duration = 3f;` instead of " +
"`ps.main.duration = 3f;`");
}
if (exception is AmbiguousMatchException)
{
hints.Add("Reflection matched a same-named member on both a base and derived type. " +
"Use BindingFlags.DeclaredOnly and walk BaseType manually instead of a single " +
"GetMember/GetProperty/GetField call.");
}
if (possibleSessionRebuild &&
(error.Contains("does not exist in the current context") || error.Contains("CS0103")))
{
hints.Add("This execute created a new session instead of continuing the previous " +
"one -- if that wasn't intentional, a domain reload likely destroyed it and its " +
"variables are gone. Re-declare what you need, or call Repl.ById() to recover any " +
"object handle you stashed with Repl.Id() before the reload.");
}
return hints.Count == 0
? error
: error + "\n\n[REPL HINT] " + string.Join(" ", hints);
}
static string BuildBlockedMessage(string reason) =>
$"Blocked by ReplGuard: {reason} -- this session's variables would be lost. " +
"To proceed anyway, resend this exact script with \"unlock_domain_reload\": true -- if you " +
"want to keep a handle to a key object across the reload, call Repl.Id(obj) before resending " +
"and Repl.ById(token) afterward to recover it. " +
"If this call doesn't need the session, resend with \"enable_repl\": false for a one-shot " +
"execution instead. If you don't need the current session's state, resend with " +
"\"script_session_id\": \"new\" -- a new session's first submission is never blocked.";
// Matches `Type.Method(...)` and `qualified.Type.Method(...)` invocations by the trailing
// type + method name, ignoring how the type was qualified (namespace, `this`, etc.).
static bool IsMemberCall(InvocationExpressionSyntax invocation, string typeName, string methodName)
{
if (!(invocation.Expression is MemberAccessExpressionSyntax member))
return false;
if (member.Name.Identifier.ValueText != methodName)
return false;
switch (member.Expression)
{
case IdentifierNameSyntax id:
return id.Identifier.ValueText == typeName;
case MemberAccessExpressionSyntax inner:
return inner.Name.Identifier.ValueText == typeName;
default:
return false;
}
}
// A .cs write == a call to one of the write-method names above, or `new StreamWriter(...)`,
// gated on a ".cs" string literal appearing anywhere in the submission -- so AppendAllText
// writing a plain log line, or a StreamWriter to a .txt file, is not blocked.
static bool WritesCsFile(CompilationUnitSyntax root)
{
var mentionsCs = root.DescendantTokens().Any(t =>
t.IsKind(SyntaxKind.StringLiteralToken) &&
t.ValueText.IndexOf(".cs", StringComparison.OrdinalIgnoreCase) >= 0);
if (!mentionsCs)
return false;
var writesFile = root.DescendantNodes().OfType<InvocationExpressionSyntax>()
.Any(inv => inv.Expression is MemberAccessExpressionSyntax m &&
s_CsWriteMethodNames.Contains(m.Name.Identifier.ValueText));
var newStreamWriter = root.DescendantNodes().OfType<ObjectCreationExpressionSyntax>()
.Any(oc => oc.Type is IdentifierNameSyntax idn && idn.Identifier.ValueText == "StreamWriter" ||
oc.Type is QualifiedNameSyntax qn && qn.Right.Identifier.ValueText == "StreamWriter");
return writesFile || newStreamWriter;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1b0b2e633453a541f43c551b43466c45
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: