diff --git a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs new file mode 100644 index 000000000..b5ce36bd8 --- /dev/null +++ b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies edited-line remap onto a named method's compiled span, including the UseCase route. + /// + [TestFixture] + public sealed class PausePointEditedLineRemapTests + { + private const string RemapFixtureFile = + "Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs"; + private const string RoundForwardFixtureFile = + "Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs"; + private const int UniqueTargetStatementLine = 10; + private const int UniqueOtherStatementLine = 16; + private const int DuplicateOtherStatementLine = 30; + private const int ZeroMatchOtherStatementLine = 36; + private const int CommentOtherCommentLine = 16; + + private const string ExpectedUniqueTargetResolvedMethod = + "System.Int32 io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures.EditedLineRemapFixture::UniqueTarget(System.Int32)"; + + private const string ExpectedRemapWarning = + "--line 16 did not resolve in method 'UniqueTarget' against the last compiled source; the edited line's text was found at line 10 inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers."; + + private const string ExpectedSuccessWarning = + "--line 16 did not resolve in method 'UniqueTarget' against the last compiled source; the edited line's text was found at line 10 inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers. The target method body is very small and may be inlined by Mono's JIT into its callers; if HitCount stays 0 while the line demonstrably runs, move the pause point into the calling method."; + + private const string ExpectedZeroMatchFailureMessage = + "No method named 'UniqueTarget' with a sequence point on or after line 36 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.ZeroMatchOther' spans lines 35-38."; + + private const string ExpectedDuplicateMatchFailureMessage = + "No method named 'DuplicateTarget' with a sequence point on or after line 30 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.DuplicateOther' spans lines 29-32."; + + private const string ExpectedRoundForwardFailureMessage = + "No method named 'CommentTarget' with a sequence point on or after line 16 was found. Nearby methods in the last compiled source: 'EditedLineRemapRoundForwardFixture.CommentOther' spans lines 15-18."; + + private const string ExpectedNoSnapshotFailureMessage = + "No method named 'UniqueTarget' with a sequence point on or after line 16 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.UniqueOther' spans lines 15-18."; + + private Func _previousSnapshotLoader; + + [SetUp] + public void SetUp() + { + UloopPausePointRegistry.ConfigureForTests(new FakePausePointPauseController(), () => DateTime.UtcNow); + _previousSnapshotLoader = HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile; + HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = null; + } + + [TearDown] + public void TearDown() + { + HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = _previousSnapshotLoader; + SourcePausePointPatcher.UnpatchAll(); + UloopPausePointRegistry.ResetForTests(); + } + + /// + /// What: a single trimmed match inside the named method span remaps to that compiled line. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenOneMatchInSpan_ReturnsThatLine() + { + IReadOnlyList compiledSourceLines = new[] + { + "void Target()", + " int uniqueRemapProbe = value + 1;", + " return uniqueRemapProbe;", + "}", + " int uniqueRemapProbe = value + 1;" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 4) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + "Target", + " int uniqueRemapProbe = value + 1;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(2)); + } + + /// + /// What: a match that exists only outside the named method span does not remap. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenMatchIsOutsideSpan_ReturnsZero() + { + IReadOnlyList compiledSourceLines = new[] + { + "void Target()", + " return value;", + "}", + " int uniqueRemapProbe = value + 1;" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 3) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + "Target", + "int uniqueRemapProbe = value + 1;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(0)); + } + + /// + /// What: two matches inside the named method span do not remap. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenMultipleMatchesInSpan_ReturnsZero() + { + IReadOnlyList compiledSourceLines = new[] + { + "void Target()", + " _ = 12345;", + " int skip = 0;", + " _ = 12345;", + "}" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 5) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + "Target", + "_ = 12345;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(0)); + } + + /// + /// What: overlapping spans that share one matching line count as two hits and do not remap. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenOverlappingSpansShareTheMatch_ReturnsZero() + { + IReadOnlyList compiledSourceLines = new[] + { + "void Foo()", + " int sharedRemapProbe = 1;", + " return sharedRemapProbe;", + "}" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 4), + new SourcePausePointCompiledMethodSpan(2, 4) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + "Foo", + "int sharedRemapProbe = 1;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(0)); + } + + /// + /// What: remap is skipped when --method is omitted even if the span has one match. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenMethodFilterIsEmpty_ReturnsZero() + { + IReadOnlyList compiledSourceLines = new[] + { + " int uniqueRemapProbe = value + 1;" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 1) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + string.Empty, + "int uniqueRemapProbe = value + 1;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(0)); + } + + /// + /// What: the remap warning is the planned fixed literal. + /// + [Test] + public void BuildEditedLineRemapWarning_UsesFixedLiteral() + { + string warning = PausePointEnableWarnings.BuildEditedLineRemapWarning(16, "UniqueTarget", 10); + + Assert.That(warning, Is.EqualTo(ExpectedRemapWarning)); + } + + /// + /// What: UseCase resolve failure remaps onto the unique compiled span line and patches there. + /// + [Test] + public void Enable_WhenEditedLineMatchesOnceInNamedMethodSpan_RemapsAndPatches() + { + InstallSnapshotFromFile(RemapFixtureFile); + + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RemapFixtureFile, + Line = UniqueOtherStatementLine, + Method = "UniqueTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.True, response.ErrorCode + " / " + response.Message); + Assert.That(response.ResolvedLine, Is.EqualTo(UniqueTargetStatementLine)); + Assert.That(response.ResolvedLineText, Is.EqualTo("int uniqueRemapProbe = value + 1;")); + Assert.That(response.ResolvedMethod, Is.EqualTo(ExpectedUniqueTargetResolvedMethod)); + Assert.That( + response.Id, + Is.EqualTo(RemapFixtureFile + ":" + UniqueOtherStatementLine)); + Assert.That( + response.SnapshotTiming, + Is.EqualTo(SourcePausePointConstants.PreLineSnapshotTimingNote)); + Assert.That(response.Warning, Is.EqualTo(ExpectedSuccessWarning)); + } + + /// + /// What: without a verified compiled snapshot the UseCase keeps the existing resolve failure. + /// + [Test] + public void Enable_WhenVerifiedSnapshotIsMissing_KeepsResolveFailure() + { + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RemapFixtureFile, + Line = UniqueOtherStatementLine, + Method = "UniqueTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); + Assert.That(response.Message, Is.EqualTo(ExpectedNoSnapshotFailureMessage)); + Assert.That(response.ResolvedLine, Is.EqualTo(0)); + Assert.That(response.ResolvedMethod, Is.EqualTo(string.Empty)); + } + + /// + /// What: zero matches inside the named method span leave the existing resolve failure unchanged. + /// + [Test] + public void Enable_WhenEditedLineDoesNotMatchNamedMethodSpan_KeepsResolveFailure() + { + InstallSnapshotFromFile(RemapFixtureFile); + + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RemapFixtureFile, + Line = ZeroMatchOtherStatementLine, + Method = "UniqueTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); + Assert.That(response.Message, Is.EqualTo(ExpectedZeroMatchFailureMessage)); + Assert.That(response.ResolvedLine, Is.EqualTo(0)); + Assert.That(response.ResolvedMethod, Is.EqualTo(string.Empty)); + } + + /// + /// What: multiple matches inside the named method span leave the existing resolve failure unchanged. + /// + [Test] + public void Enable_WhenEditedLineMatchesTwiceInNamedMethodSpan_KeepsResolveFailure() + { + InstallSnapshotFromFile(RemapFixtureFile); + + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RemapFixtureFile, + Line = DuplicateOtherStatementLine, + Method = "DuplicateTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); + Assert.That(response.Message, Is.EqualTo(ExpectedDuplicateMatchFailureMessage)); + } + + /// + /// What: a unique span match that only rounds forward on re-resolve keeps the original failure. + /// + [Test] + public void Enable_WhenRemappedLineRoundsForward_KeepsResolveFailure() + { + InstallSnapshotFromFile(RoundForwardFixtureFile); + + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RoundForwardFixtureFile, + Line = CommentOtherCommentLine, + Method = "CommentTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); + Assert.That(response.Message, Is.EqualTo(ExpectedRoundForwardFailureMessage)); + } + + private static void InstallSnapshotFromFile(string projectRelativeFile) + { + string absoluteFilePath = Path.Combine( + UnityCliLoopPathResolver.GetProjectRoot(), + projectRelativeFile); + string snapshotSource = File.ReadAllText(absoluteFilePath); + HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = _ => snapshotSource; + } + + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public int PauseCount { get; private set; } + public bool IsPlaying => true; + public bool IsPaused => PauseCount > 0; + + public void Pause() + { + PauseCount++; + } + + public void Resume() + { + PauseCount = 0; + } + } + } +} diff --git a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta new file mode 100644 index 000000000..074193e1e --- /dev/null +++ b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bbbb27937cd284c2cb47b56b5e6f8bc1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs new file mode 100644 index 000000000..4da66bf7d --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs @@ -0,0 +1,40 @@ +// FROZEN FIXTURE: content and line numbers are asserted by PausePointEditedLineRemapTests +// and SourcePausePointResolverTests. +// Do not reformat or edit this file; add a new fixture file instead. +namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures +{ + internal sealed class EditedLineRemapFixture + { + public int UniqueTarget(int value) + { + int uniqueRemapProbe = value + 1; + return uniqueRemapProbe; + } + + public int UniqueOther(int value) + { + int uniqueRemapProbe = value + 1; + return uniqueRemapProbe; + } + + public int DuplicateTarget(int value) + { + _ = 12345; + int skip = 0; + _ = 12345; + return skip; + } + + public int DuplicateOther(int value) + { + _ = 12345; + return value; + } + + public int ZeroMatchOther(int value) + { + int zeroMatchOnlyHere = value; + return zeroMatchOnlyHere; + } + } +} diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta new file mode 100644 index 000000000..6cbfa3a80 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6da72c4c915344fefa04892102fa0d81 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs new file mode 100644 index 000000000..37cc7ac21 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs @@ -0,0 +1,20 @@ +// FROZEN FIXTURE: content and line numbers are asserted by PausePointEditedLineRemapTests. +// Do not reformat or edit this file; add a new fixture file instead. +namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures +{ + internal sealed class EditedLineRemapRoundForwardFixture + { + public int CommentTarget(int value) + { + // uniqueRemapCommentProbe + int x = value; + return x; + } + + public int CommentOther(int value) + { + // uniqueRemapCommentProbe + return value; + } + } +} diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta new file mode 100644 index 000000000..b2c2c4ebe --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 19ff076ff2d1d4b81aa60ff977c675d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs b/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs index 39b833f42..8d45e6176 100644 --- a/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs +++ b/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs @@ -316,6 +316,38 @@ public void Resolve_WhenMethodFilterHasNoSequencePointOnOrAfterLine_FailsWithNam Is.EqualTo(otherMethod.Resolution.CompiledMethodEndLine)); } + /// + /// What: FindCompiledMethodSpans returns the named method's compiled span, not a neighbor. + /// + [Test] + public void FindCompiledMethodSpans_WhenMethodFilterMatches_ReturnsThatMethodSpan() + { + string file = FixturesDirectory + "CompiledMethodSpanFixture.cs"; + SourcePausePointResolveResult expected = SourcePausePointResolver.Resolve(file, 9, "Target"); + Assert.That(expected.Success, Is.True, expected.ErrorMessage); + + IReadOnlyList spans = + SourcePausePointResolver.FindCompiledMethodSpans(file, "Target"); + + Assert.That(spans.Count, Is.EqualTo(1)); + Assert.That(spans[0].StartLine, Is.EqualTo(expected.Resolution.CompiledMethodStartLine)); + Assert.That(spans[0].EndLine, Is.EqualTo(expected.Resolution.CompiledMethodEndLine)); + } + + /// + /// What: FindCompiledMethodSpans with no --method returns no spans. + /// + [Test] + public void FindCompiledMethodSpans_WhenMethodFilterIsEmpty_ReturnsNoSpans() + { + string file = FixturesDirectory + "CompiledMethodSpanFixture.cs"; + + IReadOnlyList spans = + SourcePausePointResolver.FindCompiledMethodSpans(file, string.Empty); + + Assert.That(spans, Is.Empty); + } + /// /// What: an empty method filter accepts every compiled method name. /// diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs new file mode 100644 index 000000000..fd85254bf --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs @@ -0,0 +1,18 @@ +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Loads last-compiled snapshot source text for pause-point line comparison and remap. + /// + internal static class PausePointCompiledSourceReader + { + internal static string LoadSnapshotOrEmpty(string requestedFile) + { + string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile); + string snapshotSource = + HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile?.Invoke(normalizedFile); + return snapshotSource ?? string.Empty; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta new file mode 100644 index 000000000..52717ab2b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e024249aebfd84757b173bd0c5c4e96a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs new file mode 100644 index 000000000..ed732eba6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Remaps a failed --method/--line resolve onto the unique matching line inside that + /// method's compiled span, or leaves the original failure unchanged. + /// + internal static class PausePointEditedLineRemap + { + internal static (SourcePausePointResolveResult resolveResult, string remapWarning) + ResolveWithEditedLineRemap(string file, int line, string method) + { + SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(file, line, method); + if (resolveResult.Success) + { + return (resolveResult, string.Empty); + } + + return TryRemapAfterResolveFailure(resolveResult, file, line, method); + } + + internal static (SourcePausePointResolveResult resolveResult, string remapWarning) + TryRemapAfterResolveFailure( + SourcePausePointResolveResult failedResult, + string file, + int line, + string method) + { + Debug.Assert(failedResult != null, "failedResult must not be null."); + Debug.Assert(!failedResult.Success, "TryRemapAfterResolveFailure requires a failed resolve."); + + if (string.IsNullOrEmpty(method) || line <= 0 || string.IsNullOrEmpty(file)) + { + return (failedResult, string.Empty); + } + + // Why snapshot-only: the on-disk file can already include uncompiled edits, so + // scanning it against the last PDB span can unique-match a later statement onto + // an old sequence-point line and still pass the exact-line pin. + string compiledSnapshotSource = PausePointCompiledSourceReader.LoadSnapshotOrEmpty(file); + if (string.IsNullOrEmpty(compiledSnapshotSource)) + { + return (failedResult, string.Empty); + } + + IReadOnlyList spans = + SourcePausePointResolver.FindCompiledMethodSpans(file, method); + if (spans.Count == 0) + { + return (failedResult, string.Empty); + } + + (bool readOk, string editedLineText) = + PausePointCompiledLineComparisonWarnings.ReadEditedLineText(file, line); + if (!readOk) + { + return (failedResult, string.Empty); + } + + string[] compiledSourceLines = + SourcePausePointSourceLineReader.SplitSourceLines(compiledSnapshotSource); + int remappedLine = FindUniqueMatchingCompiledLineOrZero( + method, + editedLineText, + compiledSourceLines, + spans); + if (remappedLine <= 0) + { + return (failedResult, string.Empty); + } + + SourcePausePointResolveResult retry = SourcePausePointResolver.Resolve(file, remappedLine, method); + // Why exact line: Resolve rounds a comment or continuation forward, and the + // remap warning claims the marker was placed at remappedLine. + if (!retry.Success || retry.Resolution.ResolvedLine != remappedLine) + { + return (failedResult, string.Empty); + } + + return ( + retry, + PausePointEnableWarnings.BuildEditedLineRemapWarning(line, method, remappedLine)); + } + + // Why every span line: file-wide candidate search stops at three hits and cannot prove + // uniqueness; a match outside the named method's compiled span must not count. + internal static int FindUniqueMatchingCompiledLineOrZero( + string methodFilter, + string editedLineText, + IReadOnlyList compiledSourceLines, + IReadOnlyList spans) + { + if (string.IsNullOrEmpty(methodFilter) + || string.IsNullOrEmpty(editedLineText) + || compiledSourceLines == null + || spans == null) + { + return 0; + } + + string editedTrimmed = editedLineText.Trim(); + if (editedTrimmed.Length == 0) + { + return 0; + } + + int matchingLine = 0; + int matchCount = 0; + for (int spanIndex = 0; spanIndex < spans.Count; spanIndex++) + { + SourcePausePointCompiledMethodSpan span = spans[spanIndex]; + if (span == null) + { + continue; + } + + for (int compiledLine = span.StartLine; compiledLine <= span.EndLine; compiledLine++) + { + if (compiledLine > compiledSourceLines.Count) + { + continue; + } + + string compiledText = compiledSourceLines[compiledLine - 1]; + if (compiledText == null) + { + continue; + } + + if (!string.Equals(compiledText.Trim(), editedTrimmed, StringComparison.Ordinal)) + { + continue; + } + + matchCount++; + matchingLine = compiledLine; + } + } + + if (matchCount != 1) + { + return 0; + } + + return matchingLine; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta new file mode 100644 index 000000000..eee20d31a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bbfa30397a5384a678d63a25ca7d2f75 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs index 4afd9ed0a..a28e4485a 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs @@ -476,6 +476,21 @@ internal static string BuildCompiledLineMapResolveFailureWarningOrEmpty( SourcePausePointPathNormalizer.ToForwardSlashes(file)); } + internal static string BuildEditedLineRemapWarning( + int originalLine, + string methodName, + int remappedLine) + { + Debug.Assert(originalLine > 0, "originalLine must be a positive 1-based line number."); + Debug.Assert(!string.IsNullOrEmpty(methodName), "methodName must not be empty."); + Debug.Assert(remappedLine > 0, "remappedLine must be a positive 1-based line number."); + return string.Format( + SourcePausePointConstants.EditedLineRemapWarningFormat, + originalLine, + methodName, + remappedLine); + } + internal static string CreateEnableWarning() { if (EditorApplication.isPlaying) diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs index 10f39b3dc..916b9ec84 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs @@ -268,8 +268,9 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema // map; only the warning text differs. } - SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve( - parameters.File, parameters.Line, parameters.Method); + (SourcePausePointResolveResult resolveResult, string editedLineRemapWarning) = + PausePointEditedLineRemap.ResolveWithEditedLineRemap( + parameters.File, parameters.Line, parameters.Method); if (!resolveResult.Success) { bool hasActiveHotReloadPatches = shimLookup != null; @@ -286,7 +287,7 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema // path, so those reads would change the historical no-patch failure for no gain. if (hasActiveHotReloadPatches) { - string compiledSnapshotSource = LoadCompiledSnapshotSourceOrEmpty(parameters.File); + string compiledSnapshotSource = PausePointCompiledSourceReader.LoadSnapshotOrEmpty(parameters.File); compiledSourceLinesOrNull = string.IsNullOrEmpty(compiledSnapshotSource) ? null : SourcePausePointSourceLineReader.SplitSourceLines(compiledSnapshotSource); @@ -349,7 +350,8 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema hasActiveHotReloadPatches: shimLookup != null, compiledMethodStartLine: resolveResult.Resolution.CompiledMethodStartLine, compiledMethodEndLine: resolveResult.Resolution.CompiledMethodEndLine, - patchedMethodPdbUnavailableWarning: patchedMethodPdbUnavailableWarning); + patchedMethodPdbUnavailableWarning: patchedMethodPdbUnavailableWarning, + editedLineRemapWarning: editedLineRemapWarning); } private static PausePointResponse FinishEnableBySourceLocation( @@ -365,7 +367,8 @@ private static PausePointResponse FinishEnableBySourceLocation( int editedMethodEndLine = 0, int compiledMethodStartLine = 0, int compiledMethodEndLine = 0, - string patchedMethodPdbUnavailableWarning = "") + string patchedMethodPdbUnavailableWarning = "", + string editedLineRemapWarning = "") { string rearmWarning = PausePointEnableWarnings.BuildRearmDiscardWarningOrEmpty( UloopPausePointRegistry.GetStatus(id)); @@ -384,7 +387,7 @@ private static PausePointResponse FinishEnableBySourceLocation( bool compareCompiledLineDrift = hasActiveHotReloadPatches && !retargetedToHotReloadPatch; string compiledSnapshotSource = compareCompiledLineDrift - ? LoadCompiledSnapshotSourceOrEmpty(parameters.File) + ? PausePointCompiledSourceReader.LoadSnapshotOrEmpty(parameters.File) : string.Empty; // Why snapshot over disk: the editor file may already include unpatched-line drift, so // reading disk at the compiled ResolvedLine shows the wrong statement (FB9 empty/mismatch). @@ -402,7 +405,9 @@ private static PausePointResponse FinishEnableBySourceLocation( response.ResolvedLineText = resolvedLineText; response.ResolvedMethod = resolvedMethod; response.SnapshotTiming = SourcePausePointConstants.PreLineSnapshotTimingNote; - string enableWarning = PausePointEnableWarnings.CreateEnableWarning(); + string enableWarning = PausePointEnableWarnings.MergeWarnings( + PausePointEnableWarnings.CreateEnableWarning(), + editedLineRemapWarning); enableWarning = PausePointEnableWarnings.MergeWarnings( enableWarning, PausePointEnableWarnings.BuildRetargetedToHotReloadPatchWarningOrEmpty( @@ -531,14 +536,6 @@ private static void LogPhysicsDispatchDiagnostics(string operation, string id, T }); } - private static string LoadCompiledSnapshotSourceOrEmpty(string requestedFile) - { - string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile); - string snapshotSource = - HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile?.Invoke(normalizedFile); - return snapshotSource ?? string.Empty; - } - // The derived id must use the originally requested file/line (not the resolved/rounded // line) so repeated calls at the same requested location stay idempotent. private static string BuildSourcePausePointId(string file, int line) diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs new file mode 100644 index 000000000..dc6d76718 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Inclusive compiled-source line range of one method that matches a --method filter. + /// + internal sealed class SourcePausePointCompiledMethodSpan + { + public int StartLine { get; } + public int EndLine { get; } + + public SourcePausePointCompiledMethodSpan(int startLine, int endLine) + { + Debug.Assert(startLine > 0, "startLine must be a positive 1-based line number."); + Debug.Assert(endLine >= startLine, "endLine must be on or after startLine."); + StartLine = startLine; + EndLine = endLine; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta new file mode 100644 index 000000000..2a10e9879 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d13319e661d0d44dcbbcb59e8189e342 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index 45c7321c0..c9202e950 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -390,5 +390,9 @@ internal static class SourcePausePointConstants // Format: resolved line, resolved method display name. public const string ClosingBraceResolvedLineWarningFormat = "--line resolved to the method's closing brace at line {0}. Every return path through {1} reaches this line, including early returns, so captured variables can reflect a different path than the one you meant. To observe one specific path, target a statement line inside that path."; + + // Format: original --line, --method name, remapped compiled line. + public const string EditedLineRemapWarningFormat = + "--line {0} did not resolve in method '{1}' against the last compiled source; the edited line's text was found at line {2} inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers."; } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs index c7ced427a..fd1994d01 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs @@ -294,6 +294,50 @@ private static (int startLine, int endLine) CollectCompiledMethodSpan( return (startLine, endLine); } + // Why a dedicated API: FindNearbyCompiledMethods does not take methodFilter and cannot + // promise the named method's compiled span. + internal static IReadOnlyList FindCompiledMethodSpans( + string projectRelativeFilePath, + string methodFilter) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativeFilePath), "projectRelativeFilePath must not be null or empty."); + if (string.IsNullOrEmpty(methodFilter)) + { + return Array.Empty(); + } + + return WithCompiledModuleOrDefault( + projectRelativeFilePath, + (module, normalizedInputPath) => + CollectCompiledMethodSpans(module, normalizedInputPath, methodFilter), + Array.Empty()); + } + + private static IReadOnlyList CollectCompiledMethodSpans( + ModuleDefinition module, + string normalizedInputPath, + string methodFilter) + { + List spans = new List(); + foreach (MethodDefinition method in EnumerateMethodsInModule(module)) + { + if (!method.HasBody || !CompiledMethodMatchesFilter(methodFilter, method)) + { + continue; + } + + (int startLine, int endLine) = CollectCompiledMethodSpan(method, normalizedInputPath); + if (startLine <= 0 || endLine <= 0) + { + continue; + } + + spans.Add(new SourcePausePointCompiledMethodSpan(startLine, endLine)); + } + + return spans; + } + // Why a file:line entry: the resolver test assembly cannot take a Cecil // ModuleDefinition dependency, but TakeAtMostTwo only runs on this walk. internal static IReadOnlyList FindNearbyCompiledMethodsInFile( @@ -303,11 +347,25 @@ internal static IReadOnlyList FindNearbyCo Debug.Assert(!string.IsNullOrEmpty(projectRelativeFilePath), "projectRelativeFilePath must not be null or empty."); Debug.Assert(line > 0, "line must be a positive 1-based line number."); + return WithCompiledModuleOrDefault( + projectRelativeFilePath, + (module, normalizedInputPath) => + FindNearbyCompiledMethods(module, normalizedInputPath, line), + Array.Empty()); + } + + private static TResult WithCompiledModuleOrDefault( + string projectRelativeFilePath, + Func read, + TResult fallback) + { + Debug.Assert(read != null, "read must not be null."); + string normalizedInputPath = SourcePausePointPathNormalizer.ToForwardSlashes(projectRelativeFilePath); string rawAssemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath(normalizedInputPath); if (string.IsNullOrEmpty(rawAssemblyName)) { - return Array.Empty(); + return fallback; } string assemblyName = Path.GetFileNameWithoutExtension(rawAssemblyName); @@ -322,7 +380,7 @@ internal static IReadOnlyList FindNearbyCo assemblyName + SourcePausePointConstants.DebugSymbolsExtension); if (!File.Exists(dllPath) || !File.Exists(pdbPath)) { - return Array.Empty(); + return fallback; } using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); @@ -336,7 +394,7 @@ internal static IReadOnlyList FindNearbyCo }; using AssemblyDefinition assemblyDefinition = AssemblyDefinition.ReadAssembly(dllStream, readerParameters); - return FindNearbyCompiledMethods(assemblyDefinition.MainModule, normalizedInputPath, line); + return read(assemblyDefinition.MainModule, normalizedInputPath); } // Why a separate walk from FindClosestSequencePointOnOrAfterLine: that search only