From aac420c453b3d627a996df871d5150c9bf4a7a0d Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 17:18:00 +0900 Subject: [PATCH 1/3] Add characterization tests for DynamicCodeLiteralHoister and SourceShaper Lock current Rewrite/Analyze/WrapIfNeeded outputs before splitting the god classes so behavior stays pinned across Extract Class refactors. Co-authored-by: Cursor --- ...CodeLiteralHoisterCharacterizationTests.cs | 195 ++++++++++++++++++ ...iteralHoisterCharacterizationTests.cs.meta | 11 + .../SourceShaperCharacterizationTests.cs | 168 +++++++++++++++ .../SourceShaperCharacterizationTests.cs.meta | 11 + 4 files changed, 385 insertions(+) create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs.meta create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SourceShaperCharacterizationTests.cs create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SourceShaperCharacterizationTests.cs.meta diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs new file mode 100644 index 0000000000..841aa5fd2d --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs @@ -0,0 +1,195 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests +{ + /// + /// Characterization tests that lock current DynamicCodeLiteralHoister Rewrite outputs before god-class splits. + /// + [TestFixture] + public class DynamicCodeLiteralHoisterCharacterizationTests + { + /// + /// Pins integer literal hoisting for a simple return statement. + /// + [Test] + public void Rewrite_WhenIntegerLiteral_ShouldHoistToParameter() + { + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite("return 42;"); + + Assert.That(result.RewrittenSource, Is.EqualTo("return __uloop_literal_0;")); + Assert.That(result.Bindings, Has.Count.EqualTo(1)); + Assert.That(result.Bindings[0].ParameterName, Is.EqualTo("__uloop_literal_0")); + Assert.That(result.Bindings[0].TypeName, Is.EqualTo("int")); + Assert.That(result.Bindings[0].Value, Is.EqualTo(42)); + Assert.That( + result.DeclarationLines, + Is.EqualTo(new[] + { + "int __uloop_literal_0 = (int)parameters[\"__uloop_literal_0\"];" + })); + } + + /// + /// Pins long-suffixed integer literal hoisting. + /// + [Test] + public void Rewrite_WhenLongLiteral_ShouldHoistAsLong() + { + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite("return 10L;"); + + Assert.That(result.RewrittenSource, Is.EqualTo("return __uloop_literal_0;")); + Assert.That(result.Bindings, Has.Count.EqualTo(1)); + Assert.That(result.Bindings[0].TypeName, Is.EqualTo("long")); + Assert.That(result.Bindings[0].Value, Is.EqualTo(10L)); + } + + /// + /// Pins multiple integer literals hoisted in source order. + /// + [Test] + public void Rewrite_WhenMultipleIntegerLiterals_ShouldHoistInOrder() + { + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite("return 1 + 2;"); + + Assert.That(result.RewrittenSource, Is.EqualTo("return __uloop_literal_0 + __uloop_literal_1;")); + Assert.That(result.Bindings, Has.Count.EqualTo(2)); + Assert.That(result.Bindings[0].Value, Is.EqualTo(1)); + Assert.That(result.Bindings[1].Value, Is.EqualTo(2)); + } + + /// + /// Pins regular string literal hoisting with the unescaped value stored in the binding. + /// + [Test] + public void Rewrite_WhenRegularStringLiteral_ShouldHoistUnescapedValue() + { + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite("return \"hello\";"); + + Assert.That(result.RewrittenSource, Is.EqualTo("return __uloop_literal_0;")); + Assert.That(result.Bindings, Has.Count.EqualTo(1)); + Assert.That(result.Bindings[0].TypeName, Is.EqualTo("string")); + Assert.That(result.Bindings[0].Value, Is.EqualTo("hello")); + Assert.That( + result.DeclarationLines, + Is.EqualTo(new[] + { + "string __uloop_literal_0 = (string)parameters[\"__uloop_literal_0\"];" + })); + } + + /// + /// Pins escape-sequence unescaping for regular string literals. + /// + [Test] + public void Rewrite_WhenStringLiteralHasEscape_ShouldStoreUnescapedBindingValue() + { + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite("return \"a\\nb\";"); + + Assert.That(result.RewrittenSource, Is.EqualTo("return __uloop_literal_0;")); + Assert.That(result.Bindings, Has.Count.EqualTo(1)); + Assert.That(result.Bindings[0].Value, Is.EqualTo("a\nb")); + } + + /// + /// Pins verbatim strings remaining inline without hoisting. + /// + [Test] + public void Rewrite_WhenVerbatimString_ShouldKeepInline() + { + string source = "return @\"verbatim\";"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Is.EqualTo(source)); + Assert.That(result.Bindings, Is.Empty); + Assert.That(result.DeclarationLines, Is.Empty); + } + + /// + /// Pins character literals remaining inline without hoisting. + /// + [Test] + public void Rewrite_WhenCharLiteral_ShouldKeepInline() + { + string source = "return 'x';"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Is.EqualTo(source)); + Assert.That(result.Bindings, Is.Empty); + } + + /// + /// Pins interpolated strings remaining opaque, including nested integer tokens. + /// + [Test] + public void Rewrite_WhenInterpolatedString_ShouldKeepOpaque() + { + string source = "return $\"hi{1}\";"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Is.EqualTo(source)); + Assert.That(result.Bindings, Is.Empty); + } + + /// + /// Pins decimal tokens remaining inline because the integer scanner stops at the decimal point. + /// + [Test] + public void Rewrite_WhenDecimalLiteral_ShouldKeepInline() + { + string source = "return 1.5;"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Is.EqualTo(source)); + Assert.That(result.Bindings, Is.Empty); + } + + /// + /// Pins sources with no hoistable literals remaining unchanged. + /// + [Test] + public void Rewrite_WhenNoHoistableLiteral_ShouldReturnOriginalSource() + { + string source = "return null;"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Is.EqualTo(source)); + Assert.That(result.Bindings, Is.Empty); + Assert.That(result.DeclarationLines, Is.Empty); + } + + /// + /// Pins comments remaining intact while trailing literals still hoist. + /// + [Test] + public void Rewrite_WhenCommentPrecedesLiteral_ShouldPreserveCommentAndHoistLiteral() + { + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite("// note\nreturn 7;"); + + Assert.That(result.RewrittenSource, Is.EqualTo("// note\nreturn __uloop_literal_0;")); + Assert.That(result.Bindings, Has.Count.EqualTo(1)); + Assert.That(result.Bindings[0].Value, Is.EqualTo(7)); + } + + /// + /// Pins mixed string and integer hoisting in a single expression. + /// + [Test] + public void Rewrite_WhenStringAndIntegerLiterals_ShouldHoistBoth() + { + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite( + "return \"count\" + 3;"); + + Assert.That(result.RewrittenSource, Is.EqualTo("return __uloop_literal_0 + __uloop_literal_1;")); + Assert.That(result.Bindings, Has.Count.EqualTo(2)); + Assert.That(result.Bindings[0].Value, Is.EqualTo("count")); + Assert.That(result.Bindings[1].Value, Is.EqualTo(3)); + } + } +} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs.meta b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs.meta new file mode 100644 index 0000000000..635d5c009e --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b4a1665891c4e1a9e8fd479e42b8d25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SourceShaperCharacterizationTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SourceShaperCharacterizationTests.cs new file mode 100644 index 0000000000..d613b95bfc --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SourceShaperCharacterizationTests.cs @@ -0,0 +1,168 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests +{ + /// + /// Characterization tests that lock current SourceShaper Analyze/WrapIfNeeded outputs before god-class splits. + /// + [TestFixture] + public class SourceShaperCharacterizationTests + { + /// + /// Pins Analyze classification for a plain top-level return script. + /// + [Test] + public void Analyze_WhenTopLevelReturn_ShouldMarkTopLevelStatementsOnly() + { + SourceShapeResult result = SourceShaper.Analyze("return 1;"); + + Assert.That(result.HasTopLevelStatements, Is.True); + Assert.That(result.HasTypeDeclaration, Is.False); + Assert.That(result.HasNamespaceDeclaration, Is.False); + Assert.That(result.UsingDirectives, Is.Empty); + Assert.That(result.TopLevelBodyBuilder.ToString().TrimEnd(), Is.EqualTo("return 1;")); + } + + /// + /// Pins using-directive extraction and remaining body content. + /// + [Test] + public void Analyze_WhenUsingDirectiveThenReturn_ShouldExtractUsingAndBody() + { + SourceShapeResult result = SourceShaper.Analyze( + "using System.Text;\nreturn Encoding.UTF8;"); + + Assert.That(result.HasTopLevelStatements, Is.True); + Assert.That(result.UsingDirectives, Has.Count.EqualTo(1)); + Assert.That(result.UsingDirectives[0], Is.EqualTo("using System.Text;")); + // why: Analyze keeps the newline that followed the using directive in TopLevelBodyBuilder + Assert.That(result.TopLevelBodyBuilder.ToString().TrimEnd(), Is.EqualTo("\nreturn Encoding.UTF8;")); + } + + /// + /// Pins type-only sources as raw declarations without top-level statements. + /// + [Test] + public void Analyze_WhenTypeDeclarationOnly_ShouldMarkTypeDeclaration() + { + SourceShapeResult result = SourceShaper.Analyze("public class Example { }"); + + Assert.That(result.HasTypeDeclaration, Is.True); + Assert.That(result.HasTopLevelStatements, Is.False); + Assert.That(result.HasNamespaceDeclaration, Is.False); + } + + /// + /// Pins namespace block sources as namespace declarations; nested types stay unmarked. + /// + [Test] + public void Analyze_WhenNamespaceDeclarationOnly_ShouldMarkNamespaceDeclaration() + { + SourceShapeResult result = SourceShaper.Analyze("namespace Demo { public class Example { } }"); + + Assert.That(result.HasNamespaceDeclaration, Is.True); + // why: nested types inside a skipped namespace block are not separately flagged + Assert.That(result.HasTypeDeclaration, Is.False); + Assert.That(result.HasTopLevelStatements, Is.False); + } + + /// + /// Pins WrapIfNeeded passthrough for raw type declarations. + /// + [Test] + public void WrapIfNeeded_WhenTypeDeclarationOnly_ShouldReturnOriginalSource() + { + string source = "public class Example { }"; + + string wrapped = SourceShaper.WrapIfNeeded( + source, + DynamicCodeConstants.DEFAULT_NAMESPACE, + DynamicCodeConstants.DEFAULT_CLASS_NAME); + + Assert.That(wrapped, Is.EqualTo(source)); + } + + /// + /// Pins WrapIfNeeded rejecting mixed type + top-level statement sources. + /// + [Test] + public void WrapIfNeeded_WhenMixedTypeAndTopLevel_ShouldReturnNull() + { + string wrapped = SourceShaper.WrapIfNeeded( + "class Example { }\nreturn 1;", + DynamicCodeConstants.DEFAULT_NAMESPACE, + DynamicCodeConstants.DEFAULT_CLASS_NAME); + + Assert.That(wrapped, Is.Null); + } + + /// + /// Pins WrapIfNeeded wrapping a script that already returns a value. + /// + [Test] + public void WrapIfNeeded_WhenTopLevelReturn_ShouldWrapWithUserBodyIntact() + { + string wrapped = SourceShaper.WrapIfNeeded( + "return 1;", + DynamicCodeConstants.DEFAULT_NAMESPACE, + DynamicCodeConstants.DEFAULT_CLASS_NAME); + + Assert.That(wrapped, Is.Not.Null); + Assert.That(wrapped, Does.Contain("namespace " + DynamicCodeConstants.DEFAULT_NAMESPACE)); + Assert.That(wrapped, Does.Contain("public class " + DynamicCodeConstants.DEFAULT_CLASS_NAME)); + Assert.That(wrapped, Does.Contain(WrapperTemplate.UserCodeStartMarker)); + Assert.That(wrapped, Does.Contain(" return 1;")); + Assert.That(wrapped, Does.Contain(WrapperTemplate.UserCodeEndMarker)); + Assert.That(wrapped, Does.Not.Contain("return null;")); + } + + /// + /// Pins WrapIfNeeded injecting a trailing return null for scripts without an explicit return. + /// + [Test] + public void WrapIfNeeded_WhenNoReturn_ShouldAppendReturnNull() + { + string wrapped = SourceShaper.WrapIfNeeded( + "var x = 1;", + DynamicCodeConstants.DEFAULT_NAMESPACE, + DynamicCodeConstants.DEFAULT_CLASS_NAME); + + Assert.That(wrapped, Is.Not.Null); + Assert.That(wrapped, Does.Contain(" var x = 1;")); + Assert.That(wrapped, Does.Contain(" return null;")); + } + + /// + /// Pins WrapIfNeeded wrapping an empty script as a null-returning method body. + /// + [Test] + public void WrapIfNeeded_WhenEmptySource_ShouldWrapReturnNullOnly() + { + string wrapped = SourceShaper.WrapIfNeeded( + "", + DynamicCodeConstants.DEFAULT_NAMESPACE, + DynamicCodeConstants.DEFAULT_CLASS_NAME); + + Assert.That(wrapped, Is.Not.Null); + Assert.That(wrapped, Does.Contain(" return null;")); + } + + /// + /// Pins using-alias detection flowing into WrapIfNeeded alias tracking. + /// + [Test] + public void WrapIfNeeded_WhenUsingAlias_ShouldPreserveAliasDirectiveInWrapper() + { + string wrapped = SourceShaper.WrapIfNeeded( + "using Object = System.Object;\nreturn new Object();", + DynamicCodeConstants.DEFAULT_NAMESPACE, + DynamicCodeConstants.DEFAULT_CLASS_NAME); + + Assert.That(wrapped, Is.Not.Null); + Assert.That(wrapped, Does.Contain("using Object = System.Object;")); + Assert.That(wrapped, Does.Contain(" return new Object();")); + } + } +} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SourceShaperCharacterizationTests.cs.meta b/Assets/Tests/Editor/DynamicCodeToolTests/SourceShaperCharacterizationTests.cs.meta new file mode 100644 index 0000000000..9fcf026b07 --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SourceShaperCharacterizationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 252f02f7dd7c4f1a923ab54a4298153d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 9eb5a64f193adae9caca29f8d11eac6e7265aa24 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 17:23:55 +0900 Subject: [PATCH 2/3] Split ExecuteDynamicCode god classes under 500 lines Extract Class on DynamicCodeLiteralHoister, SourceShaper, and SharedRoslynCompilerWorkerHost so each file has one clear responsibility while keeping Rewrite/Analyze/WrapIfNeeded and worker host APIs behaviorally unchanged. Co-authored-by: Cursor --- .../DynamicCodeLiteralHoister.cs | 748 +----------------- .../DynamicCodeLiteralSyntaxScanner.cs | 436 ++++++++++ .../DynamicCodeLiteralSyntaxScanner.cs.meta | 11 + ...ynamicCodeRegularStringLiteralUnescaper.cs | 226 ++++++ ...cCodeRegularStringLiteralUnescaper.cs.meta | 11 + .../SharedRoslynCompilerWorkerHost.cs | 318 +------- .../SharedRoslynCompilerWorkerHostProcess.cs | 247 ++++++ ...redRoslynCompilerWorkerHostProcess.cs.meta | 11 + .../SharedRoslynCompilerWorkerHostResults.cs | 92 +++ ...redRoslynCompilerWorkerHostResults.cs.meta | 11 + .../DynamicCompilation/SourceShaper.cs | 420 +--------- .../DynamicCompilation/SourceTokenScanner.cs | 404 ++++++++++ .../SourceTokenScanner.cs.meta | 11 + 13 files changed, 1484 insertions(+), 1462 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralSyntaxScanner.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralSyntaxScanner.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeRegularStringLiteralUnescaper.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeRegularStringLiteralUnescaper.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs index cb5ff4942e..c3945e4db5 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs @@ -11,20 +11,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class DynamicCodeLiteralHoister { internal const string LiteralParameterPrefix = "__uloop_literal_"; - private static readonly Dictionary RegularStringEscapes = new() - { - { '\'', '\'' }, - { '"', '"' }, - { '\\', '\\' }, - { '0', '\0' }, - { 'a', '\a' }, - { 'b', '\b' }, - { 'f', '\f' }, - { 'n', '\n' }, - { 'r', '\r' }, - { 't', '\t' }, - { 'v', '\v' } - }; public static HoistedLiteralRewriteResult Rewrite(string source) { @@ -35,27 +21,27 @@ public static HoistedLiteralRewriteResult Rewrite(string source) while (index < source.Length) { - if (TryCopyInterpolatedStringLiteral(source, rewrittenSource, ref index)) + if (DynamicCodeLiteralSyntaxScanner.TryCopyInterpolatedStringLiteral(source, rewrittenSource, ref index)) { continue; } - if (TryCopyVerbatimStringLiteral(source, rewrittenSource, ref index)) + if (DynamicCodeLiteralSyntaxScanner.TryCopyVerbatimStringLiteral(source, rewrittenSource, ref index)) { continue; } - if (TryCopyCharLiteral(source, rewrittenSource, ref index)) + if (DynamicCodeLiteralSyntaxScanner.TryCopyCharLiteral(source, rewrittenSource, ref index)) { continue; } - if (TryCopyLineComment(source, rewrittenSource, ref index)) + if (DynamicCodeLiteralSyntaxScanner.TryCopyLineComment(source, rewrittenSource, ref index)) { continue; } - if (TryCopyBlockComment(source, rewrittenSource, ref index)) + if (DynamicCodeLiteralSyntaxScanner.TryCopyBlockComment(source, rewrittenSource, ref index)) { continue; } @@ -91,7 +77,7 @@ public static HoistedLiteralRewriteResult Rewrite(string source) if (scopeTracker.ShouldSuppressLiteralHoisting) { - if (TryCopyRegularStringLiteral(source, rewrittenSource, ref index)) + if (DynamicCodeLiteralSyntaxScanner.TryCopyRegularStringLiteral(source, rewrittenSource, ref index)) { continue; } @@ -124,512 +110,6 @@ public static HoistedLiteralRewriteResult Rewrite(string source) declarationLines); } - private static bool TryCopyInterpolatedStringLiteral( - string source, - StringBuilder rewrittenSource, - ref int index) - { - int start = index; - if (!TryAdvanceInterpolatedStringLiteral(source, ref index)) - { - return false; - } - - rewrittenSource.Append(source, start, index - start); - return true; - } - - private static bool TryCopyLineComment( - string source, - StringBuilder rewrittenSource, - ref int index) - { - if (index + 1 >= source.Length || source[index] != '/' || source[index + 1] != '/') - { - return false; - } - - while (index < source.Length) - { - char current = source[index]; - rewrittenSource.Append(current); - index++; - if (current == '\n') - { - break; - } - } - - return true; - } - - private static bool TryCopyBlockComment( - string source, - StringBuilder rewrittenSource, - ref int index) - { - if (index + 1 >= source.Length || source[index] != '/' || source[index + 1] != '*') - { - return false; - } - - rewrittenSource.Append(source[index]); - rewrittenSource.Append(source[index + 1]); - index += 2; - - while (index < source.Length) - { - char current = source[index]; - rewrittenSource.Append(current); - index++; - if (current == '*' && index < source.Length && source[index] == '/') - { - rewrittenSource.Append('/'); - index++; - break; - } - } - - return true; - } - - private static bool TryCopyVerbatimStringLiteral( - string source, - StringBuilder rewrittenSource, - ref int index) - { - if (source[index] != '@' - || index + 1 >= source.Length - || source[index + 1] != '"') - { - return false; - } - - int start = index; - index += 2; - - while (index < source.Length) - { - if (source[index] != '"') - { - index++; - continue; - } - - if (index + 1 < source.Length && source[index + 1] == '"') - { - index += 2; - continue; - } - - index++; - rewrittenSource.Append(source, start, index - start); - return true; - } - - index = start; - return false; - } - - private static bool TryAdvanceInterpolatedStringLiteral(string source, ref int index) - { - int start = index; - if (!TryMatchInterpolatedStringStart(source, ref index, out bool isVerbatim)) - { - return false; - } - - int interpolationDepth = 0; - while (index < source.Length) - { - if (interpolationDepth > 0) - { - interpolationDepth = AdvanceInterpolatedExpressionSegment( - source, - ref index, - interpolationDepth); - continue; - } - - InterpolatedStringAdvanceResult advanceResult = - AdvanceInterpolatedStringContentSegment(source, index, isVerbatim); - index = advanceResult.Index; - interpolationDepth = advanceResult.InterpolationDepth; - if (advanceResult.Completed) - { - return true; - } - } - - index = start; - return false; - } - - private static int AdvanceInterpolatedExpressionSegment( - string source, - ref int index, - int interpolationDepth) - { - if (TryAdvanceInterpolatedExpressionToken(source, ref index)) - { - return interpolationDepth; - } - - char expressionCharacter = source[index]; - if (expressionCharacter == '{') - { - index++; - return interpolationDepth + 1; - } - - if (expressionCharacter == '}') - { - index++; - return interpolationDepth - 1; - } - - index++; - return interpolationDepth; - } - - private static InterpolatedStringAdvanceResult AdvanceInterpolatedStringContentSegment( - string source, - int index, - bool isVerbatim) - { - char current = source[index]; - if (current == '{') - { - return index + 1 < source.Length && source[index + 1] == '{' - ? new InterpolatedStringAdvanceResult(index + 2, 0, false) - : new InterpolatedStringAdvanceResult(index + 1, 1, false); - } - - if (current == '}') - { - return index + 1 < source.Length && source[index + 1] == '}' - ? new InterpolatedStringAdvanceResult(index + 2, 0, false) - : new InterpolatedStringAdvanceResult(index + 1, 0, false); - } - - if (!isVerbatim && current == '\\') - { - int escapedIndex = index; - AdvanceEscapedLiteralSequence(source, ref escapedIndex); - return new InterpolatedStringAdvanceResult(escapedIndex, 0, false); - } - - if (current != '"') - { - return new InterpolatedStringAdvanceResult(index + 1, 0, false); - } - - if (isVerbatim && index + 1 < source.Length && source[index + 1] == '"') - { - return new InterpolatedStringAdvanceResult(index + 2, 0, false); - } - - return new InterpolatedStringAdvanceResult(index + 1, 0, true); - } - - private static bool TryMatchInterpolatedStringStart( - string source, - ref int index, - out bool isVerbatim) - { - isVerbatim = false; - - if (source[index] == '$') - { - if (index + 1 < source.Length && source[index + 1] == '"') - { - index += 2; - return true; - } - - if (index + 2 < source.Length && source[index + 1] == '@' && source[index + 2] == '"') - { - isVerbatim = true; - index += 3; - return true; - } - - return false; - } - - if (source[index] != '@') - { - return false; - } - - if (index + 2 < source.Length && source[index + 1] == '$' && source[index + 2] == '"') - { - isVerbatim = true; - index += 3; - return true; - } - - return false; - } - - private static bool TryAdvanceInterpolatedExpressionToken(string source, ref int index) - { - if (TryAdvanceInterpolatedStringLiteral(source, ref index)) - { - return true; - } - - if (TryAdvanceVerbatimStringLiteral(source, ref index)) - { - return true; - } - - if (TryAdvanceRegularStringLiteral(source, ref index)) - { - return true; - } - - if (TryAdvanceCharLiteral(source, ref index)) - { - return true; - } - - if (TryAdvanceLineComment(source, ref index)) - { - return true; - } - - if (TryAdvanceBlockComment(source, ref index)) - { - return true; - } - - return false; - } - - private static bool TryAdvanceLineComment(string source, ref int index) - { - if (index + 1 >= source.Length || source[index] != '/' || source[index + 1] != '/') - { - return false; - } - - index += 2; - while (index < source.Length && source[index] != '\n') - { - index++; - } - - if (index < source.Length) - { - index++; - } - - return true; - } - - private static bool TryAdvanceBlockComment(string source, ref int index) - { - if (index + 1 >= source.Length || source[index] != '/' || source[index + 1] != '*') - { - return false; - } - - index += 2; - while (index < source.Length) - { - if (source[index] == '*' && index + 1 < source.Length && source[index + 1] == '/') - { - index += 2; - return true; - } - - index++; - } - - return true; - } - - private static bool TryAdvanceVerbatimStringLiteral(string source, ref int index) - { - if (source[index] != '@' - || index + 1 >= source.Length - || source[index + 1] != '"') - { - return false; - } - - int start = index; - index += 2; - - while (index < source.Length) - { - if (source[index] != '"') - { - index++; - continue; - } - - if (index + 1 < source.Length && source[index + 1] == '"') - { - index += 2; - continue; - } - - index++; - return true; - } - - index = start; - return false; - } - - private static bool TryAdvanceRegularStringLiteral(string source, ref int index) - { - if (source[index] != '"') - { - return false; - } - - if (index > 0 && (source[index - 1] == '@' || source[index - 1] == '$')) - { - return false; - } - - int start = index; - index++; - - while (index < source.Length) - { - char current = source[index]; - if (current == '\\') - { - AdvanceEscapedLiteralSequence(source, ref index); - continue; - } - - if (current == '"') - { - index++; - return true; - } - - index++; - } - - index = start; - return false; - } - - private static bool TryAdvanceCharLiteral(string source, ref int index) - { - if (source[index] != '\'') - { - return false; - } - - int start = index; - index++; - - while (index < source.Length) - { - char current = source[index]; - if (current == '\\') - { - AdvanceEscapedLiteralSequence(source, ref index); - continue; - } - - if (current == '\'') - { - index++; - return true; - } - - index++; - } - - index = start; - return false; - } - - private static bool TryCopyCharLiteral( - string source, - StringBuilder rewrittenSource, - ref int index) - { - if (source[index] != '\'') - { - return false; - } - - int start = index; - index++; - - while (index < source.Length) - { - char current = source[index]; - if (current == '\\') - { - AdvanceEscapedLiteralSequence(source, ref index); - continue; - } - - if (current == '\'') - { - index++; - rewrittenSource.Append(source, start, index - start); - return true; - } - - index++; - } - - index = start; - return false; - } - - private static bool TryCopyRegularStringLiteral( - string source, - StringBuilder rewrittenSource, - ref int index) - { - if (source[index] != '"') - { - return false; - } - - if (index > 0 && (source[index - 1] == '@' || source[index - 1] == '$')) - { - return false; - } - - int start = index; - index++; - - while (index < source.Length) - { - char current = source[index]; - if (current == '\\') - { - AdvanceEscapedLiteralSequence(source, ref index); - continue; - } - - if (current == '"') - { - index++; - rewrittenSource.Append(source, start, index - start); - return true; - } - - index++; - } - - index = start; - return false; - } - private static bool TryHoistRegularStringLiteral( string source, StringBuilder rewrittenSource, @@ -654,7 +134,7 @@ private static bool TryHoistRegularStringLiteral( char current = source[index]; if (current == '\\') { - AdvanceEscapedLiteralSequence(source, ref index); + DynamicCodeRegularStringLiteralUnescaper.AdvanceEscapedLiteralSequence(source, ref index); continue; } @@ -662,7 +142,7 @@ private static bool TryHoistRegularStringLiteral( { index++; string literalToken = source.Substring(start, index - start); - if (!TryUnescapeRegularStringLiteral(literalToken, out string value)) + if (!DynamicCodeRegularStringLiteralUnescaper.TryUnescapeRegularStringLiteral(literalToken, out string value)) { rewrittenSource.Append(literalToken); return true; @@ -766,218 +246,6 @@ private static string CreateParameterName(int index) { return $"{LiteralParameterPrefix}{index}"; } - - private static void AdvanceEscapedLiteralSequence(string source, ref int index) - { - index++; - if (index >= source.Length) - { - return; - } - - char escape = source[index]; - index++; - - switch (escape) - { - case 'u': - index = Math.Min(source.Length, index + 4); - break; - case 'U': - index = Math.Min(source.Length, index + 8); - break; - case 'x': - index = AdvanceVariableLengthHexDigits(source, index, 4); - break; - } - } - - private static int AdvanceVariableLengthHexDigits(string value, int index, int maxDigits) - { - int digitsConsumed = 0; - while (index < value.Length && digitsConsumed < maxDigits && IsHexDigit(value[index])) - { - index++; - digitsConsumed++; - } - - return index; - } - - private static bool TryUnescapeRegularStringLiteral(string token, out string value) - { - string inner = token.Substring(1, token.Length - 2); - StringBuilder unescaped = new(inner.Length); - - for (int index = 0; index < inner.Length; index++) - { - char current = inner[index]; - if (current != '\\') - { - unescaped.Append(current); - continue; - } - - if (!TryAppendRegularStringEscape(inner, ref index, unescaped)) - { - value = null; - return false; - } - } - - value = unescaped.ToString(); - return true; - } - - private static bool TryAppendRegularStringEscape( - string inner, - ref int index, - StringBuilder unescaped) - { - if (index + 1 >= inner.Length) - { - return false; - } - - index++; - char escape = inner[index]; - if (RegularStringEscapes.ContainsKey(escape)) - { - unescaped.Append(RegularStringEscapes[escape]); - return true; - } - - return TryAppendComplexRegularStringEscape(inner, ref index, unescaped, escape); - } - - private static bool TryAppendComplexRegularStringEscape( - string inner, - ref int index, - StringBuilder unescaped, - char escape) - { - switch (escape) - { - case 'u': - return TryAppendUnicodeEscape(inner, ref index, unescaped); - case 'U': - return TryAppendUtf32Escape(inner, ref index, unescaped); - case 'x': - return TryAppendVariableLengthHexEscape(inner, ref index, unescaped); - default: - return false; - } - } - - private static bool TryAppendUnicodeEscape( - string inner, - ref int index, - StringBuilder unescaped) - { - if (!TryParseHexDigits(inner, ref index, 4, out int unicodeValue)) - { - return false; - } - - unescaped.Append((char)unicodeValue); - return true; - } - - private static bool TryAppendUtf32Escape( - string inner, - ref int index, - StringBuilder unescaped) - { - if (!TryParseHexDigits(inner, ref index, 8, out int codePoint) || - !IsValidUtf32CodePoint(codePoint)) - { - return false; - } - - unescaped.Append(char.ConvertFromUtf32(codePoint)); - return true; - } - - private static bool TryAppendVariableLengthHexEscape( - string inner, - ref int index, - StringBuilder unescaped) - { - if (!TryParseVariableLengthHexDigits(inner, ref index, out int variableLengthValue)) - { - return false; - } - - unescaped.Append((char)variableLengthValue); - return true; - } - - private static bool TryParseHexDigits(string value, ref int index, int digitCount, out int parsedValue) - { - int start = index + 1; - if (start + digitCount > value.Length) - { - parsedValue = 0; - return false; - } - - string hex = value.Substring(start, digitCount); - if (!int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsedValue)) - { - return false; - } - - index += digitCount; - return true; - } - - private static bool TryParseVariableLengthHexDigits(string value, ref int index, out int parsedValue) - { - int start = index + 1; - int end = AdvanceVariableLengthHexDigits(value, start, 4); - if (end == start) - { - parsedValue = 0; - return false; - } - - string hex = value.Substring(start, end - start); - if (!int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsedValue)) - { - return false; - } - - index = end - 1; - return true; - } - - private static bool IsValidUtf32CodePoint(int value) - { - return value >= 0 - && value <= 0x10FFFF - && (value < 0xD800 || value > 0xDFFF); - } - - private static bool IsHexDigit(char value) - { - return (value >= '0' && value <= '9') - || (value >= 'a' && value <= 'f') - || (value >= 'A' && value <= 'F'); - } - - private readonly struct InterpolatedStringAdvanceResult - { - public InterpolatedStringAdvanceResult(int index, int interpolationDepth, bool completed) - { - Index = index; - InterpolationDepth = interpolationDepth; - Completed = completed; - } - - public int Index { get; } - public int InterpolationDepth { get; } - public bool Completed { get; } - } } /// diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralSyntaxScanner.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralSyntaxScanner.cs new file mode 100644 index 0000000000..3eb6d32775 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralSyntaxScanner.cs @@ -0,0 +1,436 @@ +using System.Text; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Scans C# source and copies or advances over literal and comment syntax segments. + /// + internal static class DynamicCodeLiteralSyntaxScanner + { + internal static bool TryCopyInterpolatedStringLiteral( + string source, + StringBuilder rewrittenSource, + ref int index) + { + int start = index; + if (!TryAdvanceInterpolatedStringLiteral(source, ref index)) + { + return false; + } + + rewrittenSource.Append(source, start, index - start); + return true; + } + + internal static bool TryCopyLineComment( + string source, + StringBuilder rewrittenSource, + ref int index) + { + int start = index; + if (!TryAdvanceLineComment(source, ref index)) + { + return false; + } + + rewrittenSource.Append(source, start, index - start); + return true; + } + + internal static bool TryCopyBlockComment( + string source, + StringBuilder rewrittenSource, + ref int index) + { + int start = index; + if (!TryAdvanceBlockComment(source, ref index)) + { + return false; + } + + rewrittenSource.Append(source, start, index - start); + return true; + } + + internal static bool TryCopyVerbatimStringLiteral( + string source, + StringBuilder rewrittenSource, + ref int index) + { + int start = index; + if (!TryAdvanceVerbatimStringLiteral(source, ref index)) + { + return false; + } + + rewrittenSource.Append(source, start, index - start); + return true; + } + + private static bool TryAdvanceInterpolatedStringLiteral(string source, ref int index) + { + int start = index; + if (!TryMatchInterpolatedStringStart(source, ref index, out bool isVerbatim)) + { + return false; + } + + int interpolationDepth = 0; + while (index < source.Length) + { + if (interpolationDepth > 0) + { + interpolationDepth = AdvanceInterpolatedExpressionSegment( + source, + ref index, + interpolationDepth); + continue; + } + + InterpolatedStringAdvanceResult advanceResult = + AdvanceInterpolatedStringContentSegment(source, index, isVerbatim); + index = advanceResult.Index; + interpolationDepth = advanceResult.InterpolationDepth; + if (advanceResult.Completed) + { + return true; + } + } + + index = start; + return false; + } + + private static int AdvanceInterpolatedExpressionSegment( + string source, + ref int index, + int interpolationDepth) + { + if (TryAdvanceInterpolatedExpressionToken(source, ref index)) + { + return interpolationDepth; + } + + char expressionCharacter = source[index]; + if (expressionCharacter == '{') + { + index++; + return interpolationDepth + 1; + } + + if (expressionCharacter == '}') + { + index++; + return interpolationDepth - 1; + } + + index++; + return interpolationDepth; + } + + private static InterpolatedStringAdvanceResult AdvanceInterpolatedStringContentSegment( + string source, + int index, + bool isVerbatim) + { + char current = source[index]; + if (current == '{') + { + return index + 1 < source.Length && source[index + 1] == '{' + ? new InterpolatedStringAdvanceResult(index + 2, 0, false) + : new InterpolatedStringAdvanceResult(index + 1, 1, false); + } + + if (current == '}') + { + return index + 1 < source.Length && source[index + 1] == '}' + ? new InterpolatedStringAdvanceResult(index + 2, 0, false) + : new InterpolatedStringAdvanceResult(index + 1, 0, false); + } + + if (!isVerbatim && current == '\\') + { + int escapedIndex = index; + DynamicCodeRegularStringLiteralUnescaper.AdvanceEscapedLiteralSequence(source, ref escapedIndex); + return new InterpolatedStringAdvanceResult(escapedIndex, 0, false); + } + + if (current != '"') + { + return new InterpolatedStringAdvanceResult(index + 1, 0, false); + } + + if (isVerbatim && index + 1 < source.Length && source[index + 1] == '"') + { + return new InterpolatedStringAdvanceResult(index + 2, 0, false); + } + + return new InterpolatedStringAdvanceResult(index + 1, 0, true); + } + + private static bool TryMatchInterpolatedStringStart( + string source, + ref int index, + out bool isVerbatim) + { + isVerbatim = false; + + if (source[index] == '$') + { + if (index + 1 < source.Length && source[index + 1] == '"') + { + index += 2; + return true; + } + + if (index + 2 < source.Length && source[index + 1] == '@' && source[index + 2] == '"') + { + isVerbatim = true; + index += 3; + return true; + } + + return false; + } + + if (source[index] != '@') + { + return false; + } + + if (index + 2 < source.Length && source[index + 1] == '$' && source[index + 2] == '"') + { + isVerbatim = true; + index += 3; + return true; + } + + return false; + } + + private static bool TryAdvanceInterpolatedExpressionToken(string source, ref int index) + { + if (TryAdvanceInterpolatedStringLiteral(source, ref index)) + { + return true; + } + + if (TryAdvanceVerbatimStringLiteral(source, ref index)) + { + return true; + } + + if (TryAdvanceRegularStringLiteral(source, ref index)) + { + return true; + } + + if (TryAdvanceCharLiteral(source, ref index)) + { + return true; + } + + if (TryAdvanceLineComment(source, ref index)) + { + return true; + } + + if (TryAdvanceBlockComment(source, ref index)) + { + return true; + } + + return false; + } + + private static bool TryAdvanceLineComment(string source, ref int index) + { + if (index + 1 >= source.Length || source[index] != '/' || source[index + 1] != '/') + { + return false; + } + + index += 2; + while (index < source.Length && source[index] != '\n') + { + index++; + } + + if (index < source.Length) + { + index++; + } + + return true; + } + + private static bool TryAdvanceBlockComment(string source, ref int index) + { + if (index + 1 >= source.Length || source[index] != '/' || source[index + 1] != '*') + { + return false; + } + + index += 2; + while (index < source.Length) + { + if (source[index] == '*' && index + 1 < source.Length && source[index + 1] == '/') + { + index += 2; + return true; + } + + index++; + } + + return true; + } + + private static bool TryAdvanceVerbatimStringLiteral(string source, ref int index) + { + if (source[index] != '@' + || index + 1 >= source.Length + || source[index + 1] != '"') + { + return false; + } + + int start = index; + index += 2; + + while (index < source.Length) + { + if (source[index] != '"') + { + index++; + continue; + } + + if (index + 1 < source.Length && source[index + 1] == '"') + { + index += 2; + continue; + } + + index++; + return true; + } + + index = start; + return false; + } + + private static bool TryAdvanceRegularStringLiteral(string source, ref int index) + { + if (source[index] != '"') + { + return false; + } + + if (index > 0 && (source[index - 1] == '@' || source[index - 1] == '$')) + { + return false; + } + + int start = index; + index++; + + while (index < source.Length) + { + char current = source[index]; + if (current == '\\') + { + DynamicCodeRegularStringLiteralUnescaper.AdvanceEscapedLiteralSequence(source, ref index); + continue; + } + + if (current == '"') + { + index++; + return true; + } + + index++; + } + + index = start; + return false; + } + + private static bool TryAdvanceCharLiteral(string source, ref int index) + { + if (source[index] != '\'') + { + return false; + } + + int start = index; + index++; + + while (index < source.Length) + { + char current = source[index]; + if (current == '\\') + { + DynamicCodeRegularStringLiteralUnescaper.AdvanceEscapedLiteralSequence(source, ref index); + continue; + } + + if (current == '\'') + { + index++; + return true; + } + + index++; + } + + index = start; + return false; + } + + internal static bool TryCopyCharLiteral( + string source, + StringBuilder rewrittenSource, + ref int index) + { + int start = index; + if (!TryAdvanceCharLiteral(source, ref index)) + { + return false; + } + + rewrittenSource.Append(source, start, index - start); + return true; + } + + internal static bool TryCopyRegularStringLiteral( + string source, + StringBuilder rewrittenSource, + ref int index) + { + int start = index; + if (!TryAdvanceRegularStringLiteral(source, ref index)) + { + return false; + } + + rewrittenSource.Append(source, start, index - start); + return true; + } + + private readonly struct InterpolatedStringAdvanceResult + { + public InterpolatedStringAdvanceResult(int index, int interpolationDepth, bool completed) + { + Index = index; + InterpolationDepth = interpolationDepth; + Completed = completed; + } + + public int Index { get; } + public int InterpolationDepth { get; } + public bool Completed { get; } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralSyntaxScanner.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralSyntaxScanner.cs.meta new file mode 100644 index 0000000000..afd38cff3f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralSyntaxScanner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8cc5669e3cfb4d4e9698616c7cc6b83e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeRegularStringLiteralUnescaper.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeRegularStringLiteralUnescaper.cs new file mode 100644 index 0000000000..9096c30b7d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeRegularStringLiteralUnescaper.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Unescapes regular C# string literal tokens into their decoded text values. + /// + internal static class DynamicCodeRegularStringLiteralUnescaper + { + private static readonly Dictionary RegularStringEscapes = new() + { + { '\'', '\'' }, + { '"', '"' }, + { '\\', '\\' }, + { '0', '\0' }, + { 'a', '\a' }, + { 'b', '\b' }, + { 'f', '\f' }, + { 'n', '\n' }, + { 'r', '\r' }, + { 't', '\t' }, + { 'v', '\v' } + }; + + internal static void AdvanceEscapedLiteralSequence(string source, ref int index) + { + index++; + if (index >= source.Length) + { + return; + } + + char escape = source[index]; + index++; + + switch (escape) + { + case 'u': + index = Math.Min(source.Length, index + 4); + break; + case 'U': + index = Math.Min(source.Length, index + 8); + break; + case 'x': + index = AdvanceVariableLengthHexDigits(source, index, 4); + break; + } + } + + private static int AdvanceVariableLengthHexDigits(string value, int index, int maxDigits) + { + int digitsConsumed = 0; + while (index < value.Length && digitsConsumed < maxDigits && IsHexDigit(value[index])) + { + index++; + digitsConsumed++; + } + + return index; + } + + internal static bool TryUnescapeRegularStringLiteral(string token, out string value) + { + string inner = token.Substring(1, token.Length - 2); + StringBuilder unescaped = new(inner.Length); + + for (int index = 0; index < inner.Length; index++) + { + char current = inner[index]; + if (current != '\\') + { + unescaped.Append(current); + continue; + } + + if (!TryAppendRegularStringEscape(inner, ref index, unescaped)) + { + value = null; + return false; + } + } + + value = unescaped.ToString(); + return true; + } + + private static bool TryAppendRegularStringEscape( + string inner, + ref int index, + StringBuilder unescaped) + { + if (index + 1 >= inner.Length) + { + return false; + } + + index++; + char escape = inner[index]; + if (RegularStringEscapes.ContainsKey(escape)) + { + unescaped.Append(RegularStringEscapes[escape]); + return true; + } + + return TryAppendComplexRegularStringEscape(inner, ref index, unescaped, escape); + } + + private static bool TryAppendComplexRegularStringEscape( + string inner, + ref int index, + StringBuilder unescaped, + char escape) + { + switch (escape) + { + case 'u': + return TryAppendUnicodeEscape(inner, ref index, unescaped); + case 'U': + return TryAppendUtf32Escape(inner, ref index, unescaped); + case 'x': + return TryAppendVariableLengthHexEscape(inner, ref index, unescaped); + default: + return false; + } + } + + private static bool TryAppendUnicodeEscape( + string inner, + ref int index, + StringBuilder unescaped) + { + if (!TryParseHexDigits(inner, ref index, 4, out int unicodeValue)) + { + return false; + } + + unescaped.Append((char)unicodeValue); + return true; + } + + private static bool TryAppendUtf32Escape( + string inner, + ref int index, + StringBuilder unescaped) + { + if (!TryParseHexDigits(inner, ref index, 8, out int codePoint) || + !IsValidUtf32CodePoint(codePoint)) + { + return false; + } + + unescaped.Append(char.ConvertFromUtf32(codePoint)); + return true; + } + + private static bool TryAppendVariableLengthHexEscape( + string inner, + ref int index, + StringBuilder unescaped) + { + if (!TryParseVariableLengthHexDigits(inner, ref index, out int variableLengthValue)) + { + return false; + } + + unescaped.Append((char)variableLengthValue); + return true; + } + + private static bool TryParseHexDigits(string value, ref int index, int digitCount, out int parsedValue) + { + int start = index + 1; + if (start + digitCount > value.Length) + { + parsedValue = 0; + return false; + } + + string hex = value.Substring(start, digitCount); + if (!int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsedValue)) + { + return false; + } + + index += digitCount; + return true; + } + + private static bool TryParseVariableLengthHexDigits(string value, ref int index, out int parsedValue) + { + int start = index + 1; + int end = AdvanceVariableLengthHexDigits(value, start, 4); + if (end == start) + { + parsedValue = 0; + return false; + } + + string hex = value.Substring(start, end - start); + if (!int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsedValue)) + { + return false; + } + + index = end - 1; + return true; + } + + private static bool IsValidUtf32CodePoint(int value) + { + return value >= 0 + && value <= 0x10FFFF + && (value < 0xD800 || value > 0xDFFF); + } + + private static bool IsHexDigit(char value) + { + return (value >= '0' && value <= '9') + || (value >= 'a' && value <= 'f') + || (value >= 'A' && value <= 'F'); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeRegularStringLiteralUnescaper.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeRegularStringLiteralUnescaper.cs.meta new file mode 100644 index 0000000000..805d456920 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeRegularStringLiteralUnescaper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b3f43db33fa47e38cd96d4e15f1a27c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs index 92d6ef7bcd..757da064e1 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs @@ -17,126 +17,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class SharedRoslynCompilerWorkerHost { private const int SharedCompilerWorkerMaxAttempts = 2; - private const string RoslynWorkerSourceFileName = "RoslynCompilerWorker.cs"; - private const string RoslynWorkerAssemblyFileName = "RoslynCompilerWorker.dll"; - private const string RoslynWorkerCompileResponseFileName = "RoslynCompilerWorker.rsp"; private static readonly SharedRoslynCompilerWorkerSession ServiceValue = new(); - /// - /// Carries the result data produced by Worker Attempt behavior. - /// - private sealed class WorkerAttemptResult - { - public CompilerMessage[] Messages { get; } - - public bool ShouldRetry { get; } - - public string FailureReason { get; } - - public object FailureContext { get; } - - private WorkerAttemptResult( - CompilerMessage[] messages, - bool shouldRetry, - string failureReason, - object failureContext) - { - Messages = messages; - ShouldRetry = shouldRetry; - FailureReason = failureReason; - FailureContext = failureContext; - } - - public bool Succeeded => Messages != null; - - public static WorkerAttemptResult Successful(CompilerMessage[] messages) - { - return new WorkerAttemptResult(messages, false, null, null); - } - - public static WorkerAttemptResult RetryableFailure(string failureReason, object failureContext) - { - return new WorkerAttemptResult(null, true, failureReason, failureContext); - } - - public static WorkerAttemptResult NonRetryableFailure(string failureReason, object failureContext) - { - return new WorkerAttemptResult(null, false, failureReason, failureContext); - } - } - - /// - /// Carries the result data produced by Worker Startup behavior. - /// - private sealed class WorkerStartupResult - { - public bool IsReady { get; } - - public bool IsRetryable { get; } - - public string FailureReason { get; } - - public object FailureContext { get; } - - private WorkerStartupResult( - bool isReady, - bool isRetryable, - string failureReason, - object failureContext) - { - IsReady = isReady; - IsRetryable = isRetryable; - FailureReason = failureReason; - FailureContext = failureContext; - } - - public static WorkerStartupResult Ready() - { - return new WorkerStartupResult(true, false, null, null); - } - - public static WorkerStartupResult Failure(string failureReason, object failureContext) - { - return new WorkerStartupResult(false, true, failureReason, failureContext); - } - - public static WorkerStartupResult ClosedLifecycleFailure() - { - return new WorkerStartupResult( - false, - false, - "shared_worker_lifecycle_closed", - new { reason = "lifecycle_generation_advanced" }); - } - } - - /// - /// Provides Worker Paths behavior for Unity CLI Loop. - /// - private sealed class WorkerPaths - { - public string DirectoryPath { get; } - - public string SourcePath { get; } - - public string AssemblyPath { get; } - - public string CompileResponseFilePath { get; } - - public WorkerPaths( - string directoryPath, - string sourcePath, - string assemblyPath, - string compileResponseFilePath) - { - DirectoryPath = directoryPath; - SourcePath = sourcePath; - AssemblyPath = assemblyPath; - CompileResponseFilePath = compileResponseFilePath; - } - } - internal static void RegisterLifecycleForEditorStartup() { AssemblyReloadEvents.beforeAssemblyReload -= ShutdownForReload; @@ -216,7 +99,8 @@ private static async Task TryCompileOnceAsync( Action markBuildFinished, Action incrementBuildCount) { - WorkerStartupResult startupResult = await EnsureWorkerReadyAsync( + WorkerStartupResult startupResult = await SharedRoslynCompilerWorkerHostProcess.EnsureWorkerReadyAsync( + ServiceValue, externalCompilerPaths, lifecycleGenerationAtStart).ConfigureAwait(false); if (!startupResult.IsReady) @@ -241,117 +125,6 @@ private static async Task TryCompileOnceAsync( incrementBuildCount).ConfigureAwait(false); } - private static async Task EnsureWorkerReadyAsync( - ExternalCompilerPaths externalCompilerPaths, - int lifecycleGenerationAtStart) - { - WorkerStartupResult earlyResult = ServiceValue.ExecuteWithStateLock(() => - { - if (!ServiceValue.IsLifecycleGenerationCurrentLocked(lifecycleGenerationAtStart)) - { - return WorkerStartupResult.ClosedLifecycleFailure(); - } - - if (ServiceValue.HasLiveProcessLocked()) - { - return WorkerStartupResult.Ready(); - } - - return null; - }); - if (earlyResult != null) - { - return earlyResult; - } - - WorkerPaths workerPaths = CreateWorkerPaths(); - SynchronizeWorkerSource(workerPaths); - - WorkerStartupResult workerAssemblyResult = await EnsureWorkerAssemblyBuiltAsync( - externalCompilerPaths, - workerPaths).ConfigureAwait(false); - if (!workerAssemblyResult.IsReady) - { - return workerAssemblyResult; - } - - return StartWorkerProcess( - externalCompilerPaths, - workerPaths, - lifecycleGenerationAtStart); - } - - private static async Task EnsureWorkerAssemblyBuiltAsync( - ExternalCompilerPaths externalCompilerPaths, - WorkerPaths workerPaths) - { - if (File.Exists(workerPaths.AssemblyPath)) - { - return WorkerStartupResult.Ready(); - } - - // Why outside state lock: worker DLL compile can take seconds; shutdown must still kill - // an already-running shared worker without waiting on this build. - // Why Task.Run (via CompileWorkerAssemblyAsync): WaitForExit(timeout) is synchronous and - // this path can still run on the Unity main thread before the first await when the - // compile gate is acquired without yielding. - SharedRoslynCompilerWorkerAssemblyBuilder.WorkerAssemblyBuildResult buildResult = - await ServiceValue.CompileWorkerAssemblyAsync( - externalCompilerPaths, - workerPaths.SourcePath, - workerPaths.AssemblyPath, - workerPaths.CompileResponseFilePath).ConfigureAwait(false); - if (!buildResult.StartedSuccessfully) - { - return WorkerStartupResult.Failure( - buildResult.FailureReason, - buildResult.FailureContext); - } - - if (!HasErrors(buildResult.Messages)) - { - return WorkerStartupResult.Ready(); - } - - SharedRoslynCompilerWorkerAssemblyBuilder.DeleteWorkerAssemblyIfPresent(workerPaths.AssemblyPath); - return WorkerStartupResult.Failure( - "worker_build_failed", - new - { - first_error = FindFirstErrorMessage(buildResult.Messages), - worker_source_path = workerPaths.SourcePath - }); - } - - private static WorkerStartupResult StartWorkerProcess( - ExternalCompilerPaths externalCompilerPaths, - WorkerPaths workerPaths, - int lifecycleGenerationAtStart) - { - ProcessStartInfo startInfo = CreateWorkerStartInfo(externalCompilerPaths, workerPaths); - return ServiceValue.ExecuteWithStateLock(() => - { - if (!ServiceValue.IsLifecycleGenerationCurrentLocked(lifecycleGenerationAtStart)) - { - return WorkerStartupResult.ClosedLifecycleFailure(); - } - - bool started = ServiceValue.StartProcessLocked(startInfo); - if (!started) - { - return WorkerStartupResult.Failure( - "worker_start_failed", - new - { - dotnet_host_path = externalCompilerPaths.DotnetHostPath, - worker_assembly_path = workerPaths.AssemblyPath - }); - } - - return WorkerStartupResult.Ready(); - }); - } - private static async Task InvokeWorkerOnceAsync( string requestFilePath, CancellationToken ct, @@ -461,91 +234,6 @@ private static async Task ReadWorkerResponseAsync( return WorkerAttemptResult.Successful(compilerMessages); } - private static WorkerPaths CreateWorkerPaths() - { - string workerDirectoryPath = GetWorkerDirectoryPath(); - Directory.CreateDirectory(workerDirectoryPath); - ServiceValue.ExecuteWithStateLock( - () => ServiceValue.RecordWorkerDirectoryLocked(workerDirectoryPath)); - return new WorkerPaths( - workerDirectoryPath, - Path.Combine(workerDirectoryPath, RoslynWorkerSourceFileName), - Path.Combine(workerDirectoryPath, RoslynWorkerAssemblyFileName), - Path.Combine(workerDirectoryPath, RoslynWorkerCompileResponseFileName)); - } - - private static string GetWorkerDirectoryPath() - { - return Path.Combine( - Path.GetTempPath(), - "UnityCliLoopCompilation", - $"RoslynWorker-{Process.GetCurrentProcess().Id}"); - } - - private static void SynchronizeWorkerSource(WorkerPaths workerPaths) - { - string workerSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); - if (File.Exists(workerPaths.SourcePath) && File.ReadAllText(workerPaths.SourcePath) == workerSource) - { - return; - } - - File.WriteAllText(workerPaths.SourcePath, workerSource); - if (File.Exists(workerPaths.AssemblyPath)) - { - File.Delete(workerPaths.AssemblyPath); - } - } - - private static ProcessStartInfo CreateWorkerStartInfo( - ExternalCompilerPaths externalCompilerPaths, - WorkerPaths workerPaths) - { - ProcessStartInfo startInfo = new() - { - FileName = externalCompilerPaths.DotnetHostPath, - Arguments = "exec" - + " --runtimeconfig " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(externalCompilerPaths.CompilerRuntimeConfigPath) - + " --depsfile " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(externalCompilerPaths.CompilerDepsFilePath) - + " " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(workerPaths.AssemblyPath), - WorkingDirectory = workerPaths.DirectoryPath, - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = false, - CreateNoWindow = true - }; - - SharedRoslynCompilerWorkerAssemblyBuilder.ConfigureWorkerDotnetRuntimeEnvironment(startInfo); - return startInfo; - } - - private static bool HasErrors(IReadOnlyCollection messages) - { - foreach (CompilerMessage message in messages) - { - if (message.type == CompilerMessageType.Error) - { - return true; - } - } - - return false; - } - - private static string FindFirstErrorMessage(IReadOnlyCollection messages) - { - foreach (CompilerMessage message in messages) - { - if (message.type == CompilerMessageType.Error) - { - return message.message; - } - } - - return string.Empty; - } - private static void ShutdownForReload() { Shutdown(); @@ -585,7 +273,7 @@ internal static void SetResponseTimeoutMillisecondsForTests(int timeoutMilliseco private static void Shutdown() { - ServiceValue.Shutdown(GetWorkerDirectoryPath()); + ServiceValue.Shutdown(SharedRoslynCompilerWorkerHostProcess.GetWorkerDirectoryPath()); } private static WorkerAttemptResult CreateRetryableWorkerCommunicationFailure( diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs new file mode 100644 index 0000000000..9d88227b5a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs @@ -0,0 +1,247 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Provides worker process paths, source sync, assembly build, and startup for the shared Roslyn compiler worker. + /// + internal static class SharedRoslynCompilerWorkerHostProcess + { + private const string RoslynWorkerSourceFileName = "RoslynCompilerWorker.cs"; + private const string RoslynWorkerAssemblyFileName = "RoslynCompilerWorker.dll"; + private const string RoslynWorkerCompileResponseFileName = "RoslynCompilerWorker.rsp"; + + /// + /// Provides Worker Paths behavior for Unity CLI Loop. + /// + internal sealed class WorkerPaths + { + public string DirectoryPath { get; } + + public string SourcePath { get; } + + public string AssemblyPath { get; } + + public string CompileResponseFilePath { get; } + + public WorkerPaths( + string directoryPath, + string sourcePath, + string assemblyPath, + string compileResponseFilePath) + { + DirectoryPath = directoryPath; + SourcePath = sourcePath; + AssemblyPath = assemblyPath; + CompileResponseFilePath = compileResponseFilePath; + } + } + + internal static string GetWorkerDirectoryPath() + { + return Path.Combine( + Path.GetTempPath(), + "UnityCliLoopCompilation", + $"RoslynWorker-{Process.GetCurrentProcess().Id}"); + } + + internal static WorkerPaths CreateWorkerPaths(SharedRoslynCompilerWorkerSession session) + { + string workerDirectoryPath = GetWorkerDirectoryPath(); + Directory.CreateDirectory(workerDirectoryPath); + session.ExecuteWithStateLock( + () => session.RecordWorkerDirectoryLocked(workerDirectoryPath)); + return new WorkerPaths( + workerDirectoryPath, + Path.Combine(workerDirectoryPath, RoslynWorkerSourceFileName), + Path.Combine(workerDirectoryPath, RoslynWorkerAssemblyFileName), + Path.Combine(workerDirectoryPath, RoslynWorkerCompileResponseFileName)); + } + + internal static void SynchronizeWorkerSource(WorkerPaths workerPaths) + { + string workerSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); + if (File.Exists(workerPaths.SourcePath) && File.ReadAllText(workerPaths.SourcePath) == workerSource) + { + return; + } + + File.WriteAllText(workerPaths.SourcePath, workerSource); + if (File.Exists(workerPaths.AssemblyPath)) + { + File.Delete(workerPaths.AssemblyPath); + } + } + + internal static ProcessStartInfo CreateWorkerStartInfo( + ExternalCompilerPaths externalCompilerPaths, + WorkerPaths workerPaths) + { + ProcessStartInfo startInfo = new() + { + FileName = externalCompilerPaths.DotnetHostPath, + Arguments = "exec" + + " --runtimeconfig " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(externalCompilerPaths.CompilerRuntimeConfigPath) + + " --depsfile " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(externalCompilerPaths.CompilerDepsFilePath) + + " " + SharedRoslynCompilerWorkerAssemblyBuilder.QuoteCommandLineArgument(workerPaths.AssemblyPath), + WorkingDirectory = workerPaths.DirectoryPath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = false, + CreateNoWindow = true + }; + + SharedRoslynCompilerWorkerAssemblyBuilder.ConfigureWorkerDotnetRuntimeEnvironment(startInfo); + return startInfo; + } + + internal static async Task EnsureWorkerReadyAsync( + SharedRoslynCompilerWorkerSession session, + ExternalCompilerPaths externalCompilerPaths, + int lifecycleGenerationAtStart) + { + WorkerStartupResult earlyResult = session.ExecuteWithStateLock(() => + { + if (!session.IsLifecycleGenerationCurrentLocked(lifecycleGenerationAtStart)) + { + return WorkerStartupResult.ClosedLifecycleFailure(); + } + + if (session.HasLiveProcessLocked()) + { + return WorkerStartupResult.Ready(); + } + + return null; + }); + if (earlyResult != null) + { + return earlyResult; + } + + WorkerPaths workerPaths = CreateWorkerPaths(session); + SynchronizeWorkerSource(workerPaths); + + WorkerStartupResult workerAssemblyResult = await EnsureWorkerAssemblyBuiltAsync( + session, + externalCompilerPaths, + workerPaths).ConfigureAwait(false); + if (!workerAssemblyResult.IsReady) + { + return workerAssemblyResult; + } + + return StartWorkerProcess( + session, + externalCompilerPaths, + workerPaths, + lifecycleGenerationAtStart); + } + + internal static async Task EnsureWorkerAssemblyBuiltAsync( + SharedRoslynCompilerWorkerSession session, + ExternalCompilerPaths externalCompilerPaths, + WorkerPaths workerPaths) + { + if (File.Exists(workerPaths.AssemblyPath)) + { + return WorkerStartupResult.Ready(); + } + + // Why outside state lock: worker DLL compile can take seconds; shutdown must still kill + // an already-running shared worker without waiting on this build. + // Why Task.Run (via CompileWorkerAssemblyAsync): WaitForExit(timeout) is synchronous and + // this path can still run on the Unity main thread before the first await when the + // compile gate is acquired without yielding. + SharedRoslynCompilerWorkerAssemblyBuilder.WorkerAssemblyBuildResult buildResult = + await session.CompileWorkerAssemblyAsync( + externalCompilerPaths, + workerPaths.SourcePath, + workerPaths.AssemblyPath, + workerPaths.CompileResponseFilePath).ConfigureAwait(false); + if (!buildResult.StartedSuccessfully) + { + return WorkerStartupResult.Failure( + buildResult.FailureReason, + buildResult.FailureContext); + } + + if (!HasErrors(buildResult.Messages)) + { + return WorkerStartupResult.Ready(); + } + + SharedRoslynCompilerWorkerAssemblyBuilder.DeleteWorkerAssemblyIfPresent(workerPaths.AssemblyPath); + return WorkerStartupResult.Failure( + "worker_build_failed", + new + { + first_error = FindFirstErrorMessage(buildResult.Messages), + worker_source_path = workerPaths.SourcePath + }); + } + + internal static WorkerStartupResult StartWorkerProcess( + SharedRoslynCompilerWorkerSession session, + ExternalCompilerPaths externalCompilerPaths, + WorkerPaths workerPaths, + int lifecycleGenerationAtStart) + { + ProcessStartInfo startInfo = CreateWorkerStartInfo(externalCompilerPaths, workerPaths); + return session.ExecuteWithStateLock(() => + { + if (!session.IsLifecycleGenerationCurrentLocked(lifecycleGenerationAtStart)) + { + return WorkerStartupResult.ClosedLifecycleFailure(); + } + + bool started = session.StartProcessLocked(startInfo); + if (!started) + { + return WorkerStartupResult.Failure( + "worker_start_failed", + new + { + dotnet_host_path = externalCompilerPaths.DotnetHostPath, + worker_assembly_path = workerPaths.AssemblyPath + }); + } + + return WorkerStartupResult.Ready(); + }); + } + + private static bool HasErrors(IReadOnlyCollection messages) + { + foreach (CompilerMessage message in messages) + { + if (message.type == CompilerMessageType.Error) + { + return true; + } + } + + return false; + } + + private static string FindFirstErrorMessage(IReadOnlyCollection messages) + { + foreach (CompilerMessage message in messages) + { + if (message.type == CompilerMessageType.Error) + { + return message.message; + } + } + + return string.Empty; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs.meta new file mode 100644 index 0000000000..7c53c1d410 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 350910eb8ec944c6a51c98d4bf06e5c9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs new file mode 100644 index 0000000000..e6eadfe316 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs @@ -0,0 +1,92 @@ +using UnityEditor.Compilation; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Carries the result data produced by Worker Attempt behavior. + /// + internal sealed class WorkerAttemptResult + { + public CompilerMessage[] Messages { get; } + + public bool ShouldRetry { get; } + + public string FailureReason { get; } + + public object FailureContext { get; } + + private WorkerAttemptResult( + CompilerMessage[] messages, + bool shouldRetry, + string failureReason, + object failureContext) + { + Messages = messages; + ShouldRetry = shouldRetry; + FailureReason = failureReason; + FailureContext = failureContext; + } + + public bool Succeeded => Messages != null; + + public static WorkerAttemptResult Successful(CompilerMessage[] messages) + { + return new WorkerAttemptResult(messages, false, null, null); + } + + public static WorkerAttemptResult RetryableFailure(string failureReason, object failureContext) + { + return new WorkerAttemptResult(null, true, failureReason, failureContext); + } + + public static WorkerAttemptResult NonRetryableFailure(string failureReason, object failureContext) + { + return new WorkerAttemptResult(null, false, failureReason, failureContext); + } + } + + /// + /// Carries the result data produced by Worker Startup behavior. + /// + internal sealed class WorkerStartupResult + { + public bool IsReady { get; } + + public bool IsRetryable { get; } + + public string FailureReason { get; } + + public object FailureContext { get; } + + private WorkerStartupResult( + bool isReady, + bool isRetryable, + string failureReason, + object failureContext) + { + IsReady = isReady; + IsRetryable = isRetryable; + FailureReason = failureReason; + FailureContext = failureContext; + } + + public static WorkerStartupResult Ready() + { + return new WorkerStartupResult(true, false, null, null); + } + + public static WorkerStartupResult Failure(string failureReason, object failureContext) + { + return new WorkerStartupResult(false, true, failureReason, failureContext); + } + + public static WorkerStartupResult ClosedLifecycleFailure() + { + return new WorkerStartupResult( + false, + false, + "shared_worker_lifecycle_closed", + new { reason = "lifecycle_generation_advanced" }); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs.meta new file mode 100644 index 0000000000..02665f28d5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8656547f5b694b52ab83e26e76c8ab10 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceShaper.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceShaper.cs index e958654dc0..b3260b6b5f 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceShaper.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceShaper.cs @@ -36,7 +36,7 @@ public static SourceShapeResult Analyze(string source) } else { - pos = AdvanceInsideBlock(source, pos, ref braceDepth); + pos = SourceTokenScanner.AdvanceInsideBlock(source, pos, ref braceDepth); } } @@ -70,12 +70,12 @@ private static SourceTopLevelStep AnalyzeTopLevelSourceStep( int pos, int braceDepth) { - if (TryMatchLineComment(source, pos, out int afterComment)) + if (SourceTokenScanner.TryMatchLineComment(source, pos, out int afterComment)) { return new SourceTopLevelStep(afterComment, braceDepth); } - if (TryMatchBlockComment(source, pos, out int afterBlock)) + if (SourceTokenScanner.TryMatchBlockComment(source, pos, out int afterBlock)) { return new SourceTopLevelStep(afterBlock, braceDepth); } @@ -100,7 +100,7 @@ private static SourceTopLevelStep AnalyzeTopLevelSourceStep( } int segmentStart = pos; - int afterUsing = SkipWhitespaceAndComments(source, pos + 5); + int afterUsing = SourceTokenScanner.SkipWhitespaceAndComments(source, pos + 5); if (StartsWithKeyword(source, afterUsing, "static")) { return AddUsingDirectiveStep(source, segmentStart, braceDepth, result); @@ -121,7 +121,7 @@ private static SourceTopLevelStep AnalyzeTopLevelSourceStep( int braceDepth, SourceShapeResult result) { - int usingPos = SkipWhitespaceAndComments(source, pos + 6); + int usingPos = SourceTokenScanner.SkipWhitespaceAndComments(source, pos + 6); if (!StartsWithKeyword(source, usingPos, "using")) { return null; @@ -136,7 +136,7 @@ private static SourceTopLevelStep AddUsingDirectiveStep( int braceDepth, SourceShapeResult result) { - int semiEnd = FindSemicolon(source, segmentStart); + int semiEnd = SourceTokenScanner.FindSemicolon(source, segmentStart); RegisterUsingDirective(result, source, segmentStart, semiEnd); return new SourceTopLevelStep(semiEnd + 1, braceDepth); } @@ -163,7 +163,7 @@ private static string ExtractUsingAliasName(string source, int segmentStart, int int position = segmentStart; if (StartsWithKeyword(source, position, "global")) { - position = SkipWhitespaceAndComments(source, position + "global".Length); + position = SourceTokenScanner.SkipWhitespaceAndComments(source, position + "global".Length); } if (!StartsWithKeyword(source, position, "using")) @@ -171,7 +171,7 @@ private static string ExtractUsingAliasName(string source, int segmentStart, int return null; } - position = SkipWhitespaceAndComments(source, position + "using".Length); + position = SourceTokenScanner.SkipWhitespaceAndComments(source, position + "using".Length); if (StartsWithKeyword(source, position, "static")) { return null; @@ -183,7 +183,7 @@ private static string ExtractUsingAliasName(string source, int segmentStart, int return null; } - int equalsPosition = SkipWhitespaceAndComments(source, aliasName.EndPosition); + int equalsPosition = SourceTokenScanner.SkipWhitespaceAndComments(source, aliasName.EndPosition); if (equalsPosition > semiEnd || source[equalsPosition] != '=') { return null; @@ -225,29 +225,6 @@ private static bool IsIdentifierPart(char value) return char.IsLetterOrDigit(value) || value == '_'; } - private static int SkipWhitespaceAndComments(string source, int pos) - { - int current = SkipWhitespace(source, pos); - while (true) - { - if (TryMatchLineComment(source, current, out int afterLine)) - { - current = SkipWhitespace(source, afterLine); - continue; - } - - if (TryMatchBlockComment(source, current, out int afterBlock)) - { - current = SkipWhitespace(source, afterBlock); - continue; - } - - break; - } - - return current; - } - private static SourceTopLevelStep? TryAnalyzeTopLevelDeclaration( string source, int pos, @@ -281,7 +258,7 @@ private static int SkipWhitespaceAndComments(string source, int pos) return null; } - int afterAttr = SkipAttributeBlock(source, pos); + int afterAttr = SourceTokenScanner.SkipAttributeBlock(source, pos); int nextNonWs = SkipWhitespace(source, afterAttr); int declarationStart = SkipAccessModifiers(source, nextNonWs); if (declarationStart >= source.Length || !IsTypeDeclarationKeyword(source, declarationStart)) @@ -322,7 +299,7 @@ private static SourceTopLevelStep AnalyzeTopLevelStatement( { result.HasTopLevelStatements = true; int nextBraceDepth = braceDepth; - int stmtEnd = FindStatementEnd(source, pos, ref nextBraceDepth); + int stmtEnd = SourceTokenScanner.FindStatementEnd(source, pos, ref nextBraceDepth); int originalLineNumber1Based = GetLineNumber1Based(source, pos); PadTopLevelBodyBuilderToOriginalLine(result, originalLineNumber1Based); string statementText = source.Substring(pos, stmtEnd - pos + 1).TrimEnd(); @@ -378,7 +355,7 @@ private static int CountLinesInText(string text) private static SourceTopLevelStep SkipTopLevelBlock(string source, int pos, int braceDepth) { int nextBraceDepth = braceDepth; - int nextPosition = SkipBlock(source, pos, ref nextBraceDepth); + int nextPosition = SourceTokenScanner.SkipBlock(source, pos, ref nextBraceDepth); return new SourceTopLevelStep(nextPosition, nextBraceDepth); } @@ -471,380 +448,9 @@ private static int SkipAccessModifiers(string s, int pos) return pos; } - private static bool TryMatchLineComment(string s, int pos, out int afterComment) - { - afterComment = pos; - if (pos + 1 < s.Length && s[pos] == '/' && s[pos + 1] == '/') - { - int end = pos + 2; - while (end < s.Length && s[end] != '\n') end++; - if (end < s.Length) end++; // skip \n - afterComment = end; - return true; - } - return false; - } - - private static bool TryMatchBlockComment(string s, int pos, out int afterBlock) - { - afterBlock = pos; - if (pos + 1 < s.Length && s[pos] == '/' && s[pos + 1] == '*') - { - int end = pos + 2; - while (end + 1 < s.Length && !(s[end] == '*' && s[end + 1] == '/')) end++; - afterBlock = end + 2 < s.Length ? end + 2 : s.Length; - return true; - } - return false; - } - - private static int FindSemicolon(string s, int pos) - { - while (pos < s.Length) - { - if (s[pos] == ';') return pos; - pos = AdvanceOneToken(s, pos); - } - return s.Length - 1; - } - - private static int FindStatementEnd(string s, int pos, ref int braceDepth) - { - while (pos < s.Length) - { - char c = s[pos]; - if (c == '{') - { - braceDepth++; - pos++; - while (pos < s.Length && braceDepth > 0) - { - pos = AdvanceInsideBlock(s, pos, ref braceDepth); - } - return pos - 1; - } - if (c == ';') return pos; - pos = AdvanceOneToken(s, pos); - } - return s.Length - 1; - } - - private static int SkipBlock(string s, int pos, ref int braceDepth) - { - // Advance past keywords until we hit the opening brace - while (pos < s.Length && s[pos] != '{') - { - if (s[pos] == ';') return pos + 1; // forward declaration - pos = AdvanceOneToken(s, pos); - } - if (pos < s.Length && s[pos] == '{') - { - braceDepth++; - pos++; - while (pos < s.Length && braceDepth > 0) - { - pos = AdvanceInsideBlock(s, pos, ref braceDepth); - } - } - return pos; - } - - private static int SkipAttributeBlock(string s, int pos) - { - Debug.Assert(s[pos] == '[', "SkipAttributeBlock must start at '['"); - int depth = 1; - pos++; - while (pos < s.Length && depth > 0) - { - if (s[pos] == '[') depth++; - else if (s[pos] == ']') depth--; - else pos = AdvanceOneToken(s, pos) - 1; // -1 because loop will pos++ via fallthrough - pos++; - } - return pos; - } - - private static int AdvanceInsideBlock(string s, int pos, ref int braceDepth) - { - char c = s[pos]; - if (c == '{') { braceDepth++; return pos + 1; } - if (c == '}') { braceDepth--; return pos + 1; } - return AdvanceOneToken(s, pos); - } - internal static int AdvanceOneTokenPublic(string s, int pos) { - return AdvanceOneToken(s, pos); - } - - private static int AdvanceOneToken(string s, int pos) - { - if (pos >= s.Length) return s.Length; - - (bool matched, int nextPosition) = TryAdvanceLineComment(s, pos); - if (matched) return nextPosition; - (matched, nextPosition) = TryAdvanceBlockComment(s, pos); - if (matched) return nextPosition; - (matched, nextPosition) = TryAdvanceVerbatimString(s, pos); - if (matched) return nextPosition; - (matched, nextPosition) = TryAdvanceRawString(s, pos); - if (matched) return nextPosition; - (matched, nextPosition) = TryAdvanceRegularString(s, pos); - if (matched) return nextPosition; - (matched, nextPosition) = TryAdvanceCharLiteral(s, pos); - if (matched) return nextPosition; - (matched, nextPosition) = TryAdvanceInterpolatedString(s, pos); - if (matched) return nextPosition; - - return pos + 1; - } - - private static (bool Matched, int NextPosition) TryAdvanceLineComment(string s, int pos) - { - if (pos + 1 >= s.Length || s[pos] != '/' || s[pos + 1] != '/') - { - return (false, pos); - } - - int end = pos + 2; - while (end < s.Length && s[end] != '\n') end++; - return (true, end < s.Length ? end + 1 : s.Length); - } - - private static (bool Matched, int NextPosition) TryAdvanceBlockComment(string s, int pos) - { - if (pos + 1 >= s.Length || s[pos] != '/' || s[pos + 1] != '*') - { - return (false, pos); - } - - int end = pos + 2; - while (end + 1 < s.Length && !(s[end] == '*' && s[end + 1] == '/')) end++; - return (true, end + 2 < s.Length ? end + 2 : s.Length); - } - - private static (bool Matched, int NextPosition) TryAdvanceVerbatimString(string s, int pos) - { - if (pos + 1 >= s.Length || s[pos] != '@' || s[pos + 1] != '"') - { - return (false, pos); - } - - return (true, SkipVerbatimString(s, pos + 2)); - } - - private static (bool Matched, int NextPosition) TryAdvanceRawString(string s, int pos) - { - if (pos + 2 >= s.Length || s[pos] != '"' || s[pos + 1] != '"' || s[pos + 2] != '"') - { - return (false, pos); - } - - return (true, SkipRawString(s, pos + 3)); - } - - private static (bool Matched, int NextPosition) TryAdvanceRegularString(string s, int pos) - { - if (s[pos] != '"') - { - return (false, pos); - } - - return (true, SkipRegularString(s, pos + 1)); - } - - private static (bool Matched, int NextPosition) TryAdvanceCharLiteral(string s, int pos) - { - if (s[pos] != '\'') - { - return (false, pos); - } - - return (true, SkipCharLiteral(s, pos + 1)); - } - - private static (bool Matched, int NextPosition) TryAdvanceInterpolatedString(string s, int pos) - { - if (pos + 1 >= s.Length || s[pos] != '$' || s[pos + 1] != '"') - { - return (false, pos); - } - - return (true, SkipInterpolatedString(s, pos + 2)); - } - - private static int SkipInterpolatedString(string s, int pos) - { - int end = pos; - while (end < s.Length) - { - if (s[end] == '\\') - { - end += 2; - continue; - } - - if (s[end] == '{') - { - if (end + 1 < s.Length && s[end + 1] == '{') - { - end += 2; - continue; - } - - end = SkipInterpolationHole(s, end + 1); - continue; - } - - if (s[end] == '"' && end + 1 < s.Length && s[end + 1] == '"') - { - end += 2; - continue; - } - - if (s[end] == '"') - { - return end + 1; - } - - end++; - } - - return s.Length; - } - - private static int SkipInterpolationHole(string s, int pos) - { - int depth = 1; - int end = pos; - - while (end < s.Length && depth > 0) - { - int afterLiteral = SkipInterpolationHoleLiteral(s, end); - if (afterLiteral != end) - { - end = afterLiteral; - continue; - } - - if (s[end] == '{') - { - depth++; - end++; - continue; - } - - if (s[end] == '}') - { - depth--; - end++; - continue; - } - - end++; - } - - return end; - } - - private static int SkipInterpolationHoleLiteral(string s, int end) - { - if (s[end] == '\\') - { - return end + 2; - } - - if (s[end] == '@' && end + 1 < s.Length && s[end + 1] == '"') - { - return SkipVerbatimString(s, end + 2); - } - - if (s[end] == '$' && end + 1 < s.Length && s[end + 1] == '"') - { - return SkipInterpolatedString(s, end + 2); - } - - if (s[end] == '"' && end + 2 < s.Length && s[end + 1] == '"' && s[end + 2] == '"') - { - return SkipRawString(s, end + 3); - } - - if (s[end] == '"') - { - return SkipRegularString(s, end + 1); - } - - return s[end] == '\'' ? SkipCharLiteral(s, end + 1) : end; - } - - private static int SkipRegularString(string s, int pos) - { - int end = pos; - while (end < s.Length && s[end] != '"') - { - if (s[end] == '\\') - { - end++; - } - - end++; - } - - return end < s.Length ? end + 1 : s.Length; - } - - private static int SkipVerbatimString(string s, int pos) - { - int end = pos; - while (end < s.Length) - { - if (s[end] == '"') - { - if (end + 1 < s.Length && s[end + 1] == '"') - { - end += 2; - continue; - } - - return end + 1; - } - - end++; - } - - return s.Length; - } - - private static int SkipRawString(string s, int pos) - { - int end = pos; - while (end + 2 < s.Length) - { - if (s[end] == '"' && s[end + 1] == '"' && s[end + 2] == '"') - { - return end + 3; - } - - end++; - } - - return s.Length; - } - - private static int SkipCharLiteral(string s, int pos) - { - int end = pos; - while (end < s.Length && s[end] != '\'') - { - if (s[end] == '\\') - { - end++; - } - - end++; - } - - return end < s.Length ? end + 1 : s.Length; + return SourceTokenScanner.AdvanceOneToken(s, pos); } private readonly struct SourceTopLevelStep diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs new file mode 100644 index 0000000000..2b6310867a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs @@ -0,0 +1,404 @@ +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Low-level C# source token scanner for skipping strings, comments, and brace-delimited regions. + /// + internal static class SourceTokenScanner + { + internal static int SkipWhitespaceAndComments(string source, int pos) + { + int current = SourceShaper.SkipWhitespace(source, pos); + while (true) + { + if (TryMatchLineComment(source, current, out int afterLine)) + { + current = SourceShaper.SkipWhitespace(source, afterLine); + continue; + } + + if (TryMatchBlockComment(source, current, out int afterBlock)) + { + current = SourceShaper.SkipWhitespace(source, afterBlock); + continue; + } + + break; + } + + return current; + } + + internal static bool TryMatchLineComment(string s, int pos, out int afterComment) + { + afterComment = pos; + if (pos + 1 < s.Length && s[pos] == '/' && s[pos + 1] == '/') + { + int end = pos + 2; + while (end < s.Length && s[end] != '\n') end++; + if (end < s.Length) end++; // skip \n + afterComment = end; + return true; + } + return false; + } + + internal static bool TryMatchBlockComment(string s, int pos, out int afterBlock) + { + afterBlock = pos; + if (pos + 1 < s.Length && s[pos] == '/' && s[pos + 1] == '*') + { + int end = pos + 2; + while (end + 1 < s.Length && !(s[end] == '*' && s[end + 1] == '/')) end++; + afterBlock = end + 2 < s.Length ? end + 2 : s.Length; + return true; + } + return false; + } + + internal static int FindSemicolon(string s, int pos) + { + while (pos < s.Length) + { + if (s[pos] == ';') return pos; + pos = AdvanceOneToken(s, pos); + } + return s.Length - 1; + } + + internal static int FindStatementEnd(string s, int pos, ref int braceDepth) + { + while (pos < s.Length) + { + char c = s[pos]; + if (c == '{') + { + braceDepth++; + pos++; + while (pos < s.Length && braceDepth > 0) + { + pos = AdvanceInsideBlock(s, pos, ref braceDepth); + } + return pos - 1; + } + if (c == ';') return pos; + pos = AdvanceOneToken(s, pos); + } + return s.Length - 1; + } + + internal static int SkipBlock(string s, int pos, ref int braceDepth) + { + // Advance past keywords until we hit the opening brace + while (pos < s.Length && s[pos] != '{') + { + if (s[pos] == ';') return pos + 1; // forward declaration + pos = AdvanceOneToken(s, pos); + } + if (pos < s.Length && s[pos] == '{') + { + braceDepth++; + pos++; + while (pos < s.Length && braceDepth > 0) + { + pos = AdvanceInsideBlock(s, pos, ref braceDepth); + } + } + return pos; + } + + internal static int SkipAttributeBlock(string s, int pos) + { + Debug.Assert(s[pos] == '[', "SkipAttributeBlock must start at '['"); + int depth = 1; + pos++; + while (pos < s.Length && depth > 0) + { + if (s[pos] == '[') depth++; + else if (s[pos] == ']') depth--; + else pos = AdvanceOneToken(s, pos) - 1; // -1 because loop will pos++ via fallthrough + pos++; + } + return pos; + } + + internal static int AdvanceInsideBlock(string s, int pos, ref int braceDepth) + { + char c = s[pos]; + if (c == '{') { braceDepth++; return pos + 1; } + if (c == '}') { braceDepth--; return pos + 1; } + return AdvanceOneToken(s, pos); + } + + internal static int AdvanceOneToken(string s, int pos) + { + if (pos >= s.Length) return s.Length; + + (bool matched, int nextPosition) = TryAdvanceLineComment(s, pos); + if (matched) return nextPosition; + (matched, nextPosition) = TryAdvanceBlockComment(s, pos); + if (matched) return nextPosition; + (matched, nextPosition) = TryAdvanceVerbatimString(s, pos); + if (matched) return nextPosition; + (matched, nextPosition) = TryAdvanceRawString(s, pos); + if (matched) return nextPosition; + (matched, nextPosition) = TryAdvanceRegularString(s, pos); + if (matched) return nextPosition; + (matched, nextPosition) = TryAdvanceCharLiteral(s, pos); + if (matched) return nextPosition; + (matched, nextPosition) = TryAdvanceInterpolatedString(s, pos); + if (matched) return nextPosition; + + return pos + 1; + } + + private static (bool Matched, int NextPosition) TryAdvanceLineComment(string s, int pos) + { + if (pos + 1 >= s.Length || s[pos] != '/' || s[pos + 1] != '/') + { + return (false, pos); + } + + int end = pos + 2; + while (end < s.Length && s[end] != '\n') end++; + return (true, end < s.Length ? end + 1 : s.Length); + } + + private static (bool Matched, int NextPosition) TryAdvanceBlockComment(string s, int pos) + { + if (pos + 1 >= s.Length || s[pos] != '/' || s[pos + 1] != '*') + { + return (false, pos); + } + + int end = pos + 2; + while (end + 1 < s.Length && !(s[end] == '*' && s[end + 1] == '/')) end++; + return (true, end + 2 < s.Length ? end + 2 : s.Length); + } + + private static (bool Matched, int NextPosition) TryAdvanceVerbatimString(string s, int pos) + { + if (pos + 1 >= s.Length || s[pos] != '@' || s[pos + 1] != '"') + { + return (false, pos); + } + + return (true, SkipVerbatimString(s, pos + 2)); + } + + private static (bool Matched, int NextPosition) TryAdvanceRawString(string s, int pos) + { + if (pos + 2 >= s.Length || s[pos] != '"' || s[pos + 1] != '"' || s[pos + 2] != '"') + { + return (false, pos); + } + + return (true, SkipRawString(s, pos + 3)); + } + + private static (bool Matched, int NextPosition) TryAdvanceRegularString(string s, int pos) + { + if (s[pos] != '"') + { + return (false, pos); + } + + return (true, SkipRegularString(s, pos + 1)); + } + + private static (bool Matched, int NextPosition) TryAdvanceCharLiteral(string s, int pos) + { + if (s[pos] != '\'') + { + return (false, pos); + } + + return (true, SkipCharLiteral(s, pos + 1)); + } + + private static (bool Matched, int NextPosition) TryAdvanceInterpolatedString(string s, int pos) + { + if (pos + 1 >= s.Length || s[pos] != '$' || s[pos + 1] != '"') + { + return (false, pos); + } + + return (true, SkipInterpolatedString(s, pos + 2)); + } + + private static int SkipInterpolatedString(string s, int pos) + { + int end = pos; + while (end < s.Length) + { + if (s[end] == '\\') + { + end += 2; + continue; + } + + if (s[end] == '{') + { + if (end + 1 < s.Length && s[end + 1] == '{') + { + end += 2; + continue; + } + + end = SkipInterpolationHole(s, end + 1); + continue; + } + + if (s[end] == '"' && end + 1 < s.Length && s[end + 1] == '"') + { + end += 2; + continue; + } + + if (s[end] == '"') + { + return end + 1; + } + + end++; + } + + return s.Length; + } + + private static int SkipInterpolationHole(string s, int pos) + { + int depth = 1; + int end = pos; + + while (end < s.Length && depth > 0) + { + int afterLiteral = SkipInterpolationHoleLiteral(s, end); + if (afterLiteral != end) + { + end = afterLiteral; + continue; + } + + if (s[end] == '{') + { + depth++; + end++; + continue; + } + + if (s[end] == '}') + { + depth--; + end++; + continue; + } + + end++; + } + + return end; + } + + private static int SkipInterpolationHoleLiteral(string s, int end) + { + if (s[end] == '\\') + { + return end + 2; + } + + if (s[end] == '@' && end + 1 < s.Length && s[end + 1] == '"') + { + return SkipVerbatimString(s, end + 2); + } + + if (s[end] == '$' && end + 1 < s.Length && s[end + 1] == '"') + { + return SkipInterpolatedString(s, end + 2); + } + + if (s[end] == '"' && end + 2 < s.Length && s[end + 1] == '"' && s[end + 2] == '"') + { + return SkipRawString(s, end + 3); + } + + if (s[end] == '"') + { + return SkipRegularString(s, end + 1); + } + + return s[end] == '\'' ? SkipCharLiteral(s, end + 1) : end; + } + + private static int SkipRegularString(string s, int pos) + { + int end = pos; + while (end < s.Length && s[end] != '"') + { + if (s[end] == '\\') + { + end++; + } + + end++; + } + + return end < s.Length ? end + 1 : s.Length; + } + + private static int SkipVerbatimString(string s, int pos) + { + int end = pos; + while (end < s.Length) + { + if (s[end] == '"') + { + if (end + 1 < s.Length && s[end + 1] == '"') + { + end += 2; + continue; + } + + return end + 1; + } + + end++; + } + + return s.Length; + } + + private static int SkipRawString(string s, int pos) + { + int end = pos; + while (end + 2 < s.Length) + { + if (s[end] == '"' && s[end + 1] == '"' && s[end + 2] == '"') + { + return end + 3; + } + + end++; + } + + return s.Length; + } + + private static int SkipCharLiteral(string s, int pos) + { + int end = pos; + while (end < s.Length && s[end] != '\'') + { + if (s[end] == '\\') + { + end++; + } + + end++; + } + + return end < s.Length ? end + 1 : s.Length; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs.meta new file mode 100644 index 0000000000..1673eda69e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f54478544d13434cac1dcbdfb53103c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 1eb65fab170f65387eef2958b1bb84b9b213e15d Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 17:31:58 +0900 Subject: [PATCH 3/3] Pin block-comment hoister paths and untangle SourceTokenScanner deps Add characterization coverage for terminated and unterminated block comments around the rewritten TryCopy* path, and move SkipWhitespace into SourceTokenScanner so scanning no longer depends back on SourceShaper. Co-authored-by: Cursor --- ...CodeLiteralHoisterCharacterizationTests.cs | 28 +++++++++++++++++++ .../DynamicCompilation/SourceShaper.cs | 5 +--- .../DynamicCompilation/SourceTokenScanner.cs | 15 ++++++++-- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs index 841aa5fd2d..622b6f19da 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterCharacterizationTests.cs @@ -177,6 +177,34 @@ public void Rewrite_WhenCommentPrecedesLiteral_ShouldPreserveCommentAndHoistLite Assert.That(result.Bindings[0].Value, Is.EqualTo(7)); } + /// + /// Pins block-comment copy path: comment text stays intact while a following integer still hoists. + /// + [Test] + public void Rewrite_WhenBlockCommentPrecedesLiteral_ShouldPreserveCommentAndHoistLiteral() + { + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite("/* c */ return 1;"); + + Assert.That(result.RewrittenSource, Is.EqualTo("/* c */ return __uloop_literal_0;")); + Assert.That(result.Bindings, Has.Count.EqualTo(1)); + Assert.That(result.Bindings[0].Value, Is.EqualTo(1)); + } + + /// + /// Pins unterminated block comments consuming the remainder of the source through EOF. + /// + [Test] + public void Rewrite_WhenUnterminatedBlockComment_ShouldTreatRemainderAsComment() + { + string source = "/* unterminated"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Is.EqualTo(source)); + Assert.That(result.Bindings, Is.Empty); + Assert.That(result.DeclarationLines, Is.Empty); + } + /// /// Pins mixed string and integer hoisting in a single expression. /// diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceShaper.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceShaper.cs index b3260b6b5f..800bc78139 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceShaper.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceShaper.cs @@ -391,10 +391,7 @@ public static string WrapIfNeeded(string source, string namespaceName, string cl internal static int SkipWhitespace(string s, int pos) { - Debug.Assert(s != null, "s must not be null"); - Debug.Assert(pos >= 0, "pos must be non-negative"); - while (pos < s.Length && char.IsWhiteSpace(s[pos])) pos++; - return pos; + return SourceTokenScanner.SkipWhitespace(s, pos); } internal static bool StartsWithKeyword(string s, int pos, string keyword) diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs index 2b6310867a..852e816555 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SourceTokenScanner.cs @@ -7,20 +7,29 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class SourceTokenScanner { + internal static int SkipWhitespace(string s, int pos) + { + Debug.Assert(s != null, "s must not be null"); + Debug.Assert(pos >= 0, "pos must be non-negative"); + while (pos < s.Length && char.IsWhiteSpace(s[pos])) pos++; + return pos; + } + internal static int SkipWhitespaceAndComments(string source, int pos) { - int current = SourceShaper.SkipWhitespace(source, pos); + // why: own SkipWhitespace so SourceTokenScanner does not depend back on SourceShaper + int current = SkipWhitespace(source, pos); while (true) { if (TryMatchLineComment(source, current, out int afterLine)) { - current = SourceShaper.SkipWhitespace(source, afterLine); + current = SkipWhitespace(source, afterLine); continue; } if (TryMatchBlockComment(source, current, out int afterBlock)) { - current = SourceShaper.SkipWhitespace(source, afterBlock); + current = SkipWhitespace(source, afterBlock); continue; }