diff --git a/Assets/Tests/Editor/HotReload/Fixtures.meta b/Assets/Tests/Editor/HotReload/Fixtures.meta new file mode 100644 index 000000000..5d26d7d21 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3298d49965eca4acbba263b69f9df9d7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta new file mode 100644 index 000000000..d2be3a989 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 747fd421be97e470792bafc10aa93f02 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs new file mode 100644 index 000000000..88249b1eb --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs @@ -0,0 +1 @@ +// A source with only this comment, so the compiled PDB lists it in its document table but no method maps to it. diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta new file mode 100644 index 000000000..b3daab23a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e11477b08cdb4a8282b9052296b3694 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs new file mode 100644 index 000000000..e69de29bb diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta new file mode 100644 index 000000000..ef2b11b25 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b28fb164e01984cb699d60514414cc90 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs new file mode 100644 index 000000000..a76345970 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs @@ -0,0 +1,10 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A source with no method body, so the compiled PDB lists it in its document table but no sequence point refers to it. + /// + internal interface IPdbDocumentTableFixture + { + void Run(); + } +} diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta new file mode 100644 index 000000000..01cacb640 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8328444c980024a6b82be1a025db1c44 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs new file mode 100644 index 000000000..46fd9aa9c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Cross-checks, on this project's compiled assemblies, the source list built from each PDB's + /// document table against the source list Unity reports for the assembly. Whether the two agree + /// decides if the snapshot capture can stop asking Unity for its assemblies. + /// + public sealed class HotReloadCompiledSourceCrossCheckTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const int MaxListedDifferences = 10; + + /// + /// What: for every compiled assembly with sources, a dll and a PDB, the PDB list agrees with + /// Unity's on whether the assembly is an immutable package's, and for the others names the same sources. + /// + [Test] + public void FromDocuments_MatchesTheSourceFilesUnityListsForEveryCompiledAssembly() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); + IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); + StringComparer comparer = HotReloadSourcePathNormalizer.ProjectRelativePathComparer(); + List mismatches = new List(); + Stopwatch readTime = new Stopwatch(); + int checkedAssemblies = 0; + + foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current()) + { + string[] sourceFiles = assembly.sourceFiles; + string dllPath = layout.DllPath(assembly.name); + string pdbPath = layout.PdbPath(assembly.name); + if (sourceFiles == null || sourceFiles.Length == 0 || !File.Exists(dllPath) || !File.Exists(pdbPath)) + { + continue; + } + + readTime.Start(); + List documents = HotReloadPdbDocumentTable.Read(dllPath, pdbPath); + readTime.Stop(); + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + documents, + layout.MainProjectRoot, + layout.ProjectRoot, + packageRoots, + PathComparison()); + checkedAssemblies++; + + bool expectedImmutable = HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles); + if (sources.IsImmutablePackageAssembly != expectedImmutable) + { + mismatches.Add( + assembly.name + ": IsImmutablePackageAssembly is " + sources.IsImmutablePackageAssembly + + " but Unity's first source says " + expectedImmutable + "."); + continue; + } + + if (expectedImmutable) + { + continue; + } + + HashSet fromPdb = new HashSet(sources.AssetPaths, comparer); + HashSet fromUnity = new HashSet( + sourceFiles.Select(HotReloadSourcePathNormalizer.ToForwardSlashes), + comparer); + if (!fromPdb.SetEquals(fromUnity)) + { + mismatches.Add(DescribeDifference(assembly.name, fromPdb, fromUnity)); + } + } + + TestContext.WriteLine( + "Read " + checkedAssemblies + " PDB document tables in " + readTime.ElapsedMilliseconds + " ms."); + Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compiled assembly with a dll and a PDB was found."); + Assert.That(mismatches, Is.Empty, string.Join("\n", mismatches)); + } + + /// + /// What: the test assembly's list holds the fixtures without a method body, so the list is the + /// document table and not only the documents a sequence point refers to. + /// + [Test] + public void FromDocuments_IncludesTheBodylessFixtures() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); + List documents = HotReloadPdbDocumentTable.Read( + layout.DllPath(TestAssemblyName), + layout.PdbPath(TestAssemblyName)); + + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + documents, + layout.MainProjectRoot, + layout.ProjectRoot, + HotReloadSnapshotPackageRoots.ReadCurrent(), + PathComparison()); + + Assert.That(sources.AssetPaths, Is.SupersetOf(HotReloadPdbDocumentTableTests.BodylessFixtureAssetPaths)); + } + + private static string DescribeDifference(string assemblyName, HashSet fromPdb, HashSet fromUnity) + { + StringBuilder text = new StringBuilder(); + text.Append(assemblyName).Append(": the source lists differ."); + AppendOnlyIn(text, "only in the PDB", fromPdb, fromUnity); + AppendOnlyIn(text, "only in Unity's sourceFiles", fromUnity, fromPdb); + return text.ToString(); + } + + private static void AppendOnlyIn(StringBuilder text, string label, HashSet side, HashSet other) + { + List only = side.Where(path => !other.Contains(path)).OrderBy(path => path, StringComparer.Ordinal).ToList(); + text.Append("\n ").Append(label).Append(" (").Append(only.Count).Append("):"); + foreach (string path in only.Take(MaxListedDifferences)) + { + text.Append("\n ").Append(path); + } + } + + // The comparison the snapshot paths use on this platform: case-insensitive on Windows only. + private static StringComparison PathComparison() + { + return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta new file mode 100644 index 000000000..b36ce1580 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa9fbc393cd4c42fb8b852c9c82dfcba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs new file mode 100644 index 000000000..c74701520 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; + +using Mono.Cecil.Cil; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers how the document urls of a PDB map to the asset paths Unity uses for the sources of an + /// assembly, and whether the assembly belongs to an immutable package. Only synthetic urls and + /// package roots are used; nothing is read from disk. + /// + public sealed class HotReloadCompiledSourceMapTests + { + private const string ProjectRoot = "/proj"; + private const string EmbeddedResolvedPath = "/proj/Packages/src"; + private const string EmbeddedAssetPath = "Packages/com.example.embedded"; + private const string LocalResolvedPath = "/elsewhere/LocalPkg"; + private const string LocalAssetPath = "Packages/com.example.local"; + private const string RegistryResolvedPath = "/proj/Library/PackageCache/com.example.reg@1.0.0"; + private const string RegistryAssetPath = "Packages/com.example.reg"; + + private static readonly HotReloadSnapshotPackageRoot[] Packages = + { + new HotReloadSnapshotPackageRoot(EmbeddedResolvedPath, EmbeddedAssetPath, false), + new HotReloadSnapshotPackageRoot(LocalResolvedPath, LocalAssetPath, false), + new HotReloadSnapshotPackageRoot(RegistryResolvedPath, RegistryAssetPath, true), + }; + + /// + /// What: a project-relative url of an Assets source maps to its asset path, and the assembly is not immutable. + /// + [Test] + public void FromDocuments_ProjectRelativeAssetsSource_MapsToTheAssetPath() + { + HotReloadCompiledSources sources = Map("./Assets/A.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a url written with backslashes maps to the asset path written with slashes. + /// + [Test] + public void FromDocuments_BackslashUrl_MapsToASlashAssetPath() + { + HotReloadCompiledSources sources = Map(".\\Assets\\B.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/B.cs" })); + } + + /// + /// What: an absolute url inside the compiling project, as an assembly without a pathmap records it, maps to the asset path. + /// + [Test] + public void FromDocuments_AbsoluteUrlInsideTheCompilingProject_MapsToTheAssetPath() + { + HotReloadCompiledSources sources = Map("/proj/Assets/C.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/C.cs" })); + } + + /// + /// What: a source of an embedded package whose folder differs from its name maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_EmbeddedPackageWhoseFolderDiffersFromItsName_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("./Packages/src/Editor/X.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/Editor/X.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a source of a local package outside the project, recorded as an absolute url, maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_LocalPackageOutsideTheProject_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("/elsewhere/LocalPkg/Runtime/Y.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.local/Runtime/Y.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a source of a registry package makes the assembly an immutable package assembly. + /// + [Test] + public void FromDocuments_RegistryPackageSource_IsImmutable() + { + HotReloadCompiledSources sources = Map("./Library/PackageCache/com.example.reg@1.0.0/Z.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Z.cs" })); + } + + /// + /// What: a relative url without a leading "./", which is how a source generator names its output, is dropped. + /// + [Test] + public void FromDocuments_GeneratedDocumentName_IsDropped() + { + HotReloadCompiledSources sources = Map("Example.SourceGen/Gen.g.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a file inside the project but under neither Assets nor a package root is dropped. + /// + [Test] + public void FromDocuments_ProjectFileOutsideAssetsAndPackages_IsDropped() + { + HotReloadCompiledSources sources = Map("./Library/Bee/artifacts/Gen.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a document that is not a C# source is dropped. + /// + [Test] + public void FromDocuments_NonCSharpDocument_IsDropped() + { + HotReloadCompiledSources sources = Map("./Assets/Shader.hlsl"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a url listed twice yields its asset path once. + /// + [Test] + public void FromDocuments_DuplicateDocuments_YieldsTheAssetPathOnce() + { + HotReloadCompiledSources sources = Map("./Assets/A.cs", "./Assets/A.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: for a Virtual Player, whose urls are relative to the main project, an embedded package + /// source maps whether Package Manager resolves the package under the player's root or the main project's. + /// + [TestCase("/proj/Library/VP/p1/Packages/src")] + [TestCase("/proj/Packages/src")] + public void FromDocuments_VirtualPlayerEmbeddedPackage_MapsUnderThePackageName(string resolvedPath) + { + HotReloadSnapshotPackageRoot[] packages = + { + new HotReloadSnapshotPackageRoot(resolvedPath, EmbeddedAssetPath, false), + }; + + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents("./Packages/src/X.cs"), + ProjectRoot, + "/proj/Library/VP/p1", + packages, + StringComparison.Ordinal); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/X.cs" })); + } + + /// + /// What: a folder beside a package root whose name starts with the root's name is not taken for the package. + /// + [Test] + public void FromDocuments_SiblingFolderOfAPackageRoot_IsDropped() + { + HotReloadCompiledSources sources = Map("/elsewhere/LocalPkg2/A.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: the first mapped source decides immutability, so an immutable source before a mutable one makes the assembly immutable. + /// + [Test] + public void FromDocuments_FirstMappedSourceDecidesImmutability_IsImmutable() + { + HotReloadCompiledSources sources = Map( + "./Library/PackageCache/com.example.reg@1.0.0/Z.cs", + "./Assets/A.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + } + + /// + /// What: a dropped document before a mutable source does not decide immutability. + /// + [Test] + public void FromDocuments_FirstDroppedDocumentDoesNotDecide_IsMutable() + { + HotReloadCompiledSources sources = Map("Example.SourceGen/Gen.g.cs", "./Assets/A.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: under a case-insensitive comparison a url spelled in another case still maps, keeping the url's spelling. + /// + [Test] + public void FromDocuments_IgnoreCaseComparison_MapsKeepingTheUrlSpelling() + { + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents("./ASSETS/a.CS"), + ProjectRoot, + ProjectRoot, + Packages, + StringComparison.OrdinalIgnoreCase); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "ASSETS/a.CS" })); + } + + /// + /// What: no documents yield no asset paths and a mutable assembly. + /// + [Test] + public void FromDocuments_NoDocuments_IsEmptyAndMutable() + { + HotReloadCompiledSources sources = Map(); + + Assert.That(sources.AssetPaths, Is.Empty); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a Windows url with a drive letter maps to the asset path on every platform, whatever its + /// separators and the case of its drive letter. + /// + [TestCase("C:/proj/Assets/A.cs")] + [TestCase("C:\\proj\\Assets\\A.cs")] + [TestCase("c:/proj/Assets/A.cs")] + public void FromDocuments_WindowsDriveLetterUrl_MapsToTheAssetPath(string url) + { + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents(url), + "C:/proj", + "C:/proj", + Array.Empty(), + StringComparison.OrdinalIgnoreCase); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: an absolute url of an embedded package inside the project maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_AbsoluteUrlOfAnEmbeddedPackage_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("/proj/Packages/src/Editor/X.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/Editor/X.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: an absolute url of a registry package inside the project makes the assembly immutable. + /// + [Test] + public void FromDocuments_AbsoluteUrlOfARegistryPackage_IsImmutable() + { + HotReloadCompiledSources sources = Map("/proj/Library/PackageCache/com.example.reg@1.0.0/Z.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Z.cs" })); + } + + /// + /// What: a folder inside the project beside a package root whose name starts with the root's name is not taken for the package. + /// + [Test] + public void FromDocuments_SiblingFolderOfAnInProjectPackageRoot_IsDropped() + { + HotReloadCompiledSources sources = Map("./Packages/src2/A.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + private static HotReloadCompiledSources Map(params string[] urls) + { + return HotReloadCompiledSourceMap.FromDocuments( + Documents(urls), + ProjectRoot, + ProjectRoot, + Packages, + StringComparison.Ordinal); + } + + private static List Documents(params string[] urls) + { + List documents = new List(urls.Length); + foreach (string url in urls) + { + documents.Add(new HotReloadPdbDocument(url, DocumentHashAlgorithm.None, Array.Empty())); + } + + return documents; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta new file mode 100644 index 000000000..8cca48e74 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: def368e6a13934412aef3302f6b104e4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs new file mode 100644 index 000000000..a1442d3e9 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of reading every row of a PDB's document table: it lists sources without a method + /// body, which the sequence-point walk of the document index leaves out, and it refuses a PDB + /// of another build than the dll. + /// + public sealed class HotReloadPdbDocumentTableTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string OtherAssemblyName = "UnityCLILoop.PausePoints.Runtime"; + + internal static readonly string[] BodylessFixtureAssetPaths = + { + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs", + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs", + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs", + }; + + /// + /// What: the table lists the empty, comment-only and interface-only fixtures, none of which the + /// sequence-point walk returns. The urls are compared by their end because an assembly under + /// Assets has no pathmap, so its urls are absolute. + /// + [Test] + public void Read_ListsSourcesWithoutAMethodBody() + { + string dllPath = DllPath(TestAssemblyName); + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + + List table = HotReloadPdbDocumentTable.Read(dllPath, pdbPath); + List withSequencePoints = HotReloadPdbDocumentIndex.ReadDocuments(dllPath, pdbPath); + + foreach (string assetPath in BodylessFixtureAssetPaths) + { + Assert.That( + table.Any(document => EndsWithAssetPath(document, assetPath)), + Is.True, + "The document table must list " + assetPath + "."); + Assert.That( + withSequencePoints.Any(document => EndsWithAssetPath(document, assetPath)), + Is.False, + "No sequence point may refer to " + assetPath + "."); + } + } + + /// + /// What: a PDB of another assembly than the dll makes the read throw instead of returning its documents. + /// + [Test] + public void Read_WhenThePdbBelongsToAnotherBuild_Throws() + { + string directory = Path.Combine( + Path.GetTempPath(), + "uloop-pdb-document-table-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string dllPath = Path.Combine(directory, "X.dll"); + string pdbPath = Path.Combine(directory, "X.pdb"); + File.Copy(DllPath(TestAssemblyName), dllPath); + File.Copy(Path.ChangeExtension(DllPath(OtherAssemblyName), ".pdb"), pdbPath); + + Assert.Throws(() => HotReloadPdbDocumentTable.Read(dllPath, pdbPath)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static bool EndsWithAssetPath(HotReloadPdbDocument document, string assetPath) + { + return document.Url.Replace('\\', '/').EndsWith("/" + assetPath, StringComparison.Ordinal); + } + + private static string DllPath(string assemblyName) + { + return Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + assemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta new file mode 100644 index 000000000..e4632109c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea345d95bc3234bb99c3a9eb447240ce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs new file mode 100644 index 000000000..19977800f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The sources a compiled assembly was built from, named by the asset paths Unity uses for them, + /// and whether the assembly belongs to a package hot reload treats as read-only. + /// + internal sealed class HotReloadCompiledSources + { + internal HotReloadCompiledSources(IReadOnlyList assetPaths, bool isImmutablePackageAssembly) + { + Debug.Assert(assetPaths != null, "assetPaths must not be null."); + AssetPaths = assetPaths; + IsImmutablePackageAssembly = isImmutablePackageAssembly; + } + + /// Each asset path once, in the order the PDB lists its document. + internal IReadOnlyList AssetPaths { get; } + + /// True when the first source that maps to an asset path is in an immutable package. + internal bool IsImmutablePackageAssembly { get; } + } + + /// + /// Turns the document urls of a PDB into the asset paths Unity lists as the assembly's sources. + /// A url takes one of three forms: "./" and a path relative to the compiling project, which an + /// assembly compiled with a pathmap records; an absolute path, which an assembly without a + /// pathmap records for every source and any assembly records for a package outside the project; + /// or a relative name without "./", which is a source generator's output. Urls of files that are + /// not C# sources, not under Assets and not under a package root are left out. + /// + internal static class HotReloadCompiledSourceMap + { + private const string CSharpExtension = ".cs"; + private const string ProjectRelativePrefix = "./"; + private const string AssetsPrefix = "Assets/"; + + // A package root in every form a url can name its folder by. A relative form is null when + // the folder is not under that root. + private readonly struct NormalizedPackageRoot + { + public readonly string ResolvedPath; + public readonly string RelativeToCompilingRoot; + public readonly string RelativeToOwnRoot; + public readonly string AssetPath; + public readonly bool IsImmutable; + + public NormalizedPackageRoot( + string resolvedPath, + string relativeToCompilingRoot, + string relativeToOwnRoot, + string assetPath, + bool isImmutable) + { + ResolvedPath = resolvedPath; + RelativeToCompilingRoot = relativeToCompilingRoot; + RelativeToOwnRoot = relativeToOwnRoot; + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + private readonly struct MappedSource + { + public readonly string AssetPath; + public readonly bool IsImmutable; + + public MappedSource(string assetPath, bool isImmutable) + { + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + /// + /// Maps the documents of one assembly's PDB. is the + /// project whose compile wrote the PDB, which is the main project for a Virtual Player, and + /// is this Editor's own project. + /// is Ordinal or OrdinalIgnoreCase. Does no IO. + /// + internal static HotReloadCompiledSources FromDocuments( + IReadOnlyList documents, + string compilingProjectRoot, + string projectRoot, + IReadOnlyList packageRoots, + StringComparison pathComparison) + { + Debug.Assert(documents != null, "documents must not be null."); + Debug.Assert(!string.IsNullOrEmpty(compilingProjectRoot), "compilingProjectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + Debug.Assert( + pathComparison == StringComparison.Ordinal || pathComparison == StringComparison.OrdinalIgnoreCase, + "pathComparison must be Ordinal or OrdinalIgnoreCase."); + + string compilingRoot = NormalizeRoot(compilingProjectRoot); + string ownRoot = NormalizeRoot(projectRoot); + List packages = NormalizePackages(packageRoots, compilingRoot, ownRoot, pathComparison); + List assetPaths = new List(); + HashSet seen = new HashSet( + pathComparison == StringComparison.OrdinalIgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + bool? firstIsImmutable = null; + foreach (HotReloadPdbDocument document in documents) + { + if (!TryMap(document.Url, compilingRoot, packages, pathComparison, out MappedSource mapped)) + { + continue; + } + + if (!seen.Add(mapped.AssetPath)) + { + continue; + } + + // Why the first mapped source: it keeps the rule of ShouldSkipImmutablePackageSources, + // which classifies an assembly by its first source because an asmdef keeps one + // assembly inside one package. + if (firstIsImmutable == null) + { + firstIsImmutable = mapped.IsImmutable; + } + + assetPaths.Add(mapped.AssetPath); + } + + return new HotReloadCompiledSources(assetPaths, firstIsImmutable ?? false); + } + + private static bool TryMap( + string documentUrl, + string compilingRoot, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + mapped = default(MappedSource); + string url = documentUrl.Replace('\\', '/'); + if (!url.EndsWith(CSharpExtension, comparison)) + { + return false; + } + + if (url.StartsWith(ProjectRelativePrefix, StringComparison.Ordinal)) + { + return TryMapProjectRelative(url.Substring(ProjectRelativePrefix.Length), packages, comparison, out mapped); + } + + if (IsRooted(url)) + { + return TryMapAbsolute(url, compilingRoot, packages, comparison, out mapped); + } + + // A relative name without "./" is a generated source, which has no asset path. + return false; + } + + private static bool TryMapProjectRelative( + string relativePath, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + if (relativePath.StartsWith(AssetsPrefix, comparison)) + { + mapped = new MappedSource(relativePath, false); + return true; + } + + // Why both relative forms: a Virtual Player's urls are relative to the main project, + // while the Package Manager may resolve an embedded package under the player's root. + int bestLength = -1; + NormalizedPackageRoot best = default(NormalizedPackageRoot); + foreach (NormalizedPackageRoot package in packages) + { + int length = Math.Max( + PrefixLength(relativePath, package.RelativeToCompilingRoot, comparison), + PrefixLength(relativePath, package.RelativeToOwnRoot, comparison)); + if (length > bestLength) + { + bestLength = length; + best = package; + } + } + + return TryMapUnderPackage(relativePath, best, bestLength, out mapped); + } + + private static bool TryMapAbsolute( + string absolutePath, + string compilingRoot, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + int rootLength = PrefixLength(absolutePath, compilingRoot, comparison); + if (rootLength >= 0) + { + return TryMapProjectRelative(absolutePath.Substring(rootLength + 1), packages, comparison, out mapped); + } + + int bestLength = -1; + NormalizedPackageRoot best = default(NormalizedPackageRoot); + foreach (NormalizedPackageRoot package in packages) + { + int length = PrefixLength(absolutePath, package.ResolvedPath, comparison); + if (length > bestLength) + { + bestLength = length; + best = package; + } + } + + return TryMapUnderPackage(absolutePath, best, bestLength, out mapped); + } + + // path minus its first prefixLength characters and the "/" after them, under the package's asset path. + private static bool TryMapUnderPackage( + string path, + NormalizedPackageRoot package, + int prefixLength, + out MappedSource mapped) + { + if (prefixLength < 0) + { + mapped = default(MappedSource); + return false; + } + + mapped = new MappedSource(package.AssetPath + "/" + path.Substring(prefixLength + 1), package.IsImmutable); + return true; + } + + // The length of folder when path is inside it, so that the character after it is "/"; -1 + // otherwise. Why the "/": "/a/LocalPkg2/x.cs" must not count as inside "/a/LocalPkg". + private static int PrefixLength(string path, string folder, StringComparison comparison) + { + if (folder == null || path.Length <= folder.Length + 1) + { + return -1; + } + + if (path[folder.Length] != '/' || !path.StartsWith(folder, comparison)) + { + return -1; + } + + return folder.Length; + } + + // Why not Path.IsPathRooted: on macOS and Linux it does not take "C:/proj/..." for rooted, + // so a PDB written on Windows would lose its sources there. + private static bool IsRooted(string url) + { + if (url.Length > 0 && url[0] == '/') + { + return true; + } + + return url.Length >= 3 && IsAsciiLetter(url[0]) && url[1] == ':' && url[2] == '/'; + } + + private static bool IsAsciiLetter(char character) + { + return (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z'); + } + + private static List NormalizePackages( + IReadOnlyList packageRoots, + string compilingRoot, + string ownRoot, + StringComparison comparison) + { + List packages = new List(packageRoots.Count); + foreach (HotReloadSnapshotPackageRoot root in packageRoots) + { + string resolvedPath = NormalizeRoot(root.ResolvedPath); + packages.Add(new NormalizedPackageRoot( + resolvedPath, + RelativeTo(resolvedPath, compilingRoot, comparison), + RelativeTo(resolvedPath, ownRoot, comparison), + NormalizeRoot(root.AssetPath), + root.IsImmutable)); + } + + return packages; + } + + // The rest of path after root and "/", or null when path is not inside root. + private static string RelativeTo(string path, string root, StringComparison comparison) + { + int length = PrefixLength(path, root, comparison); + return length < 0 ? null : path.Substring(length + 1); + } + + private static string NormalizeRoot(string path) + { + return path.Replace('\\', '/').TrimEnd('/'); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta new file mode 100644 index 000000000..7401d0860 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7cd0f305ace4344be91cb7ea5588de22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index f17ab1280..e1499f0d8 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -509,7 +509,7 @@ private static List CollectDocumentsWithVisibleSequencePoints(Me } // Reads only the PE headers and the debug directory; the type and method tables stay unread. - private static Guid ReadCodeViewGuid(string dllPath) + internal static Guid ReadCodeViewGuid(string dllPath) { using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using PEReader peReader = new PEReader(dllStream); @@ -528,7 +528,7 @@ private static Guid ReadCodeViewGuid(string dllPath) } // The same mapping Cecil's portable PDB reader applies to a document's hash algorithm GUID. - private static DocumentHashAlgorithm ToHashAlgorithm(Guid algorithm) + internal static DocumentHashAlgorithm ToHashAlgorithm(Guid algorithm) { if (algorithm == Sha1HashAlgorithmGuid) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs new file mode 100644 index 000000000..c0eed27fa --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reads every row of a portable PDB's document table: each source the compiler was given, + /// including files without a method body, which HotReloadPdbDocumentIndex leaves out because + /// no sequence point refers to them. + /// + internal static class HotReloadPdbDocumentTable + { + /// + /// Returns the documents of the PDB in table order, after checking that the PDB belongs to + /// the build of the dll. Throws InvalidOperationException for a PDB of another build. + /// + internal static List Read(string dllPath, string pdbPath) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + + Guid codeViewGuid = HotReloadPdbDocumentIndex.ReadCodeViewGuid(dllPath); + using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); + MetadataReader reader = provider.GetMetadataReader(); + + // Why checked here: a PDB of another build would list sources the dll was not compiled from. + BlobContentId pdbId = new BlobContentId(reader.DebugMetadataHeader.Id); + if (pdbId.Guid != codeViewGuid) + { + throw new InvalidOperationException( + "The PDB beside " + Path.GetFileName(dllPath) + " belongs to another build of it."); + } + + List documents = new List(reader.Documents.Count); + foreach (DocumentHandle handle in reader.Documents) + { + Document document = reader.GetDocument(handle); + documents.Add(new HotReloadPdbDocument( + reader.GetString(document.Name), + HotReloadPdbDocumentIndex.ToHashAlgorithm(reader.GetGuid(document.HashAlgorithm)), + reader.GetBlobBytes(document.Hash))); + } + + return documents; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta new file mode 100644 index 000000000..233654e52 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b406ae595de0a4bd99e539062bf7eff8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs new file mode 100644 index 000000000..5bb8eee85 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +using UnityEditor.PackageManager; + +using PackageManagerPackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One registered package as the snapshot capture sees it: the folder its files live in, the + /// virtual path Unity names them by, and whether hot reload treats its sources as read-only. + /// + internal readonly struct HotReloadSnapshotPackageRoot + { + public readonly string ResolvedPath; + public readonly string AssetPath; + public readonly bool IsImmutable; + + public HotReloadSnapshotPackageRoot(string resolvedPath, string assetPath, bool isImmutable) + { + ResolvedPath = resolvedPath; + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + /// + /// Asks the Package Manager for the registered packages and whether each is immutable for hot + /// reload. Why not an extension of ScriptPackageRoots.ReadCurrent: that type is shared with + /// pause points, and adding the immutability to it would change their code too. + /// + internal static class HotReloadSnapshotPackageRoots + { + /// + /// Lists every registered package that has both a folder on disk and a virtual path. Reads + /// Package Manager state, so it must be called on the Unity main thread. + /// + internal static IReadOnlyList ReadCurrent() + { + PackageManagerPackageInfo[] packages = PackageManagerPackageInfo.GetAllRegisteredPackages(); + List roots = new List(packages.Length); + foreach (PackageManagerPackageInfo package in packages) + { + if (string.IsNullOrEmpty(package.resolvedPath) || string.IsNullOrEmpty(package.assetPath)) + { + continue; + } + + // The same rule as HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources: + // only project-owned packages stay editable through hot reload. + bool isImmutable = !(package.source == PackageSource.Embedded || package.source == PackageSource.Local); + roots.Add(new HotReloadSnapshotPackageRoot(package.resolvedPath, package.assetPath, isImmutable)); + } + + return roots; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta new file mode 100644 index 000000000..cbd3384be --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b006190806cec4bfe96c4947fb9a8831 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: