From b4a86a46c5f5ce3915a4acb7371cde37e380bad6 Mon Sep 17 00:00:00 2001 From: Sebastien Lebreton Date: Tue, 8 Sep 2026 10:35:04 +0200 Subject: [PATCH 1/2] Add UNT0044 --- doc/UNT0044.md | 81 +++++ doc/index.md | 2 +- .../Infrastructure/DiagnosticVerifier.cs | 22 +- .../TextMeshProSetTextTests.cs | 299 ++++++++++++++++++ .../Resources/Strings.Designer.cs | 29 +- .../Resources/Strings.resx | 10 + .../TextMeshProSetText.cs | 215 +++++++++++++ src/Microsoft.Unity.Analyzers/UnityStubs.cs | 5 + 8 files changed, 654 insertions(+), 9 deletions(-) create mode 100644 doc/UNT0044.md create mode 100644 src/Microsoft.Unity.Analyzers.Tests/TextMeshProSetTextTests.cs create mode 100644 src/Microsoft.Unity.Analyzers/TextMeshProSetText.cs diff --git a/doc/UNT0044.md b/doc/UNT0044.md new file mode 100644 index 00000000..359f3b84 --- /dev/null +++ b/doc/UNT0044.md @@ -0,0 +1,81 @@ +# UNT0044 Avoid temporary strings when setting TextMeshPro text + +TextMeshPro can consume a `StringBuilder` or character array directly and can format numeric arguments into its internal buffer. These overloads can avoid constructing an intermediate string. + +This is an **allocation-reduction suggestion, not a guarantee of faster text updates**. It does not recommend replacing assignments of existing strings or string literals with `SetText(string)`. + +## Examples of patterns that are flagged by this analyzer + +For a `TMP_Text`, `TextMeshPro`, or `TextMeshProUGUI` instance: + +```csharp +label.text = builder.ToString(); +label.SetText(builder.ToString()); + +label.text = new string(characters); +label.SetText(new string(characters, start, length)); + +label.text = $"Score: {score}"; +label.SetText(score.ToString()); +``` + +The analyzer recognizes direct, parameterless `StringBuilder.ToString()` calls, character-array string constructors, and simple numeric interpolation or parameterless numeric `ToString()` calls. It checks that the corresponding public `SetText` overload exists in the referenced TMP API. + +Numeric suggestions are limited to `byte`, `sbyte`, `short`, `ushort`, `int`, `uint`, and `float`, and the number of arguments supported by the installed API. Known constant range or precision losses are excluded. This does **not** prove that variable values or their displayed formatting will be preserved. + +The analyzer does not flag custom formatting, alignment, mixed string/numeric interpolation, literal braces in interpolation, `double`, `decimal`, `long`, `ulong`, or nullable numeric values. It also leaves compound/value-producing assignments, separately stored strings, explicit extra `SetText` arguments, and custom `text`/`SetText` members alone. + +## Solution + +Where the behavior described below is appropriate, submit the buffer directly: + +```csharp +label.SetText(builder); +label.SetText(characters); +label.SetText(characters, start, length); +``` + +TMP copies these buffers during the call; it does not retain the caller's buffer for later reading. The caller can reuse it after the call returns. + +For numeric text, select the numeric overload only when TMP's formatting, precision, and supported range meet the application's requirements: + +```csharp +label.SetText("Score: {0}", score); +``` + +This is not a mechanical replacement for .NET interpolation or `ToString()`. + +**No automatic code fix is provided.** Even changing an existing `SetText(builder.ToString())` call to `SetText(builder)` can change observable behavior. + +## Why an automatic replacement is unsafe + +| Concern | Difference | +| --- | --- | +| Unchanged text | The `text` setter can return early for equal strings. `SetText` processes its input and marks vertices/layout dirty without that equality check. | +| Preprocessing | String input uses `ITextPreprocessor` during `ParseInputText`. Buffer and numeric input take paths that bypass it in the inspected implementations. | +| Numeric formatting | TMP is not a .NET composite formatter. Culture, precision, rounding, standard format strings, alignment, and escaped braces differ. Values are converted through `float`, `decimal`, and `long`; checking only the integer-to-float conversion is insufficient. | +| Nulls and slices | A null builder's `ToString()` throws, while TMP accepts a null buffer. String constructors validate array slices; TMP uses different bounds handling. The `StringBuilder` slice overload is not public. | +| Property behavior | `text` is virtual. Calling `SetText` can bypass an override and can change dirty callbacks. Resolving a property to the base declaration does not prove that its runtime receiver has no override. | +| Reading text back | Reading `text` after a buffer/numeric update can materialize a string again. Code that depends on the getter needs separate review. | + +## Performance evidence and limitations + +For nonempty input, `StringBuilder.ToString()` and character-array string construction allocate and copy a string. TMP's buffer overloads instead copy directly into TMP-owned storage. Its numeric overloads write formatted characters into that storage without first producing a formatted string. + +That establishes an opportunity to remove an intermediate allocation, **not** a universal CPU-time improvement or a zero-allocation guarantee. TMP's buffers can grow, parsing and rendering still cost time, and reading `text` can allocate. In the inspected sources, `UNITY_EDITOR` paths also reconstruct the backing string, so Editor allocation results differ from player builds. Repeated unchanged updates can cost more after losing the property setter's early-out. + +This is source-level evidence, not comparative benchmark evidence. Profile the complete update in a warmed-up player build, including unchanged-text cases, before claiming an application-level speedup. + +## Other related candidates + +`SetCharArray` provides the character-buffer alternatives as well. Some TMP APIs also expose `SetText(ReadOnlySpan)`, making `span.ToString()` and `new string(span)` further candidates for manual review. Availability of those span overloads depends on the referenced API; they are not covered by this diagnostic. + +## Sources + +- [Unity's TMP SetText API documentation](https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.0/api/TMPro.TMP_Text.SetText.html). +- [TMP text property and equality early-out](https://github.com/needle-mirror/com.unity.textmeshpro/blob/5671be34032f3ead7e4f94de2bfa6f7a24513cdb/Scripts/Runtime/TMP_Text.cs#L114-L134), in the public package mirror. +- [TMP string and numeric SetText implementations](https://github.com/needle-mirror/com.unity.textmeshpro/blob/5671be34032f3ead7e4f94de2bfa6f7a24513cdb/Scripts/Runtime/TMP_Text.cs#L2290-L2568). +- [TMP StringBuilder and character-array overloads](https://github.com/needle-mirror/com.unity.textmeshpro/blob/5671be34032f3ead7e4f94de2bfa6f7a24513cdb/Scripts/Runtime/TMP_Text.cs#L2577-L2677), including their Editor paths. +- [TMP preprocessing paths](https://github.com/needle-mirror/com.unity.textmeshpro/blob/5671be34032f3ead7e4f94de2bfa6f7a24513cdb/Scripts/Runtime/TMP_Text.cs#L1885-L1899) and [numeric conversion/formatting](https://github.com/needle-mirror/com.unity.textmeshpro/blob/5671be34032f3ead7e4f94de2bfa6f7a24513cdb/Scripts/Runtime/TMP_Text.cs#L3531-L3595). +- [.NET StringBuilder.ToString implementation](https://github.com/dotnet/runtime/blob/d099f075e45d2aa6007a22b71b45a08758559f80/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs#L334-L369) and [character-array string constructors](https://github.com/dotnet/runtime/blob/d099f075e45d2aa6007a22b71b45a08758559f80/src/libraries/System.Private.CoreLib/src/System/String.cs#L66-L108). +- [Unity's string SetText implementation](https://github.com/Unity-Technologies/uGUI/blob/be962f3bc3208f8df3abd8e8ddfae63ecb24f8c9/com.unity.ugui/Runtime/TMP/TMP_Text.cs#L2647-L2663) and [span overloads](https://github.com/Unity-Technologies/uGUI/blob/be962f3bc3208f8df3abd8e8ddfae63ecb24f8c9/com.unity.ugui/Runtime/TMP/TMP_Text.cs#L2930-L2962). diff --git a/doc/index.md b/doc/index.md index 92bf50bf..87f111d7 100644 --- a/doc/index.md +++ b/doc/index.md @@ -45,6 +45,7 @@ ID | Title | Category [UNT0041](UNT0041.md) | Use `Animator.StringToHash` for repeated `Animator` method calls | Performance [UNT0042](UNT0042.md) | `Mesh` array property accessed in loop | Performance [UNT0043](UNT0043.md) | Possible typo in conditional compilation symbol | Correctness +[UNT0044](UNT0044.md) | Avoid temporary strings when setting TextMeshPro text | Performance # Diagnostic Suppressors @@ -73,4 +74,3 @@ ID | Suppressed ID | Justification [USP0021](USP0021.md) | IDE0041 | Prefer reference equality [USP0022](USP0022.md) | IDE0270 | Unity objects should not use if null coalescing [USP0023](USP0023.md) | IDE1006 | The Unity runtime invokes Unity messages - diff --git a/src/Microsoft.Unity.Analyzers.Tests/Infrastructure/DiagnosticVerifier.cs b/src/Microsoft.Unity.Analyzers.Tests/Infrastructure/DiagnosticVerifier.cs index 8224d539..89fecd32 100644 --- a/src/Microsoft.Unity.Analyzers.Tests/Infrastructure/DiagnosticVerifier.cs +++ b/src/Microsoft.Unity.Analyzers.Tests/Infrastructure/DiagnosticVerifier.cs @@ -24,6 +24,9 @@ public abstract class DiagnosticVerifier private const string CSharpDefaultFileExt = "cs"; private const string TestProjectName = "TestProject"; + private static readonly Lazy> _references = new( + () => [.. UnityAssemblies().Select(path => MetadataReference.CreateFromFile(path))]); + protected abstract DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer(); protected virtual IEnumerable GetRelatedAnalyzers(DiagnosticAnalyzer analyzer) @@ -340,14 +343,17 @@ protected static IEnumerable UnityAssemblies() var managed = Path.Combine(scripting, "Managed"); yield return Path.Combine(managed, "UnityEditor.dll"); - yield return Path.Combine(managed, "UnityEngine.dll"); - var monolib = Path.Combine(scripting, "MonoBleedingEdge", "lib", "mono", "4.7.1-api"); - yield return Path.Combine(monolib, "mscorlib.dll"); - yield return Path.Combine(monolib, "System.dll"); + // Package assemblies reference Unity's modules rather than the combined reference assembly. + foreach (var assembly in Directory.EnumerateFiles(Path.Combine(managed, "UnityEngine"), "*.dll")) + yield return assembly; + + var netstandard = Path.Combine(scripting, "NetStandard"); + yield return Path.Combine(netstandard, "ref", "2.1.0", "netstandard.dll"); - var facades = Path.Combine(monolib, "Facades"); - yield return Path.Combine(facades, "netstandard.dll"); + var shims = Path.Combine(netstandard, "compat", "2.1.0", "shims", "netfx"); + foreach (var assembly in Directory.EnumerateFiles(shims, "*.dll")) + yield return assembly; // Use the 2D template to get additional assemblies, normally acquired through Package Manager var libcache = Path.Combine(resources, "PackageManager", "ProjectTemplates", "libcache"); @@ -355,6 +361,8 @@ protected static IEnumerable UnityAssemblies() var template2dScriptAssemblies = Path.Combine(template2d, "ScriptAssemblies"); yield return Path.Combine(template2dScriptAssemblies, "Unity.Mathematics.dll"); + yield return Path.Combine(template2dScriptAssemblies, "UnityEngine.UI.dll"); + yield return Path.Combine(template2dScriptAssemblies, "Unity.TextMeshPro.dll"); } private static Project CreateProject(AnalyzerVerificationContext context, string[] sources) @@ -365,7 +373,7 @@ private static Project CreateProject(AnalyzerVerificationContext context, string .CurrentSolution .AddProject(projectId, TestProjectName, TestProjectName, LanguageNames.CSharp); - solution = UnityAssemblies().Aggregate(solution, (current, dll) => current.AddMetadataReference(projectId, MetadataReference.CreateFromFile(dll))); + solution = solution.AddMetadataReferences(projectId, _references.Value); var parseOptions = new CSharpParseOptions(context.LanguageVersion) .WithPreprocessorSymbols(context.PreprocessorSymbols); diff --git a/src/Microsoft.Unity.Analyzers.Tests/TextMeshProSetTextTests.cs b/src/Microsoft.Unity.Analyzers.Tests/TextMeshProSetTextTests.cs new file mode 100644 index 00000000..21ff020b --- /dev/null +++ b/src/Microsoft.Unity.Analyzers.Tests/TextMeshProSetTextTests.cs @@ -0,0 +1,299 @@ +/*-------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *-------------------------------------------------------------------------------------------*/ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Unity.Analyzers.Tests; + +public class TextMeshProSetTextTests : BaseDiagnosticVerifierTest +{ + private readonly AnalyzerVerificationContext _context = AnalyzerVerificationContext.Default + .WithAnalyzerFilter("CS8019"); // Shared test sources can contain unnecessary using directives. + + [Theory] + [InlineData("TMP_Text", "label.text = builder.ToString();")] + [InlineData("TextMeshPro", "label.text = builder.ToString();")] + [InlineData("TextMeshProUGUI", "label.text = builder.ToString();")] + [InlineData("TMP_Text", "label.SetText(builder.ToString());")] + [InlineData("TextMeshPro", "label.SetText(builder.ToString());")] + [InlineData("TextMeshProUGUI", "label.SetText(builder.ToString());")] + [InlineData("TMP_Text", "label.SetText(sourceText: builder.ToString());")] + [InlineData("TMP_Text", "label?.SetText(builder.ToString());", 15)] + [InlineData("TMP_Text", "label.text = (builder.ToString());")] + [InlineData("TMP_Text", "label.SetText((builder.ToString()));")] + [InlineData("TMP_Text", "label.text = new StringBuilder().Append(\"Score\").ToString();")] + public async Task StringBuilder(string textType, string statement, int column = 9) + { + await VerifyCSharpDiagnosticAsync(_context, + CreateSource(statement, "StringBuilder builder", textType), + ExpectDiagnostic().WithLocation(9, column).WithArguments("SetText(StringBuilder)")); + } + + [Theory] + [InlineData("label.text = new string(buffer);", "SetText(char[])")] + [InlineData("label.SetText(new string(buffer));", "SetText(char[])")] + [InlineData("label.text = new(buffer);", "SetText(char[])")] + [InlineData("label.text = new string(buffer, start, count);", "SetText(char[], int, int)")] + [InlineData("label.SetText(new string(buffer, start, count));", "SetText(char[], int, int)")] + [InlineData("label.text = new string(value: buffer, startIndex: start, length: count);", "SetText(char[], int, int)")] + [InlineData("label.text = new string(length: count, value: buffer, startIndex: start);", "SetText(char[], int, int)")] + public async Task CharacterArray(string statement, string overload) + { + await VerifyCSharpDiagnosticAsync(_context, + CreateSource(statement, "char[] buffer, int start, int count"), + ExpectDiagnostic().WithLocation(9, 9).WithArguments(overload)); + } + + [Theory] + [InlineData("byte")] + [InlineData("sbyte")] + [InlineData("short")] + [InlineData("ushort")] + [InlineData("int")] + [InlineData("uint")] + [InlineData("float")] + public async Task NumericInterpolation(string type) + { + await VerifyCSharpDiagnosticAsync(_context, + CreateSource("label.text = $\"Score: {value}\";", $"{type} value"), + ExpectDiagnostic().WithLocation(9, 9).WithArguments("SetText(string, float)")); + } + + [Theory] + [InlineData("label.SetText($\"Score: {value}\");")] + [InlineData("label.SetText(sourceText: $\"Score: {value}\");")] + [InlineData("label.text = value.ToString();")] + [InlineData("label.SetText(value.ToString());")] + [InlineData("label.text = $@\"Score: {value}\";")] + public async Task NumericText(string statement) + { + await VerifyCSharpDiagnosticAsync(_context, + CreateSource(statement, "float value"), + ExpectDiagnostic().WithLocation(9, 9).WithArguments("SetText(string, float)")); + } + + [Theory] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + public async Task MultipleNumericArguments(int count) + { + var content = string.Join(" ", Enumerable.Repeat("{value}", count)); + await VerifyCSharpDiagnosticAsync(_context, + CreateSource($"label.text = $\"{content}\";", "float value"), + ExpectDiagnostic().WithLocation(9, 9).WithArguments("SetText(string, float, ...)")); + } + + [Theory] + [InlineData("label.text = \"Score\";")] + [InlineData("label.text = text;")] + [InlineData("label.text = null;")] + [InlineData("label.text = $\"Score\";")] + [InlineData("label.text = $\"Name: {text}\";")] + [InlineData("label.text = $\"{text}: {value}\";")] + [InlineData("label.SetText(text);")] + [InlineData("label.SetText((string)null);")] + [InlineData("label.SetText(\"Score: {0}\", value);")] + [InlineData("label.SetText(builder);")] + [InlineData("label.SetText(buffer);")] + [InlineData("label.SetText(buffer, 0, buffer.Length);")] + public async Task NoTemporaryStringOrNoSuitableOverload(string statement) + { + await VerifyCSharpDiagnosticAsync(_context, CreateSource(statement, "string text, float value, StringBuilder builder, char[] buffer")); + } + + [Theory] + [InlineData("label.text = builder.ToString(0, 1);")] + [InlineData("label.text = builder?.ToString();")] + [InlineData("label.text += builder.ToString();")] + [InlineData("label.SetText(builder.ToString(), 1f);")] + [InlineData("label.SetText(arg0: 1f, sourceText: builder.ToString());")] + [InlineData("label.text = new string('a', 10);")] + [InlineData("label.text = new string((char[])null);")] + [InlineData("label.text = new string(value: (char[])null);")] + [InlineData("var copy = new TextMeshProUGUI { text = builder.ToString() };")] + public async Task UnsupportedBufferUsage(string statement) + { + await VerifyCSharpDiagnosticAsync(_context, CreateSource(statement, "StringBuilder builder")); + } + + [Theory] + [InlineData("double")] + [InlineData("decimal")] + [InlineData("long")] + [InlineData("ulong")] + [InlineData("float?")] + [InlineData("bool")] + [InlineData("char")] + [InlineData("object")] + public async Task UnsupportedNumericType(string type) + { + await VerifyCSharpDiagnosticAsync(_context, CreateSource("label.text = $\"Value: {value}\";", $"{type} value")); + await VerifyCSharpDiagnosticAsync(_context, CreateSource("label.text = value.ToString();", $"{type} value")); + } + + [Theory] + [InlineData("label.text = $\"{value:F2}\";")] + [InlineData("label.text = $\"{value:0.00}\";")] + [InlineData("label.text = $\"{value,10}\";")] + [InlineData("label.text = $\"{{Score}}: {value}\";")] + [InlineData("label.text = value.ToString(\"F2\");")] + [InlineData("label.text = value.ToString(System.Globalization.CultureInfo.InvariantCulture);")] + [InlineData("label.text = string.Format(\"{0:N2}\", value);")] + [InlineData("label.text = $\"{value} {value} {value} {value} {value} {value} {value} {value} {value}\";")] + [InlineData("label.SetText($\"{value}\", 1f);")] + public async Task UnsupportedFormatting(string statement) + { + await VerifyCSharpDiagnosticAsync(_context, CreateSource(statement, "float value")); + } + + [Theory] + [InlineData("16777217")] + [InlineData("16777217u")] + [InlineData("12345678")] + [InlineData("12345678u")] + [InlineData("12345678f")] + [InlineData("int.MaxValue")] + [InlineData("uint.MaxValue")] + [InlineData("1e20f")] + [InlineData("float.MaxValue")] + [InlineData("float.MinValue")] + [InlineData("float.NaN")] + [InlineData("float.PositiveInfinity")] + [InlineData("float.NegativeInfinity")] + public async Task KnownNumericRangeOrPrecisionLoss(string value) + { + await VerifyCSharpDiagnosticAsync(_context, CreateSource($"label.text = $\"Value: {{{value}}}\";", "float unused")); + } + + [Fact] + public async Task DerivedTextType() + { + var source = CreateSource("label.text = builder.ToString();", "StringBuilder builder", "CustomText") + + @" +class CustomText : TextMeshProUGUI +{ +} +"; + await VerifyCSharpDiagnosticAsync(_context, source, ExpectDiagnostic().WithLocation(9, 9).WithArguments("SetText(StringBuilder)")); + } + + [Theory] + [InlineData("label.text = builder.ToString();", "public override string text { get; set; }")] + [InlineData("label.text = builder.ToString();", "public new string text { get; set; }")] + [InlineData("label.SetText(builder.ToString());", "public new void SetText(string sourceText, bool syncTextInputBox = true) { }")] + public async Task CustomTextMembers(string statement, string member) + { + var source = CreateSource(statement, "StringBuilder builder", "CustomText") + + $@" +class CustomText : TextMeshProUGUI +{{ + {member} +}} +"; + await VerifyCSharpDiagnosticAsync(_context, source); + } + + [Theory] + [InlineData("label.text = builder.ToString();")] + [InlineData("label.SetText(builder.ToString());")] + public async Task UnrelatedType(string statement) + { + var source = CreateSource(statement, "StringBuilder builder", "OtherText") + + @" +class OtherText +{ + public string text { get; set; } + public void SetText(string value) { } +} +"; + await VerifyCSharpDiagnosticAsync(_context, source); + } + + [Fact] + public async Task AssignmentValueIsUsed() + { + const string source = @" +using System.Text; +using TMPro; + +class Example +{ + string UpdateText(TMP_Text label, StringBuilder builder) + { + return label.text = builder.ToString(); + } +} +"; + await VerifyCSharpDiagnosticAsync(_context, source); + } + + [Fact] + public async Task ImplicitReceiver() + { + const string source = @" +using System.Text; +using TMPro; + +class Example : TextMeshProUGUI +{ + void UpdateText(StringBuilder builder) + { + text = builder.ToString(); + SetText(builder.ToString()); + } +} +"; + await VerifyCSharpDiagnosticAsync(_context, source, + ExpectDiagnostic().WithLocation(9, 9).WithArguments("SetText(StringBuilder)"), + ExpectDiagnostic().WithLocation(10, 9).WithArguments("SetText(StringBuilder)")); + } + + [Fact] + public async Task Trivia() + { + await VerifyCSharpDiagnosticAsync(_context, + CreateSource("label.text = /* source */ builder.ToString(); // display", "StringBuilder builder"), + ExpectDiagnostic().WithLocation(9, 9).WithArguments("SetText(StringBuilder)")); + } + + [Fact] + public async Task TextMeshProNotReferenced() + { + var context = AnalyzerVerificationContext.Default; + var document = CreateDocument(context, "class Example { }"); + var references = document.Project.MetadataReferences + .Where(reference => reference.Display?.EndsWith("Unity.TextMeshPro.dll", StringComparison.OrdinalIgnoreCase) != true); + var project = document.Project.WithMetadataReferences(references); + var updatedDocument = project.GetDocument(document.Id); + Assert.NotNull(updatedDocument); + + var diagnostics = await GetSortedDiagnosticsFromDocumentsAsync(context, GetCSharpDiagnosticAnalyzer(), [updatedDocument]); + Assert.Empty(diagnostics); + } + + private static string CreateSource(string statement, string parameters, string textType = "TMP_Text") + { + return $@" +using System.Text; +using TMPro; + +class Example +{{ + void UpdateText({textType} label, {parameters}) + {{ + {statement} + }} +}} +"; + } +} diff --git a/src/Microsoft.Unity.Analyzers/Resources/Strings.Designer.cs b/src/Microsoft.Unity.Analyzers/Resources/Strings.Designer.cs index 690d9ed4..e44c5ac3 100644 --- a/src/Microsoft.Unity.Analyzers/Resources/Strings.Designer.cs +++ b/src/Microsoft.Unity.Analyzers/Resources/Strings.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.Unity.Analyzers.Resources { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Strings { @@ -1275,6 +1275,33 @@ internal static string TagComparisonDiagnosticTitle { } } + /// + /// Looks up a localized string similar to TextMeshPro SetText overloads can consume buffers or format numeric arguments without creating a temporary string. Review formatting, preprocessing, and update behavior before switching overloads.. + /// + internal static string TextMeshProSetTextDiagnosticDescription { + get { + return ResourceManager.GetString("TextMeshProSetTextDiagnosticDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Consider '{0}' to avoid creating a temporary string. + /// + internal static string TextMeshProSetTextDiagnosticMessageFormat { + get { + return ResourceManager.GetString("TextMeshProSetTextDiagnosticMessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Avoid temporary strings when setting TextMeshPro text. + /// + internal static string TextMeshProSetTextDiagnosticTitle { + get { + return ResourceManager.GetString("TextMeshProSetTextDiagnosticTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Do not use Throw expressions with Unity objects.. /// diff --git a/src/Microsoft.Unity.Analyzers/Resources/Strings.resx b/src/Microsoft.Unity.Analyzers/Resources/Strings.resx index c21c2d23..2e2f35c8 100644 --- a/src/Microsoft.Unity.Analyzers/Resources/Strings.resx +++ b/src/Microsoft.Unity.Analyzers/Resources/Strings.resx @@ -690,4 +690,14 @@ Possible typo in conditional compilation symbol + + TextMeshPro SetText overloads can consume buffers or format numeric arguments without creating a temporary string. Review formatting, preprocessing, and update behavior before switching overloads. + + + Consider '{0}' to avoid creating a temporary string + {0} is the suggested TextMeshPro SetText overload + + + Avoid temporary strings when setting TextMeshPro text + diff --git a/src/Microsoft.Unity.Analyzers/TextMeshProSetText.cs b/src/Microsoft.Unity.Analyzers/TextMeshProSetText.cs new file mode 100644 index 00000000..628a09d1 --- /dev/null +++ b/src/Microsoft.Unity.Analyzers/TextMeshProSetText.cs @@ -0,0 +1,215 @@ +/*-------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *-------------------------------------------------------------------------------------------*/ + +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.Unity.Analyzers.Resources; + +namespace Microsoft.Unity.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class TextMeshProSetTextAnalyzer : DiagnosticAnalyzer +{ + private const string RuleId = "UNT0044"; + + internal static readonly DiagnosticDescriptor Rule = new( + id: RuleId, + title: Strings.TextMeshProSetTextDiagnosticTitle, + messageFormat: Strings.TextMeshProSetTextDiagnosticMessageFormat, + category: DiagnosticCategory.Performance, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + helpLinkUri: HelpLink.ForDiagnosticId(RuleId), + description: Strings.TextMeshProSetTextDiagnosticDescription); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(startContext => + { + var textType = startContext.Compilation.GetTypeByMetadataName(typeof(TMPro.TMP_Text).FullName!); + if (textType == null) + return; + + var overloads = textType.GetMembers("SetText") + .OfType() + .Where(method => !method.IsStatic && method.DeclaredAccessibility == Accessibility.Public) + .ToImmutableArray(); + + startContext.RegisterSyntaxNodeAction(c => AnalyzeAssignment(c, textType, overloads), SyntaxKind.SimpleAssignmentExpression); + startContext.RegisterSyntaxNodeAction(c => AnalyzeInvocation(c, textType, overloads), SyntaxKind.InvocationExpression); + }); + } + + private static void AnalyzeAssignment(SyntaxNodeAnalysisContext context, INamedTypeSymbol textType, ImmutableArray overloads) + { + var syntax = (AssignmentExpressionSyntax)context.Node; + var name = syntax.Left switch + { + MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText, + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + _ => null + }; + if (name != "text") + return; + + if (context.SemanticModel.GetOperation(context.Node, context.CancellationToken) is not ISimpleAssignmentOperation + { + Target: IPropertyReferenceOperation target, + Parent: IExpressionStatementOperation + } assignment) + return; + + if (target.Property.Name != "text" || !SymbolEqualityComparer.Default.Equals(target.Property.ContainingType, textType)) + return; + + AnalyzeText(context, assignment.Value, overloads); + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, INamedTypeSymbol textType, ImmutableArray overloads) + { + var syntax = (InvocationExpressionSyntax)context.Node; + var name = syntax.Expression is MemberBindingExpressionSyntax binding ? binding.Name : syntax.GetMethodNameSyntax(); + if (name?.Identifier.ValueText != "SetText" || syntax.ArgumentList.Arguments.Count != 1) + return; + + if (context.SemanticModel.GetOperation(context.Node, context.CancellationToken) is not IInvocationOperation invocation) + return; + + var method = invocation.TargetMethod; + if (method.Name != "SetText" + || !SymbolEqualityComparer.Default.Equals(method.ContainingType, textType) + || method.Parameters.Length == 0 + || method.Parameters[0].Type.SpecialType != SpecialType.System_String) + return; + + var source = invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == 0); + if (source != null) + AnalyzeText(context, source.Value, overloads); + } + + private static void AnalyzeText(SyntaxNodeAnalysisContext context, IOperation value, ImmutableArray overloads) + { + while (value is IConversionOperation conversion + && (conversion.Conversion.IsIdentity || conversion.IsImplicit && conversion.OperatorMethod == null)) + value = conversion.Operand; + + string? replacement = null; + switch (value) + { + case IInvocationOperation invocation when invocation.TargetMethod.Name == nameof(ToString) + && invocation.Arguments.Length == 0 + && invocation.Instance != null: + if (invocation.TargetMethod.ContainingType.Matches(typeof(StringBuilder)) + && HasBufferOverload(overloads, invocation.TargetMethod.ContainingType, 1)) + { + replacement = "SetText(StringBuilder)"; + } + else if (IsSupportedNumber(invocation.Instance) && HasNumericOverload(overloads, 1)) + { + replacement = "SetText(string, float)"; + } + break; + + case IObjectCreationOperation { Constructor: { } constructor } creation + when constructor.ContainingType.SpecialType == SpecialType.System_String + && constructor.Parameters.Length is 1 or 3 + && constructor.Parameters[0].Type is IArrayTypeSymbol { Rank: 1, ElementType.SpecialType: SpecialType.System_Char } bufferType: + var buffer = creation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == 0)?.Value; + if (buffer != null + && !(buffer.ConstantValue.HasValue && buffer.ConstantValue.Value == null) + && HasBufferOverload(overloads, bufferType, constructor.Parameters.Length)) + { + replacement = constructor.Parameters.Length == 1 ? "SetText(char[])" : "SetText(char[], int, int)"; + } + break; + + case IInterpolatedStringOperation interpolation: + var count = CountNumericInterpolations(interpolation); + if (count > 0 && HasNumericOverload(overloads, count)) + replacement = count == 1 ? "SetText(string, float)" : "SetText(string, float, ...)"; + break; + } + + if (replacement != null) + context.ReportDiagnostic(Diagnostic.Create(Rule, context.Node.GetLocation(), replacement)); + } + + private static bool HasBufferOverload(ImmutableArray overloads, ITypeSymbol bufferType, int parameterCount) + { + return overloads.Any(method => method.Parameters.Length == parameterCount + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, bufferType) + && method.Parameters.Skip(1).All(parameter => parameter.Type.SpecialType == SpecialType.System_Int32)); + } + + private static bool HasNumericOverload(ImmutableArray overloads, int valueCount) + { + return overloads.Any(method => method.Parameters.Length == valueCount + 1 + && method.Parameters[0].Type.SpecialType == SpecialType.System_String + && method.Parameters.Skip(1).All(parameter => parameter.Type.SpecialType == SpecialType.System_Single)); + } + + private static int CountNumericInterpolations(IInterpolatedStringOperation interpolation) + { + var count = 0; + foreach (var part in interpolation.Parts) + { + switch (part) + { + case IInterpolationOperation item: + if (item.Alignment != null || item.FormatString != null || !IsSupportedNumber(item.Expression)) + return 0; + + count++; + break; + + case IInterpolatedStringTextOperation { Text.ConstantValue: { HasValue: true, Value: string literal } }: + if (literal.IndexOf('{') >= 0 || literal.IndexOf('}') >= 0) + return 0; + break; + } + } + + return count; + } + + private static bool IsSupportedNumber(IOperation value) + { + if (value.Type?.SpecialType is not (SpecialType.System_Byte or SpecialType.System_SByte + or SpecialType.System_Int16 or SpecialType.System_UInt16 + or SpecialType.System_Int32 or SpecialType.System_UInt32 or SpecialType.System_Single)) + return false; + + if (!value.ConstantValue.HasValue) + return true; + + return value.ConstantValue.Value switch + { + int number => IsSupportedConstant(number), + uint number => IsSupportedConstant(number), + float number => IsSupportedConstant(number), + _ => true + }; + } + + private static bool IsSupportedConstant(double value) + { + var single = (float)value; + // TMP converts through float, decimal, and long. These checks do not establish formatting equivalence. + return value >= long.MinValue && value <= long.MaxValue + && single == value + && (float)(decimal)single == single; + } +} diff --git a/src/Microsoft.Unity.Analyzers/UnityStubs.cs b/src/Microsoft.Unity.Analyzers/UnityStubs.cs index 218f748c..08dd8a08 100644 --- a/src/Microsoft.Unity.Analyzers/UnityStubs.cs +++ b/src/Microsoft.Unity.Analyzers/UnityStubs.cs @@ -824,4 +824,9 @@ class UniTask { } class UniTaskVoid { } } +namespace TMPro +{ + class TMP_Text { } +} + #pragma warning enable From a73f2c3c9207efd0e5594b94bc289dbe4ca7fce7 Mon Sep 17 00:00:00 2001 From: Sebastien Lebreton Date: Tue, 8 Sep 2026 10:46:39 +0200 Subject: [PATCH 2/2] Update docs --- doc/UNT0044.md | 40 ++++++++++++++-------------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/doc/UNT0044.md b/doc/UNT0044.md index 359f3b84..4e90432e 100644 --- a/doc/UNT0044.md +++ b/doc/UNT0044.md @@ -4,20 +4,22 @@ TextMeshPro can consume a `StringBuilder` or character array directly and can fo This is an **allocation-reduction suggestion, not a guarantee of faster text updates**. It does not recommend replacing assignments of existing strings or string literals with `SetText(string)`. -## Examples of patterns that are flagged by this analyzer +## Flagged code and possible alternatives -For a `TMP_Text`, `TextMeshPro`, or `TextMeshProUGUI` instance: +Each row is an independent before/after example for a `TMP_Text`, `TextMeshPro`, or `TextMeshProUGUI` instance. The alternatives require the behavioral review described below; they are not automatic fixes. -```csharp -label.text = builder.ToString(); -label.SetText(builder.ToString()); +| Case | Code flagged by the analyzer (before) | Possible alternative (after) | +| --- | --- | --- | +| Builder assigned to `text` | `label.text = builder.ToString();` | `label.SetText(builder);` | +| Builder passed to `SetText` | `label.SetText(builder.ToString());` | `label.SetText(builder);` | +| Character array | `label.text = new string(characters);` | `label.SetText(characters);` | +| Character-array slice | `label.SetText(new string(characters, start, length));` | `label.SetText(characters, start, length);` | +| Number with a prefix | `label.text = $"Score: {score}";` | `label.SetText("Score: {0}", score);` | +| Number without a prefix | `label.SetText(score.ToString());` | `label.SetText("{0}", score);` | -label.text = new string(characters); -label.SetText(new string(characters, start, length)); +The last two rows are separate cases: one displays `"Score: "` followed by the number, while the other displays only the number. `SetText(score.ToString())` is a flagged input pattern, **not** the optimization. Both numeric alternatives pass the number directly to TMP without first formatting it into a string. -label.text = $"Score: {score}"; -label.SetText(score.ToString()); -``` +## Scope The analyzer recognizes direct, parameterless `StringBuilder.ToString()` calls, character-array string constructors, and simple numeric interpolation or parameterless numeric `ToString()` calls. It checks that the corresponding public `SetText` overload exists in the referenced TMP API. @@ -25,25 +27,11 @@ Numeric suggestions are limited to `byte`, `sbyte`, `short`, `ushort`, `int`, `u The analyzer does not flag custom formatting, alignment, mixed string/numeric interpolation, literal braces in interpolation, `double`, `decimal`, `long`, `ulong`, or nullable numeric values. It also leaves compound/value-producing assignments, separately stored strings, explicit extra `SetText` arguments, and custom `text`/`SetText` members alone. -## Solution - -Where the behavior described below is appropriate, submit the buffer directly: - -```csharp -label.SetText(builder); -label.SetText(characters); -label.SetText(characters, start, length); -``` +## Applying an alternative TMP copies these buffers during the call; it does not retain the caller's buffer for later reading. The caller can reuse it after the call returns. -For numeric text, select the numeric overload only when TMP's formatting, precision, and supported range meet the application's requirements: - -```csharp -label.SetText("Score: {0}", score); -``` - -This is not a mechanical replacement for .NET interpolation or `ToString()`. +For numeric text, select the numeric overload only when TMP's formatting, precision, and supported range meet the application's requirements. It is not a mechanical replacement for .NET interpolation or `ToString()`. **No automatic code fix is provided.** Even changing an existing `SetText(builder.ToString())` call to `SetText(builder)` can change observable behavior.