From 392c6c2927fd7d5562feb65904a60711737f9cba Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 12 Jul 2026 16:02:36 +0900 Subject: [PATCH 1/2] Fix static local literal hoisting and document Color32 byte casts Skip literal hoisting inside static local function bodies so CS8421 no longer fires for inline constants, and add transpiler constraint hints plus SKILL.md guidance for int-to-byte failures after integer hoisting. Co-authored-by: Cursor --- .../uloop-execute-dynamic-code/SKILL.md | 5 + .../uloop-execute-dynamic-code/SKILL.md | 5 + ...ynamicCodeExecutionResponseFactoryTests.cs | 32 +++ .../DynamicCodeLiteralHoisterTests.cs | 36 ++++ ...namicCodeTranspilerConstraintHintsTests.cs | 41 ++++ ...CodeTranspilerConstraintHintsTests.cs.meta | 11 + .../DynamicCodeExecutionResponseFactory.cs | 13 ++ .../DynamicCodeLiteralHoister.cs | 83 +++++++- .../DynamicCodeTranspilerConstraintHints.cs | 46 ++++ ...namicCodeTranspilerConstraintHints.cs.meta | 11 + .../LiteralHoistScopeTracker.cs | 133 ++++++++++++ .../LiteralHoistScopeTracker.cs.meta | 11 + .../StaticLocalFunctionHeaderScanner.cs | 197 ++++++++++++++++++ .../StaticLocalFunctionHeaderScanner.cs.meta | 11 + .../ExecuteDynamicCode/Skill/SKILL.md | 5 + 15 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs.meta diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index d5d4edc3df..6d219e8cc0 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -32,6 +32,11 @@ return x; Prefer terminal commands for file operations and keep snippets focused on Unity Editor state that existing uloop tools cannot inspect or change. +## Known transpiler constraints + +- Static local functions cannot use hoisted literals (`__uloop_literal_N`). Keep literals inside static local function bodies as inline constants, or move helpers out of static local functions. +- Integer literals are hoisted as `int` values. APIs that require `byte` components (for example `new Color32(255, 0, 0, 255)`) need explicit casts such as `(byte)255` even when plain Unity scripts accept uncast numeric literals. + ## Shell Quoting - zsh/bash: single-quote the whole snippet so C# double quotes pass through unchanged: `--code 'return "hi";'`. For a single quote inside the snippet, close and reopen the shell string with `'\''`. diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index d5d4edc3df..6d219e8cc0 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -32,6 +32,11 @@ return x; Prefer terminal commands for file operations and keep snippets focused on Unity Editor state that existing uloop tools cannot inspect or change. +## Known transpiler constraints + +- Static local functions cannot use hoisted literals (`__uloop_literal_N`). Keep literals inside static local function bodies as inline constants, or move helpers out of static local functions. +- Integer literals are hoisted as `int` values. APIs that require `byte` components (for example `new Color32(255, 0, 0, 255)`) need explicit casts such as `(byte)255` even when plain Unity scripts accept uncast numeric literals. + ## Shell Quoting - zsh/bash: single-quote the whole snippet so C# double quotes pass through unchanged: `--code 'return "hi";'`. For a single quote inside the snippet, close and reopen the shell string with `'\''`. diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionResponseFactoryTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionResponseFactoryTests.cs index fc920c4566..6c53eb7d55 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionResponseFactoryTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionResponseFactoryTests.cs @@ -213,5 +213,37 @@ public void CancelledResult_WhenMapped_ReturnsNeutralCancellationResponse() Assert.That(response.CompilationErrors, Is.Empty); Assert.That(response.ErrorMessage, Is.EqualTo(UnityCliLoopConstants.ERROR_MESSAGE_EXECUTION_CANCELLED)); } + + /// + /// Verifies int-to-byte conversion failures include transpiler constraint guidance. + /// + [Test] + public void ConvertExecutionResultToResponse_WhenColor32ByteConversionFails_AddsTranspilerConstraintHint() + { + DynamicCodeExecutionResponseFactory factory = new(); + ExecutionResult result = new() + { + Success = false, + ErrorMessage = "Compilation error occurred", + CompilationErrors = new List + { + new CompilationError + { + ErrorCode = "CS1503", + Message = "CS1503: Argument 1: cannot convert from 'int' to 'byte'", + Line = 2, + Column = 20 + } + } + }; + + ExecuteDynamicCodeResponse response = factory.ConvertExecutionResultToResponse( + result, + "using UnityEngine;\nreturn new Color32(255, 0, 0, 255);"); + + Assert.That(response.Diagnostics[0].Hint, Does.Contain("Color32")); + Assert.That(response.Diagnostics[0].Suggestions, Contains.Item( + "Cast each component explicitly, for example: new Color32((byte)255, (byte)0, (byte)0, (byte)255).")); + } } } diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterTests.cs index 8fd222f37f..951112831a 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterTests.cs @@ -21,5 +21,41 @@ public void Rewrite_WhenInterpolatedHoleContainsNestedStringLiteral_ShouldKeepIn Assert.That(result.Bindings, Is.Empty); Assert.That(result.DeclarationLines, Is.Empty); } + + [Test] + public void Rewrite_WhenIntegerLiteralIsInsideStaticLocalFunction_ShouldKeepLiteralInline() + { + string source = @"int Compute(int value) +{ + static int Double(int x) => x * 2; + return Double(value); +} +return Compute(3);"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Does.Contain("x * 2")); + Assert.That(result.RewrittenSource, Does.Contain("return Compute(__uloop_literal_0)")); + Assert.That(result.RewrittenSource, Does.Not.Contain("x * __uloop_literal")); + Assert.That(result.Bindings, Has.Count.EqualTo(1)); + Assert.That(result.Bindings[0].Value, Is.EqualTo(3)); + } + + [Test] + public void Rewrite_WhenStringLiteralIsInsideStaticLocalFunctionBlockBody_ShouldKeepLiteralInline() + { + string source = @"string Build() +{ + static string Label() { return ""ok""; } + return Label(); +} +return Build();"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Does.Contain("return \"ok\"")); + Assert.That(result.RewrittenSource, Does.Not.Contain("__uloop_literal_0")); + Assert.That(result.Bindings, Is.Empty); + } } } diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs new file mode 100644 index 0000000000..81cd76ff68 --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs @@ -0,0 +1,41 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests +{ + /// + /// Test fixture that verifies transpiler constraint hint generation. + /// + [TestFixture] + public class DynamicCodeTranspilerConstraintHintsTests + { + [Test] + public void TryBuildHint_WhenCs8421ReferencesHoistedLiteral_ShouldReturnStaticLocalFunctionGuidance() + { + bool built = DynamicCodeTranspilerConstraintHints.TryBuildHint( + "CS8421", + "CS8421: A static local function cannot contain a reference to '__uloop_literal_0'.", + out string hint, + out string suggestion); + + Assert.That(built, Is.True); + Assert.That(hint, Does.Contain("Static local functions")); + Assert.That(suggestion, Does.Contain("inline constants")); + } + + [Test] + public void TryBuildHint_WhenCs1503CannotConvertIntToByte_ShouldReturnExplicitCastGuidance() + { + bool built = DynamicCodeTranspilerConstraintHints.TryBuildHint( + "CS1503", + "CS1503: Argument 1: cannot convert from 'int' to 'byte'", + out string hint, + out string suggestion); + + Assert.That(built, Is.True); + Assert.That(hint, Does.Contain("Color32")); + Assert.That(suggestion, Does.Contain("(byte)255")); + } + } +} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs.meta b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs.meta new file mode 100644 index 0000000000..3b16043ea3 --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 793fe6f168d864edb9afa78f79729471 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeExecutionResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeExecutionResponseFactory.cs index 4f34b76f22..2eaa7aba6b 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeExecutionResponseFactory.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeExecutionResponseFactory.cs @@ -300,6 +300,19 @@ private static (string hint, List suggestions) GetHintAndSuggestions( return (hint, suggestions); default: + if (DynamicCodeTranspilerConstraintHints.TryBuildHint( + error.ErrorCode, + error.Message, + out string constraintHint, + out string constraintSuggestion)) + { + hint = constraintHint; + if (!string.IsNullOrEmpty(constraintSuggestion)) + { + suggestions.Add(constraintSuggestion); + } + } + return (hint, suggestions); } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs index d6d8b91810..9956ab1e97 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs @@ -30,6 +30,7 @@ public static HoistedLiteralRewriteResult Rewrite(string source) { StringBuilder rewrittenSource = new(source.Length); List bindings = new(); + LiteralHoistScopeTracker scopeTracker = new(); int index = 0; while (index < source.Length) @@ -59,12 +60,49 @@ public static HoistedLiteralRewriteResult Rewrite(string source) continue; } - if (TryHoistRegularStringLiteral(source, rewrittenSource, bindings, ref index)) + if (scopeTracker.TryConsumeStaticLocalFunctionHeader(source, index, rewrittenSource, ref index)) { continue; } - if (TryHoistIntegerLiteral(source, rewrittenSource, bindings, ref index)) + if (source[index] == '{') + { + scopeTracker.OnOpenBrace(); + rewrittenSource.Append('{'); + index++; + continue; + } + + if (source[index] == '}') + { + scopeTracker.OnCloseBrace(); + rewrittenSource.Append('}'); + index++; + continue; + } + + if (source[index] == ';') + { + scopeTracker.OnSemicolon(); + rewrittenSource.Append(';'); + index++; + continue; + } + + if (scopeTracker.ShouldSuppressLiteralHoisting) + { + if (TryCopyRegularStringLiteral(source, rewrittenSource, ref index)) + { + continue; + } + } + else if (TryHoistRegularStringLiteral(source, rewrittenSource, bindings, ref index)) + { + continue; + } + + if (!scopeTracker.ShouldSuppressLiteralHoisting + && TryHoistIntegerLiteral(source, rewrittenSource, bindings, ref index)) { continue; } @@ -551,6 +589,47 @@ private static bool TryCopyCharLiteral( 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, diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs new file mode 100644 index 0000000000..c850aa6839 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs @@ -0,0 +1,46 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds compilation hints for execute-dynamic-code transpiler constraints. + /// + internal static class DynamicCodeTranspilerConstraintHints + { + internal static bool TryBuildHint( + string errorCode, + string message, + out string hint, + out string suggestion) + { + hint = string.Empty; + suggestion = string.Empty; + + if (string.Equals(errorCode, "CS8421", StringComparison.Ordinal) + && message.Contains(LiteralParameterPrefix, StringComparison.Ordinal)) + { + hint = + "Static local functions cannot reference hoisted literal parameters. " + + "Keep literals used inside static local functions as inline constants, " + + "or move the helper outside the static local function."; + suggestion = "Replace hoisted literals inside the static local function body with inline constants."; + return true; + } + + if (string.Equals(errorCode, "CS1503", StringComparison.Ordinal) + && message.Contains("cannot convert from 'int' to 'byte'", StringComparison.Ordinal)) + { + hint = + "Literal hoisting promotes integer literals to int values. " + + "Unity APIs such as Color32 expect byte components and do not accept implicit int-to-byte conversion " + + "after hoisting, even though plain numeric literals compile in normal Unity scripts."; + suggestion = "Cast each component explicitly, for example: new Color32((byte)255, (byte)0, (byte)0, (byte)255)."; + return true; + } + + return false; + } + + private const string LiteralParameterPrefix = "__uloop_literal_"; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs.meta new file mode 100644 index 0000000000..e053c03016 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b998634941c2049ec90c456eda0e3820 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs new file mode 100644 index 0000000000..a81cc2a0e3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Tracks static local function bodies so literal hoisting does not inject outer-scope bindings. + /// + internal sealed class LiteralHoistScopeTracker + { + private readonly Stack _suppressedBodyBraceDepths = new(); + private int _braceDepth; + private bool _pendingBlockBody; + private bool _inExpressionBody; + + internal bool ShouldSuppressLiteralHoisting => + _inExpressionBody || _suppressedBodyBraceDepths.Count > 0; + + internal void OnOpenBrace() + { + _braceDepth++; + if (_pendingBlockBody) + { + _suppressedBodyBraceDepths.Push(_braceDepth); + _pendingBlockBody = false; + } + } + + internal void OnCloseBrace() + { + if (_suppressedBodyBraceDepths.Count > 0 + && _suppressedBodyBraceDepths.Peek() == _braceDepth) + { + _suppressedBodyBraceDepths.Pop(); + } + + _braceDepth--; + } + + internal void OnSemicolon() + { + if (_inExpressionBody) + { + _inExpressionBody = false; + } + } + + internal bool TryConsumeStaticLocalFunctionHeader( + string source, + int index, + StringBuilder rewrittenSource, + ref int nextIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(rewrittenSource != null, "rewrittenSource must not be null"); + + if (_braceDepth < 1) + { + return false; + } + + if (!IsKeywordAt(source, index, "static")) + { + return false; + } + + int scanIndex = index + "static".Length; + if (!StaticLocalFunctionHeaderScanner.TrySkipHeader(source, scanIndex, out bool isExpressionBody, out int headerEndIndex)) + { + return false; + } + + rewrittenSource.Append(source, index, headerEndIndex - index); + if (isExpressionBody) + { + _inExpressionBody = true; + } + else + { + _pendingBlockBody = true; + } + + nextIndex = headerEndIndex; + return true; + } + + private static bool IsKeywordAt(string source, int index, string keyword) + { + if (index < 0 || index + keyword.Length > source.Length) + { + return false; + } + + if (!HasIdentifierBoundaryBefore(source, index)) + { + return false; + } + + for (int offset = 0; offset < keyword.Length; offset++) + { + if (source[index + offset] != keyword[offset]) + { + return false; + } + } + + return HasIdentifierBoundaryAfter(source, index + keyword.Length); + } + + private static bool HasIdentifierBoundaryBefore(string source, int index) + { + if (index <= 0) + { + return true; + } + + char previous = source[index - 1]; + return !char.IsLetterOrDigit(previous) && previous != '_'; + } + + private static bool HasIdentifierBoundaryAfter(string source, int index) + { + if (index >= source.Length) + { + return true; + } + + char next = source[index]; + return !char.IsLetterOrDigit(next) && next != '_'; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs.meta new file mode 100644 index 0000000000..4c938157a9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a799fe95102c424585ae3e7ca4fc768 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs new file mode 100644 index 0000000000..8a8adfc9d8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs @@ -0,0 +1,197 @@ +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Detects static local function headers so literal hoisting can skip their bodies. + /// + internal static class StaticLocalFunctionHeaderScanner + { + internal static bool TrySkipHeader( + string source, + int startIndex, + out bool isExpressionBody, + out int headerEndIndex) + { + Debug.Assert(source != null, "source must not be null"); + + isExpressionBody = false; + headerEndIndex = startIndex; + + int index = SkipWhitespace(source, startIndex); + if (!TrySkipReturnTypeAndName(source, ref index)) + { + return false; + } + + index = SkipWhitespace(source, index); + if (index >= source.Length || source[index] != '(') + { + return false; + } + + if (!TrySkipParameterList(source, ref index)) + { + return false; + } + + index = SkipWhitespace(source, index); + if (index + 1 < source.Length && source[index] == '=' && source[index + 1] == '>') + { + isExpressionBody = true; + headerEndIndex = index + 2; + return true; + } + + if (index < source.Length && source[index] == '{') + { + isExpressionBody = false; + headerEndIndex = index; + return true; + } + + return false; + } + + private static bool TrySkipReturnTypeAndName(string source, ref int index) + { + bool sawName = false; + + while (index < source.Length) + { + index = SkipWhitespace(source, index); + if (index >= source.Length) + { + return false; + } + + if (source[index] == '(') + { + return sawName; + } + + if (!TrySkipIdentifier(source, ref index)) + { + return false; + } + + sawName = true; + index = SkipWhitespace(source, index); + if (index < source.Length && source[index] == '<') + { + if (!TrySkipGenericArguments(source, ref index)) + { + return false; + } + } + + if (index < source.Length && source[index] == '.') + { + index++; + } + } + + return false; + } + + private static bool TrySkipParameterList(string source, ref int index) + { + if (index >= source.Length || source[index] != '(') + { + return false; + } + + int depth = 0; + while (index < source.Length) + { + char current = source[index]; + if (current == '(') + { + depth++; + } + else if (current == ')') + { + depth--; + if (depth == 0) + { + index++; + return true; + } + } + + index++; + } + + return false; + } + + private static bool TrySkipGenericArguments(string source, ref int index) + { + if (index >= source.Length || source[index] != '<') + { + return false; + } + + int depth = 0; + while (index < source.Length) + { + char current = source[index]; + if (current == '<') + { + depth++; + } + else if (current == '>') + { + depth--; + if (depth == 0) + { + index++; + return true; + } + } + + index++; + } + + return false; + } + + private static bool TrySkipIdentifier(string source, ref int index) + { + if (index >= source.Length) + { + return false; + } + + char first = source[index]; + if (!char.IsLetter(first) && first != '_') + { + return false; + } + + index++; + while (index < source.Length) + { + char current = source[index]; + if (!char.IsLetterOrDigit(current) && current != '_') + { + return true; + } + + index++; + } + + return true; + } + + private static int SkipWhitespace(string source, int index) + { + while (index < source.Length && char.IsWhiteSpace(source[index])) + { + index++; + } + + return index; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs.meta new file mode 100644 index 0000000000..c28ccff2ff --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81fb44ffbd1714d23bfc26a3afd08aab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index d5d4edc3df..6d219e8cc0 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -32,6 +32,11 @@ return x; Prefer terminal commands for file operations and keep snippets focused on Unity Editor state that existing uloop tools cannot inspect or change. +## Known transpiler constraints + +- Static local functions cannot use hoisted literals (`__uloop_literal_N`). Keep literals inside static local function bodies as inline constants, or move helpers out of static local functions. +- Integer literals are hoisted as `int` values. APIs that require `byte` components (for example `new Color32(255, 0, 0, 255)`) need explicit casts such as `(byte)255` even when plain Unity scripts accept uncast numeric literals. + ## Shell Quoting - zsh/bash: single-quote the whole snippet so C# double quotes pass through unchanged: `--code 'return "hi";'`. For a single quote inside the snippet, close and reopen the shell string with `'\''`. From 55201bd4bf90476f06052f2d888b3d933ef042c6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 12 Jul 2026 16:15:04 +0900 Subject: [PATCH 2/2] Fix top-level static local hoisting and refine transpiler hints Allow static local function scope tracking at brace depth zero, add CS8820 guidance, return hint tuples instead of out parameters, unify the literal prefix constant, and document scanner limitations in code and SKILL.md. Co-authored-by: Cursor --- .../uloop-execute-dynamic-code/SKILL.md | 3 +- .../uloop-execute-dynamic-code/SKILL.md | 3 +- .../DynamicCodeLiteralHoisterTests.cs | 40 +++++++++++++++++ ...namicCodeTranspilerConstraintHintsTests.cs | 41 ++++++++++++------ .../DynamicCodeExecutionResponseFactory.cs | 9 ++-- .../DynamicCodeLiteralHoister.cs | 2 +- .../DynamicCodeTranspilerConstraintHints.cs | 43 ++++++++++--------- .../LiteralHoistScopeTracker.cs | 5 --- .../StaticLocalFunctionHeaderScanner.cs | 5 +++ .../ExecuteDynamicCode/Skill/SKILL.md | 3 +- 10 files changed, 108 insertions(+), 46 deletions(-) diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index 6d219e8cc0..42208832b9 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -34,7 +34,8 @@ Prefer terminal commands for file operations and keep snippets focused on Unity ## Known transpiler constraints -- Static local functions cannot use hoisted literals (`__uloop_literal_N`). Keep literals inside static local function bodies as inline constants, or move helpers out of static local functions. +- Literals inside recognized static local function bodies are kept inline automatically. Unsupported header shapes (generic `where` clauses, tuple return types, statement lambdas inside expression bodies) may still hoist literals and surface CS8421; remove `static` or rewrite the helper. +- Static lambdas (`static x => ...`) cannot reference hoisted literals and surface CS8820; remove `static` from the lambda or use a non-static local function. - Integer literals are hoisted as `int` values. APIs that require `byte` components (for example `new Color32(255, 0, 0, 255)`) need explicit casts such as `(byte)255` even when plain Unity scripts accept uncast numeric literals. ## Shell Quoting diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index 6d219e8cc0..42208832b9 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -34,7 +34,8 @@ Prefer terminal commands for file operations and keep snippets focused on Unity ## Known transpiler constraints -- Static local functions cannot use hoisted literals (`__uloop_literal_N`). Keep literals inside static local function bodies as inline constants, or move helpers out of static local functions. +- Literals inside recognized static local function bodies are kept inline automatically. Unsupported header shapes (generic `where` clauses, tuple return types, statement lambdas inside expression bodies) may still hoist literals and surface CS8421; remove `static` or rewrite the helper. +- Static lambdas (`static x => ...`) cannot reference hoisted literals and surface CS8820; remove `static` from the lambda or use a non-static local function. - Integer literals are hoisted as `int` values. APIs that require `byte` components (for example `new Color32(255, 0, 0, 255)`) need explicit casts such as `(byte)255` even when plain Unity scripts accept uncast numeric literals. ## Shell Quoting diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterTests.cs index 951112831a..6dcdaa10e7 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeLiteralHoisterTests.cs @@ -22,6 +22,9 @@ public void Rewrite_WhenInterpolatedHoleContainsNestedStringLiteral_ShouldKeepIn Assert.That(result.DeclarationLines, Is.Empty); } + /// + /// Verifies literals inside a nested static local function stay inline while outer literals still hoist. + /// [Test] public void Rewrite_WhenIntegerLiteralIsInsideStaticLocalFunction_ShouldKeepLiteralInline() { @@ -41,6 +44,9 @@ public void Rewrite_WhenIntegerLiteralIsInsideStaticLocalFunction_ShouldKeepLite Assert.That(result.Bindings[0].Value, Is.EqualTo(3)); } + /// + /// Verifies string literals inside a nested static local block body are not hoisted. + /// [Test] public void Rewrite_WhenStringLiteralIsInsideStaticLocalFunctionBlockBody_ShouldKeepLiteralInline() { @@ -57,5 +63,39 @@ public void Rewrite_WhenStringLiteralIsInsideStaticLocalFunctionBlockBody_Should Assert.That(result.RewrittenSource, Does.Not.Contain("__uloop_literal_0")); Assert.That(result.Bindings, Is.Empty); } + + /// + /// Verifies top-level expression-bodied static local functions keep literals inline at brace depth zero. + /// + [Test] + public void Rewrite_WhenTopLevelExpressionBodiedStaticLocalFunction_ShouldKeepLiteralInline() + { + string source = @"static int Double(int x) => x * 2; +return Double(3);"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Does.Contain("x * 2")); + Assert.That(result.RewrittenSource, Does.Not.Contain("x * __uloop_literal")); + Assert.That(result.RewrittenSource, Does.Contain("return Double(__uloop_literal_0)")); + Assert.That(result.Bindings, Has.Count.EqualTo(1)); + Assert.That(result.Bindings[0].Value, Is.EqualTo(3)); + } + + /// + /// Verifies top-level block-bodied static local functions keep literals inline at brace depth zero. + /// + [Test] + public void Rewrite_WhenTopLevelBlockBodiedStaticLocalFunction_ShouldKeepLiteralInline() + { + string source = @"static string Label() { return ""ok""; } +return Label();"; + + HoistedLiteralRewriteResult result = DynamicCodeLiteralHoister.Rewrite(source); + + Assert.That(result.RewrittenSource, Does.Contain("return \"ok\"")); + Assert.That(result.RewrittenSource, Does.Not.Contain("__uloop_literal_0")); + Assert.That(result.Bindings, Is.Empty); + } } } diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs index 81cd76ff68..51b457454d 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeTranspilerConstraintHintsTests.cs @@ -10,30 +10,47 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests [TestFixture] public class DynamicCodeTranspilerConstraintHintsTests { + /// + /// Verifies CS8421 hoisted-literal failures suggest removing the static modifier. + /// [Test] public void TryBuildHint_WhenCs8421ReferencesHoistedLiteral_ShouldReturnStaticLocalFunctionGuidance() { - bool built = DynamicCodeTranspilerConstraintHints.TryBuildHint( + (bool matched, string hint, string suggestion) = DynamicCodeTranspilerConstraintHints.TryBuildHint( "CS8421", - "CS8421: A static local function cannot contain a reference to '__uloop_literal_0'.", - out string hint, - out string suggestion); + "CS8421: A static local function cannot contain a reference to '__uloop_literal_0'."); - Assert.That(built, Is.True); - Assert.That(hint, Does.Contain("Static local functions")); - Assert.That(suggestion, Does.Contain("inline constants")); + Assert.That(matched, Is.True); + Assert.That(hint, Does.Contain("recognized static local function bodies")); + Assert.That(suggestion, Is.EqualTo("Remove the `static` modifier from the local function.")); } + /// + /// Verifies CS8820 static lambda failures suggest removing the static modifier. + /// + [Test] + public void TryBuildHint_WhenCs8820ReferencesHoistedLiteral_ShouldReturnStaticLambdaGuidance() + { + (bool matched, string hint, string suggestion) = DynamicCodeTranspilerConstraintHints.TryBuildHint( + "CS8820", + "CS8820: A static anonymous function cannot contain a reference to '__uloop_literal_0'."); + + Assert.That(matched, Is.True); + Assert.That(hint, Does.Contain("Static lambdas")); + Assert.That(suggestion, Is.EqualTo("Remove the `static` modifier from the lambda.")); + } + + /// + /// Verifies int-to-byte conversion failures include explicit cast guidance for Color32. + /// [Test] public void TryBuildHint_WhenCs1503CannotConvertIntToByte_ShouldReturnExplicitCastGuidance() { - bool built = DynamicCodeTranspilerConstraintHints.TryBuildHint( + (bool matched, string hint, string suggestion) = DynamicCodeTranspilerConstraintHints.TryBuildHint( "CS1503", - "CS1503: Argument 1: cannot convert from 'int' to 'byte'", - out string hint, - out string suggestion); + "CS1503: Argument 1: cannot convert from 'int' to 'byte'"); - Assert.That(built, Is.True); + Assert.That(matched, Is.True); Assert.That(hint, Does.Contain("Color32")); Assert.That(suggestion, Does.Contain("(byte)255")); } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeExecutionResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeExecutionResponseFactory.cs index 2eaa7aba6b..e140477806 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeExecutionResponseFactory.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeExecutionResponseFactory.cs @@ -300,11 +300,10 @@ private static (string hint, List suggestions) GetHintAndSuggestions( return (hint, suggestions); default: - if (DynamicCodeTranspilerConstraintHints.TryBuildHint( - error.ErrorCode, - error.Message, - out string constraintHint, - out string constraintSuggestion)) + (bool matched, string constraintHint, string constraintSuggestion) = DynamicCodeTranspilerConstraintHints.TryBuildHint( + error.ErrorCode, + error.Message); + if (matched) { hint = constraintHint; if (!string.IsNullOrEmpty(constraintSuggestion)) diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs index 9956ab1e97..cb5ff4942e 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeLiteralHoister.cs @@ -10,7 +10,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class DynamicCodeLiteralHoister { - private const string LiteralParameterPrefix = "__uloop_literal_"; + internal const string LiteralParameterPrefix = "__uloop_literal_"; private static readonly Dictionary RegularStringEscapes = new() { { '\'', '\'' }, diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs index c850aa6839..c6ee4033e9 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/DynamicCodeTranspilerConstraintHints.cs @@ -7,40 +7,43 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class DynamicCodeTranspilerConstraintHints { - internal static bool TryBuildHint( + internal static (bool Matched, string Hint, string Suggestion) TryBuildHint( string errorCode, - string message, - out string hint, - out string suggestion) + string message) { - hint = string.Empty; - suggestion = string.Empty; - if (string.Equals(errorCode, "CS8421", StringComparison.Ordinal) - && message.Contains(LiteralParameterPrefix, StringComparison.Ordinal)) + && message.Contains(DynamicCodeLiteralHoister.LiteralParameterPrefix, StringComparison.Ordinal)) { - hint = + return ( + true, "Static local functions cannot reference hoisted literal parameters. " - + "Keep literals used inside static local functions as inline constants, " - + "or move the helper outside the static local function."; - suggestion = "Replace hoisted literals inside the static local function body with inline constants."; - return true; + + "Literals inside recognized static local function bodies are kept inline automatically; " + + "if this error persists, the header shape may be unsupported by the scanner.", + "Remove the `static` modifier from the local function."); + } + + if (string.Equals(errorCode, "CS8820", StringComparison.Ordinal) + && message.Contains(DynamicCodeLiteralHoister.LiteralParameterPrefix, StringComparison.Ordinal)) + { + return ( + true, + "Static lambdas cannot reference hoisted literal parameters. " + + "Remove the `static` modifier from the lambda, or use a non-static local function instead.", + "Remove the `static` modifier from the lambda."); } if (string.Equals(errorCode, "CS1503", StringComparison.Ordinal) && message.Contains("cannot convert from 'int' to 'byte'", StringComparison.Ordinal)) { - hint = + return ( + true, "Literal hoisting promotes integer literals to int values. " + "Unity APIs such as Color32 expect byte components and do not accept implicit int-to-byte conversion " - + "after hoisting, even though plain numeric literals compile in normal Unity scripts."; - suggestion = "Cast each component explicitly, for example: new Color32((byte)255, (byte)0, (byte)0, (byte)255)."; - return true; + + "after hoisting, even though plain numeric literals compile in normal Unity scripts.", + "Cast each component explicitly, for example: new Color32((byte)255, (byte)0, (byte)0, (byte)255)."); } - return false; + return (false, string.Empty, string.Empty); } - - private const string LiteralParameterPrefix = "__uloop_literal_"; } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs index a81cc2a0e3..12a2b08ba7 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/LiteralHoistScopeTracker.cs @@ -55,11 +55,6 @@ internal bool TryConsumeStaticLocalFunctionHeader( Debug.Assert(source != null, "source must not be null"); Debug.Assert(rewrittenSource != null, "rewrittenSource must not be null"); - if (_braceDepth < 1) - { - return false; - } - if (!IsKeywordAt(source, index, "static")) { return false; diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs index 8a8adfc9d8..1ccc87fae1 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/StaticLocalFunctionHeaderScanner.cs @@ -5,6 +5,11 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// /// Detects static local function headers so literal hoisting can skip their bodies. /// + /// + /// Unsupported header shapes leave literals hoistable and may still surface CS8421: + /// generic constraints with where clauses, tuple return types such as static (int, int) F(), + /// and expression bodies that contain statement lambdas (the first semicolon ends suppression early). + /// internal static class StaticLocalFunctionHeaderScanner { internal static bool TrySkipHeader( diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index 6d219e8cc0..42208832b9 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -34,7 +34,8 @@ Prefer terminal commands for file operations and keep snippets focused on Unity ## Known transpiler constraints -- Static local functions cannot use hoisted literals (`__uloop_literal_N`). Keep literals inside static local function bodies as inline constants, or move helpers out of static local functions. +- Literals inside recognized static local function bodies are kept inline automatically. Unsupported header shapes (generic `where` clauses, tuple return types, statement lambdas inside expression bodies) may still hoist literals and surface CS8421; remove `static` or rewrite the helper. +- Static lambdas (`static x => ...`) cannot reference hoisted literals and surface CS8820; remove `static` from the lambda or use a non-static local function. - Integer literals are hoisted as `int` values. APIs that require `byte` components (for example `new Color32(255, 0, 0, 255)`) need explicit casts such as `(byte)255` even when plain Unity scripts accept uncast numeric literals. ## Shell Quoting