Skip to content

Commit 965cb53

Browse files
author
Mark Kharitonov
committed
Initial Commit
0 parents  commit 965cb53

38 files changed

Lines changed: 1028 additions & 0 deletions

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
* text=auto eol=crlf
2+
*.sh text eol=lf
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
name: Bug report
3+
about: U8SI001 fires incorrectly or the code fix produces wrong output
4+
labels: bug
5+
---
6+
7+
**Utf8StringInterpolation.Analyzers version:**
8+
9+
**Utf8StringInterpolation version:**
10+
11+
**Code that triggers the issue:**
12+
```csharp
13+
14+
```
15+
16+
**Expected behavior:**
17+
18+
**Actual behavior:**

.github/workflows/ci.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ master ]
6+
pull_request:
7+
branches: [ master ]
8+
9+
permissions:
10+
contents: read
11+
checks: write
12+
pull-requests: write
13+
14+
jobs:
15+
build-and-test:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0 # NBGV needs full history to compute git height
21+
22+
- name: Setup .NET
23+
uses: actions/setup-dotnet@v4
24+
with:
25+
dotnet-version: '10.0.x'
26+
27+
- name: Restore
28+
run: dotnet restore
29+
30+
- name: Build
31+
run: dotnet build --no-restore --configuration Release
32+
33+
- name: Test
34+
run: >
35+
dotnet test --no-build --configuration Release
36+
--logger trx
37+
--results-directory ./test-results
38+
39+
- name: Upload test results
40+
uses: actions/upload-artifact@v4
41+
if: always()
42+
with:
43+
name: test-results
44+
path: ./test-results/
45+
46+
# Rich check-run report with per-test detail (runs on both push and PR)
47+
- name: Publish test report
48+
uses: dorny/test-reporter@v1
49+
if: always()
50+
with:
51+
name: Test results
52+
path: ./test-results/*.trx
53+
reporter: dotnet-trx
54+
55+
# PR comment summary (only meaningful on pull requests)
56+
- name: Publish PR test summary
57+
uses: EnricoMi/publish-unit-test-result-action@v2
58+
if: always() && github.event_name == 'pull_request'
59+
with:
60+
files: ./test-results/**/*.trx

