diff --git a/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs b/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs index ac4398faf2..1488fbd1a0 100644 --- a/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs +++ b/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs @@ -223,6 +223,7 @@ public void Enable_WhenCompiledLineDriftsFromEditedFile_AddsDriftWarningAndNextA string[] snapshotLines = diskSource.Replace("\r\n", "\n").Split('\n'); snapshotLines[requestedLine - 1] = " return 0;"; + snapshotLines[markerLine - 1] = " return 424242;"; string snapshotSource = string.Join("\n", snapshotLines); try @@ -259,6 +260,10 @@ public void Enable_WhenCompiledLineDriftsFromEditedFile_AddsDriftWarningAndNextA response.ResolvedMethod, spanResult.Resolution.CompiledMethodStartLine, spanResult.Resolution.CompiledMethodEndLine); + expectedDrift = PausePointEnableWarnings.AppendCandidateCompiledLinesToDriftWarningOrUnchanged( + expectedDrift, + "return 424242;", + snapshotLines); string expectedWarning = PausePointEnableWarnings.MergeWarnings( PausePointEnableWarnings.MergeWarnings( PausePointEnableWarnings.MergeWarnings( @@ -344,6 +349,145 @@ public void AppendCompiledMethodSpanToDriftWarningOrUnchanged_WhenDriftIsEmpty_R Assert.That(warning, Is.EqualTo(string.Empty)); } + /// + /// What: one trimmed compiled-source match appends a single-line candidate to the drift warning. + /// + [Test] + public void AppendCandidateCompiledLinesToDriftWarningOrUnchanged_WhenOneLineMatches_AppendsSingleCandidate() + { + string drift = string.Format( + SourcePausePointConstants.HotReloadCompiledLineMapLineDriftWarningFormat, + ForwardSlashFile, + 17, + "return 1;", + "return 2;"); + string[] compiledLines = + { + "class Sample", + " return 2;", + " return 1;" + }; + + string warning = PausePointEnableWarnings.AppendCandidateCompiledLinesToDriftWarningOrUnchanged( + drift, + " return 2; ", + compiledLines); + + Assert.That( + warning, + Is.EqualTo( + drift + " Candidate: the edited line's text appears at line 2 in the last compiled source.")); + } + + /// + /// What: two or three trimmed compiled-source matches append every matching line number. + /// + [Test] + public void AppendCandidateCompiledLinesToDriftWarningOrUnchanged_WhenThreeLinesMatch_AppendsAllCandidates() + { + string drift = string.Format( + SourcePausePointConstants.HotReloadCompiledLineMapLineDriftWarningFormat, + ForwardSlashFile, + 17, + "return 1;", + "return 2;"); + string[] compiledLines = + { + "class Sample", + " return 2;", + " return 1;", + " return 2;", + " return 2;" + }; + + string warning = PausePointEnableWarnings.AppendCandidateCompiledLinesToDriftWarningOrUnchanged( + drift, + "return 2;", + compiledLines); + + Assert.That( + warning, + Is.EqualTo( + drift + " Candidate: the edited line's text appears at lines 2, 4, 5 in the last compiled source.")); + } + + /// + /// What: more than three trimmed compiled-source matches append the first three and a truncation note. + /// + [Test] + public void AppendCandidateCompiledLinesToDriftWarningOrUnchanged_WhenMoreThanThreeLinesMatch_CapsAtFirstThree() + { + string drift = string.Format( + SourcePausePointConstants.HotReloadCompiledLineMapLineDriftWarningFormat, + ForwardSlashFile, + 17, + "return 1;", + "return 2;"); + string[] compiledLines = + { + " return 2;", + " return 1;", + " return 2;", + " return 2;", + " return 2;" + }; + + string warning = PausePointEnableWarnings.AppendCandidateCompiledLinesToDriftWarningOrUnchanged( + drift, + "return 2;", + compiledLines); + + Assert.That( + warning, + Is.EqualTo( + drift + " Candidate: the edited line's text appears at lines 1, 3, 4 (first 3 matches) in the last compiled source.")); + } + + /// + /// What: no compiled-source match leaves the drift warning unchanged. + /// + [Test] + public void AppendCandidateCompiledLinesToDriftWarningOrUnchanged_WhenNoLineMatches_LeavesWarningUnchanged() + { + string drift = string.Format( + SourcePausePointConstants.HotReloadCompiledLineMapLineDriftWarningFormat, + ForwardSlashFile, + 17, + "return 1;", + "return 2;"); + string[] compiledLines = + { + "class Sample", + " return 1;" + }; + + string warning = PausePointEnableWarnings.AppendCandidateCompiledLinesToDriftWarningOrUnchanged( + drift, + "return 2;", + compiledLines); + + Assert.That(warning, Is.EqualTo(drift)); + } + + /// + /// What: an empty drift warning stays empty even when compiled source contains the edited text. + /// + [Test] + public void AppendCandidateCompiledLinesToDriftWarningOrUnchanged_WhenDriftIsEmpty_ReturnsEmpty() + { + string[] compiledLines = + { + " return 2;" + }; + + string warning = PausePointEnableWarnings.AppendCandidateCompiledLinesToDriftWarningOrUnchanged( + string.Empty, + "return 2;", + compiledLines); + + Assert.That(warning, Is.EqualTo(string.Empty)); + } + /// /// What: retarget warning interpolates resolved method, requested line, and edited span. /// diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs index 206adbe195..6c858ab08a 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs @@ -115,6 +115,94 @@ internal static string AppendCompiledMethodSpanToDriftWarningOrUnchanged( compiledMethodEndLine); } + // Why only after a non-empty drift warning: a candidate list without drift would look + // like a second resolution, and empty edited text never produces drift in the first place. + internal static string AppendCandidateCompiledLinesToDriftWarningOrUnchanged( + string driftWarning, + string editedLineText, + IReadOnlyList compiledSourceLines) + { + if (string.IsNullOrEmpty(driftWarning)) + { + return driftWarning ?? string.Empty; + } + + if (string.IsNullOrEmpty(editedLineText) || compiledSourceLines == null) + { + return driftWarning; + } + + string editedTrimmed = editedLineText.Trim(); + if (editedTrimmed.Length == 0) + { + return driftWarning; + } + + (List matches, bool truncated) = CollectCandidateCompiledLineNumbers( + editedTrimmed, + compiledSourceLines); + if (matches.Count == 0) + { + return driftWarning; + } + + return driftWarning + FormatCandidateCompiledLinesSuffix(matches, truncated); + } + + private static (List matches, bool truncated) CollectCandidateCompiledLineNumbers( + string editedTrimmed, + IReadOnlyList compiledSourceLines) + { + int matchLimit = SourcePausePointConstants.CompiledLineDriftCandidateMatchLimit; + List matches = new List(); + bool truncated = false; + for (int index = 0; index < compiledSourceLines.Count; index++) + { + string compiledLine = compiledSourceLines[index]; + if (compiledLine == null) + { + continue; + } + + if (!string.Equals(compiledLine.Trim(), editedTrimmed, StringComparison.Ordinal)) + { + continue; + } + + if (matches.Count == matchLimit) + { + truncated = true; + break; + } + + matches.Add(index + 1); + } + + return (matches, truncated); + } + + private static string FormatCandidateCompiledLinesSuffix(List matches, bool truncated) + { + if (matches.Count == 1 && !truncated) + { + return string.Format( + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateSingleFormat, + matches[0]); + } + + string listed = string.Join(", ", matches); + if (truncated) + { + listed += string.Format( + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffixFormat, + SourcePausePointConstants.CompiledLineDriftCandidateMatchLimit); + } + + return string.Format( + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateMultipleFormat, + listed); + } + internal static string BuildRetargetedToHotReloadPatchWarningOrEmpty( bool retargetedToHotReloadPatch, string resolvedMethod, diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs index 236759afa6..d84cab8686 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs @@ -350,12 +350,18 @@ private static PausePointResponse FinishEnableBySourceLocation( snapshot = UloopPausePointRegistry.GetStatus(id); } - string resolvedLineText = ResolveEnableLineText( - parameters.File, - resolvedLine, - resolvedEndLine, - retargetedToHotReloadPatch, - hasActiveHotReloadPatches); + bool compareCompiledLineDrift = hasActiveHotReloadPatches && !retargetedToHotReloadPatch; + string compiledSnapshotSource = compareCompiledLineDrift + ? LoadCompiledSnapshotSourceOrEmpty(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). + // The snapshot read stays single-line because the verified snapshot has no end-line data; + // the disk read spans resolvedLine..resolvedEndLine so a rounded-forward multi-line + // statement returns its full text. + string resolvedLineText = compareCompiledLineDrift + ? SourcePausePointSourceLineReader.ReadLineTextFromSource(compiledSnapshotSource, resolvedLine) + : PausePointLineTextReader.ReadResolvedLineText(parameters.File, resolvedLine, resolvedEndLine); UloopPausePointRegistry.SetResolvedLine(id, resolvedLine, resolvedLineText); PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); @@ -373,7 +379,6 @@ private static PausePointResponse FinishEnableBySourceLocation( parameters.Line, editedMethodStartLine, editedMethodEndLine)); - bool compareCompiledLineDrift = hasActiveHotReloadPatches && !retargetedToHotReloadPatch; string compiledLineMapWarning = PausePointEnableWarnings.ChooseCompiledLineMapWarning( patchedMethodPdbUnavailableWarning, PausePointEnableWarnings.BuildCompiledLineMapWarningOrEmpty( @@ -393,6 +398,11 @@ private static PausePointResponse FinishEnableBySourceLocation( resolvedMethod, compiledMethodStartLine, compiledMethodEndLine); + string[] compiledSourceLines = SourcePausePointSourceLineReader.SplitSourceLines(compiledSnapshotSource); + driftWarning = PausePointEnableWarnings.AppendCandidateCompiledLinesToDriftWarningOrUnchanged( + driftWarning, + editedLineText, + compiledSourceLines); enableWarning = PausePointEnableWarnings.MergeWarnings(enableWarning, driftWarning); if (driftWarning.Length > 0) { @@ -466,27 +476,12 @@ private static void LogPhysicsDispatchDiagnostics(string operation, string id, T }); } - // 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). - // The snapshot read stays single-line because the verified snapshot has no end-line data; - // the disk read spans resolvedLine..resolvedEndLine so a rounded-forward multi-line - // statement returns its full text. - private static string ResolveEnableLineText( - string requestedFile, - int resolvedLine, - int resolvedEndLine, - bool retargetedToHotReloadPatch, - bool hasActiveHotReloadPatches) + private static string LoadCompiledSnapshotSourceOrEmpty(string requestedFile) { - if (hasActiveHotReloadPatches && !retargetedToHotReloadPatch) - { - string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile); - string snapshotSource = - HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile?.Invoke(normalizedFile); - return SourcePausePointSourceLineReader.ReadLineTextFromSource(snapshotSource, resolvedLine); - } - - return PausePointLineTextReader.ReadResolvedLineText(requestedFile, resolvedLine, resolvedEndLine); + 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 diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index 960bb5d5e7..8db0234110 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -256,6 +256,23 @@ internal static class SourcePausePointConstants public const string HotReloadCompiledMethodSpanInLastCompiledSourceFormat = " In the last compiled source, '{0}' spans lines {1}-{2}."; + // Why cap 3: a longer match list turns the enable warning into another line-number puzzle. + public const int CompiledLineDriftCandidateMatchLimit = 3; + + // Format: 1-based compiled line number. Why "Candidate": this is a search hit, not a + // guarantee that re-enabling there is the intended statement. + public const string HotReloadCompiledLineDriftCandidateSingleFormat = + " Candidate: the edited line's text appears at line {0} in the last compiled source."; + + // Format: comma-separated 1-based compiled line numbers, with an optional truncation note. + public const string HotReloadCompiledLineDriftCandidateMultipleFormat = + " Candidate: the edited line's text appears at lines {0} in the last compiled source."; + + // Why format from CompiledLineDriftCandidateMatchLimit: a hard-coded "3" would lie + // if the cap changed. + public const string HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffixFormat = + " (first {0} matches)"; + public const string NearbyCompiledMethodsPrefix = " Nearby methods in the last compiled source: "; diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointSourceLineReader.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointSourceLineReader.cs index 4b5e2364ae..2707418d09 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointSourceLineReader.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointSourceLineReader.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -34,6 +35,16 @@ public static string ReadLineText(string absoluteFilePath, int startLine, int en return string.Join(" ", trimmedNonEmptyLines); } + public static string[] SplitSourceLines(string sourceText) + { + if (string.IsNullOrEmpty(sourceText)) + { + return Array.Empty(); + } + + return sourceText.Replace("\r\n", "\n").Split('\n'); + } + public static string ReadLineTextFromSource(string sourceText, int lineNumber) { if (string.IsNullOrEmpty(sourceText) || lineNumber <= 0) @@ -41,7 +52,7 @@ public static string ReadLineTextFromSource(string sourceText, int lineNumber) return string.Empty; } - string[] lines = sourceText.Replace("\r\n", "\n").Split('\n'); + string[] lines = SplitSourceLines(sourceText); if (lineNumber > lines.Length) { return string.Empty;