```
chore(bridge): 删除桥接包的提交记录和Git属性配置 删除了 .codely-bridge-commit 文件中存储的提交哈希值 移除了 .gitattributes 文件中的行结束符配置规则 ```
This commit is contained in:
1 parent
d26d323912
commit
13d0c80335
392 files changed
+1814
-1895
No files matched your search
+747
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1a07a2a500b6928daa04d87f4346c51
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+214
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 40dd46d8aac23467677f277b97ca4869
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+176
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b0b2e633453a541f43c551b43466c45
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in new issue
Block a user