.github/workflows/publish.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Publish to NuGet
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
publish:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v4
11+
with:
12+
fetch-depth: 0 # NBGV needs full history to compute git height
13+
14+
- name: Setup .NET
15+
uses: actions/setup-dotnet@v4
16+
with:
17+
dotnet-version: '10.0.x'
18+
19+
- name: Restore
20+
run: dotnet restore
21+
22+
- name: Build
23+
run: dotnet build --no-restore --configuration Release
24+
25+
- name: Test
26+
run: dotnet test --no-build --configuration Release --verbosity normal
27+
28+
- name: Pack
29+
run: >
30+
dotnet pack src/Utf8StringInterpolation.Analyzers/Utf8StringInterpolation.Analyzers.csproj
31+
--no-build
32+
--configuration Release
33+
--output ./nupkg
34+
35+
- name: Publish to NuGet
36+
run: >
37+
dotnet nuget push ./nupkg/*.nupkg
38+
--api-key ${{ secrets.NUGET_API_KEY }}
39+
--source https://api.nuget.org/v3/index.json
40+
--skip-duplicate

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
bin/
2+
obj/
3+
*.user
4+
.vs/
5+
nupkg/
6+
.*mcp*

Directory.Packages.props

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project>
2+
<PropertyGroup>
3+
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
4+
</PropertyGroup>
5+
<ItemGroup>
6+
<!-- Roslyn -->
7+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
8+
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
9+
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
10+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.6.0" />
11+
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="5.6.0" />
12+
<!-- Analyzer testing -->
13+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.4" />
14+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.4" />
15+
<!-- Composition -->
16+
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
17+
<PackageVersion Include="System.Composition.AttributedModel" Version="10.0.1" />
18+
<!-- Versioning -->
19+
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.10.91" />
20+
<!-- Test SDK and xunit -->
21+
<PackageVersion Include="Utf8StringInterpolation" Version="1.3.2"/>
22+
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
23+
<PackageVersion Include="xunit" Version="2.9.3" />
24+
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
25+
</ItemGroup>
26+
</Project>

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Dayforce HCM Inc.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Utf8StringInterpolation.Analyzers
2+
3+
[![NuGet](https://img.shields.io/nuget/v/Utf8StringInterpolation.Analyzers)](https://www.nuget.org/packages/Utf8StringInterpolation.Analyzers)
4+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5+
6+
Roslyn analyzers for [Utf8StringInterpolation](https://github.com/Cysharp/Utf8StringInterpolation) — the zero-allocation UTF-8 string interpolation library by [Cysharp](https://github.com/Cysharp). These analyzers catch usage patterns that silently defeat the library's performance guarantees at compile time, turning invisible runtime regressions into build errors.
7+
8+
## The problem
9+
10+
`Utf8StringInterpolation` achieves zero allocation by making `Utf8StringWriter<T>` an [interpolated string handler](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler). When you call `AppendFormat($"...")`, the C# compiler routes the interpolation directly into the handler — no intermediate `string` is ever created.
11+
12+
However, `Utf8StringWriter<T>` also exposes `Append(string?)`. When you write `Append($"...")`, the compiler sees a plain `string` parameter with no handler attribute, so it **materializes the entire interpolated string as a heap-allocated `string` first**, then passes it in. The code compiles cleanly, produces correct output, and silently allocates — the exact opposite of the library's purpose.
13+
14+
```csharp
15+
// Looks identical. Behaves very differently.
16+
zsb.Append($"Hello, {name}!"); // allocates a string — WRONG
17+
zsb.AppendFormat($"Hello, {name}!"); // zero allocation — correct
18+
```
19+
20+
This package makes the wrong form a **build error**.
21+
22+
## Diagnostics
23+
24+
### U8SI001 — Use AppendFormat instead of Append with interpolated strings
25+
26+
**Severity:** Error
27+
**Category:** Performance
28+
29+
**Before (build error):**
30+
```csharp
31+
using Utf8StringInterpolation;
32+
33+
var zsb = Utf8String.CreateWriter(stream);
34+
zsb.Append($"Hello, {name}!"); // error U8SI001
35+
zsb.Append($"You have {count} items."); // error U8SI001
36+
```
37+
38+
**After (zero allocation):**
39+
```csharp
40+
zsb.AppendFormat($"Hello, {name}!");
41+
zsb.AppendFormat($"You have {count} items.");
42+
```
43+
44+
A **code fix** is provided. In Visual Studio, click the lightbulb on any U8SI001 error to replace `Append` with `AppendFormat` automatically. On the command line:
45+
46+
```bash
47+
dotnet format analyzers <project> --diagnostics U8SI001 --severity error
48+
```
49+
50+
> **`Append($"literal")` with no interpolation holes is intentionally ignored.** If the interpolated string contains no `{...}` placeholders, the compiler resolves it to a string constant at compile time — there is no heap allocation at runtime. The diagnostic only fires when actual interpolation holes are present, because those are the cases where `AppendFormat` provides a performance benefit.
51+
52+
## Installation
53+
54+
```xml
55+
<PackageReference Include="Utf8StringInterpolation.Analyzers" Version="x.y.z">
56+
<PrivateAssets>all</PrivateAssets>
57+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
58+
</PackageReference>
59+
```
60+
61+
The package contains both the analyzer and the code fix provider. It has no runtime impact — `PrivateAssets="all"` ensures it is not listed as a dependency of your package.
62+
63+
> **Heads up for existing codebases:** Adding this package to a project that already has `Append($"...")` calls produces immediate build errors. Run `dotnet format analyzers --diagnostics U8SI001 --severity error` on the project to fix existing violations before (or immediately after) adding the `PackageReference`.
64+
65+
## Compatibility
66+
67+
- C# projects targeting any .NET version (.NET Framework, .NET Core, .NET 5+)
68+
- Requires the [Utf8StringInterpolation](https://www.nuget.org/packages/Utf8StringInterpolation) NuGet package
69+
- Tested with Visual Studio, VS Code, and `dotnet build`
70+
71+
## How it works
72+
73+
The analyzer registers on every `InvocationExpression` and checks:
74+
75+
1. The method name is `Append`
76+
2. At least one argument is an `InterpolatedStringExpressionSyntax` containing at least one interpolation hole (`{...}`)
77+
3. The receiver type is `Utf8StringInterpolation.Utf8StringWriter<T>` (verified via semantic model, not string matching)
78+
79+
Because `Utf8StringWriter<T>.Append` has no interpolated string handler overload, any `$"..."` argument with holes is necessarily materialized as a heap-allocated `string` by the compiler. Interpolated strings without holes are compile-time constants and are excluded — they have no performance cost.
80+
81+
If all three conditions hold, U8SI001 is reported on the method name. The code fix rewrites the method name to `AppendFormat`, leaving the argument unchanged.
82+
83+
## Related
84+
85+
- [Utf8StringInterpolation](https://github.com/Cysharp/Utf8StringInterpolation) — the library this package guards
86+
- [ZString](https://github.com/Cysharp/ZString) — the predecessor library (`Utf8StringInterpolation` is its successor)
87+
- [InterpolatedStringHandler](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler) — the C# feature that makes zero-allocation interpolation possible
88+
89+
## Contributing
90+
91+
Bug reports and pull requests welcome. The repository includes unit tests (Roslyn testing framework) and an integration test that builds a real .NET Framework project, applies the code fix via `dotnet format`, and verifies the result.
92+
93+
## Versioning
94+
95+
Every push to `master` publishes a new patch version automatically via [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning). The version is `major.minor.<commit-height>`. To bump major or minor, edit `version.json`.
96+
97+
## License
98+
99+
[MIT](LICENSE)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<Solution>
2+
<Folder Name="/src/">
3+
<Project Path="src/Utf8StringInterpolation.Analyzers/Utf8StringInterpolation.Analyzers.csproj" />
4+
<Project Path="src/Utf8StringInterpolation.Analyzers.CodeFixes/Utf8StringInterpolation.Analyzers.CodeFixes.csproj" />
5+
</Folder>
6+
<Folder Name="/tests/">
7+
<Project Path="tests/Utf8StringInterpolation.Analyzers.IntegrationTests/Utf8StringInterpolation.Analyzers.IntegrationTests.csproj" />
8+
<Project Path="tests/Utf8StringInterpolation.Analyzers.Tests/Utf8StringInterpolation.Analyzers.Tests.csproj" />
9+
</Folder>
10+
</Solution>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CodeActions;
3+
using Microsoft.CodeAnalysis.CodeFixes;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using System.Collections.Immutable;
7+
using System.Composition;
8+
9+
namespace Utf8StringInterpolation.Analyzers.CodeFixes;
10+
11+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(Utf8AppendInterpolatedStringCodeFix))]
12+
[Shared]
13+
public sealed class Utf8AppendInterpolatedStringCodeFix : CodeFixProvider
14+
{
15+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = [Utf8AppendInterpolatedStringAnalyzer.DIAGNOSTIC_ID];
16+
17+
public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
18+
19+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
20+
{
21+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
22+
if (root is null)
23+
{
24+
return;
25+
}
26+
27+
var nameNode = root.FindNode(context.Diagnostics[0].Location.SourceSpan);
28+
if (nameNode.Parent is not MemberAccessExpressionSyntax memberAccess)
29+
{
30+
return;
31+
}
32+
33+
context.RegisterCodeFix(
34+
CodeAction.Create(
35+
title: "Replace with AppendFormat",
36+
createChangedDocument: ct => ReplaceWithAppendFormatAsync(context.Document, memberAccess, ct),
37+
equivalenceKey: "ReplaceAppendWithAppendFormat"),
38+
context.Diagnostics[0]);
39+
}
40+
41+
private static async Task<Document> ReplaceWithAppendFormatAsync(
42+
Document document,
43+
MemberAccessExpressionSyntax memberAccess,
44+
CancellationToken cancellationToken)
45+
{
46+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
47+
if (root is null)
48+
{
49+
return document;
50+
}
51+
52+
var newMemberAccess = memberAccess.WithName(SyntaxFactory.IdentifierName("AppendFormat").WithTriviaFrom(memberAccess.Name));
53+
return document.WithSyntaxRoot(root.ReplaceNode(memberAccess, newMemberAccess));
54+
}
55+
}

0 commit comments

Comments
 (0)