From 700cbff1ca8aedfc8f3ed815f337d75b6c321ba7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 20 Aug 2026 11:34:15 +0900 Subject: [PATCH 1/2] feat: suggest candidate compiled lines when an enable line drifts from the edited file Line-drift warnings already showed compiled vs edited text at the resolved line, but not where that edited statement lives in the last compiled source, so testers had to recompute line numbers by hand. Co-authored-by: Cursor --- .../PausePointCompiledLineMapWarningTests.cs | 150 ++++++++++++++++++ .../PausePoint/PausePointEnableWarnings.cs | 86 ++++++++++ .../PausePoint/PausePointUseCase.cs | 49 +++--- .../PausePoint/SourcePausePointConstants.cs | 15 ++ .../SourcePausePointSourceLineReader.cs | 13 +- 5 files changed, 285 insertions(+), 28 deletions(-) diff --git a/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs b/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs index ac4398faf2..4dedf24651 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,151 @@ 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 + string.Format( + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateSingleFormat, + 2))); + } + + /// + /// 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 + string.Format( + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateMultipleFormat, + "2, 4, 5"))); + } + + /// + /// 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 + string.Format( + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateMultipleFormat, + "1, 3, 4" + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffix))); + } + + /// + /// 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..75de440b51 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs @@ -115,6 +115,92 @@ 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 += SourcePausePointConstants.HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffix; + } + + 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..e988af53ae 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -256,6 +256,21 @@ 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."; + + public const string HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffix = + " (first 3 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; From df3325b3c7a805ea46bcd269ff1fde363a94a45d Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 20 Aug 2026 11:49:45 +0900 Subject: [PATCH 2/2] fix: pin candidate-line tests to literals and format the match-limit suffix The new candidate tests assembled expected text from the production format constants, so clearing {0} still passed. The truncation suffix also hard-coded 3 beside CompiledLineDriftCandidateMatchLimit. Co-authored-by: Cursor --- .../Editor/PausePointCompiledLineMapWarningTests.cs | 12 +++--------- .../PausePoint/PausePointEnableWarnings.cs | 4 +++- .../PausePoint/SourcePausePointConstants.cs | 6 ++++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs b/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs index 4dedf24651..1488fbd1a0 100644 --- a/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs +++ b/Assets/Tests/Editor/PausePointCompiledLineMapWarningTests.cs @@ -376,9 +376,7 @@ public void AppendCandidateCompiledLinesToDriftWarningOrUnchanged_WhenOneLineMat Assert.That( warning, Is.EqualTo( - drift + string.Format( - SourcePausePointConstants.HotReloadCompiledLineDriftCandidateSingleFormat, - 2))); + drift + " Candidate: the edited line's text appears at line 2 in the last compiled source.")); } /// @@ -410,9 +408,7 @@ public void AppendCandidateCompiledLinesToDriftWarningOrUnchanged_WhenThreeLines Assert.That( warning, Is.EqualTo( - drift + string.Format( - SourcePausePointConstants.HotReloadCompiledLineDriftCandidateMultipleFormat, - "2, 4, 5"))); + drift + " Candidate: the edited line's text appears at lines 2, 4, 5 in the last compiled source.")); } /// @@ -444,9 +440,7 @@ public void AppendCandidateCompiledLinesToDriftWarningOrUnchanged_WhenMoreThanTh Assert.That( warning, Is.EqualTo( - drift + string.Format( - SourcePausePointConstants.HotReloadCompiledLineDriftCandidateMultipleFormat, - "1, 3, 4" + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffix))); + drift + " Candidate: the edited line's text appears at lines 1, 3, 4 (first 3 matches) in the last compiled source.")); } /// diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs index 75de440b51..6c858ab08a 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs @@ -193,7 +193,9 @@ private static string FormatCandidateCompiledLinesSuffix(List matches, bool string listed = string.Join(", ", matches); if (truncated) { - listed += SourcePausePointConstants.HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffix; + listed += string.Format( + SourcePausePointConstants.HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffixFormat, + SourcePausePointConstants.CompiledLineDriftCandidateMatchLimit); } return string.Format( diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index e988af53ae..8db0234110 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -268,8 +268,10 @@ internal static class SourcePausePointConstants public const string HotReloadCompiledLineDriftCandidateMultipleFormat = " Candidate: the edited line's text appears at lines {0} in the last compiled source."; - public const string HotReloadCompiledLineDriftCandidateTruncatedMatchesSuffix = - " (first 3 matches)"; + // 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: ";