Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions doc/UNT0044.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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)`.

## Flagged code and possible alternatives

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.

| 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);` |

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.

## 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.

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.

## 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. 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.

## 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<char>)`, 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).
2 changes: 1 addition & 1 deletion doc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public abstract class DiagnosticVerifier
private const string CSharpDefaultFileExt = "cs";
private const string TestProjectName = "TestProject";

private static readonly Lazy<ImmutableArray<MetadataReference>> _references = new(
() => [.. UnityAssemblies().Select(path => MetadataReference.CreateFromFile(path))]);

protected abstract DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer();

protected virtual IEnumerable<DiagnosticAnalyzer> GetRelatedAnalyzers(DiagnosticAnalyzer analyzer)
Expand Down Expand Up @@ -340,21 +343,26 @@ protected static IEnumerable<string> 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");
var template2d = Directory.GetDirectories(libcache, "com.unity.template.2d-*").Single();
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)
Expand All @@ -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);
Expand Down
Loading