Skip to content

Commit 00f2d61

Browse files
author
Mark Kharitonov
committed
Initial Commit
0 parents  commit 00f2d61

38 files changed

Lines changed: 988 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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ master ]
6+
pull_request:
7+
branches: [ master ]
8+
9+
jobs:
10+
build-and-test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
with:
15+
fetch-depth: 0 # NBGV needs full history to compute git height
16+
17+
- name: Setup .NET
18+
uses: actions/setup-dotnet@v4
19+
with:
20+
dotnet-version: '10.0.x'
21+
22+
- name: Restore
23+
run: dotnet restore
24+
25+
- name: Build
26+
run: dotnet build --no-restore --configuration Release
27+
28+
- name: Test
29+
run: dotnet test --no-build --configuration Release --verbosity normal

.github/workflows/publish.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Publish to NuGet
2+
3+
on:
4+
push:
5+
branches: [ master ]
6+
7+
jobs:
8+
publish:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
with:
13+
fetch-depth: 0 # NBGV needs full history to compute git height
14+
15+
- name: Setup .NET
16+
uses: actions/setup-dotnet@v4
17+
with:
18+
dotnet-version: '10.0.x'
19+
20+
- name: Restore
21+
run: dotnet restore
22+
23+
- name: Build
24+
run: dotnet build --no-restore --configuration Release
25+
26+
- name: Test
27+
run: dotnet test --no-build --configuration Release --verbosity normal
28+
29+
- name: Pack
30+
run: >
31+
dotnet pack src/Utf8StringInterpolation.Analyzers/Utf8StringInterpolation.Analyzers.csproj
32+
--no-build
33+
--configuration Release
34+
--output ./nupkg
35+
36+
- name: Publish to NuGet
37+
run: >
38+
dotnet nuget push ./nupkg/*.nupkg
39+
--api-key ${{ secrets.NUGET_API_KEY }}
40+
--source https://api.nuget.org/v3/index.json
41+
--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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
## Installation
51+
52+
```xml
53+
<PackageReference Include="Utf8StringInterpolation.Analyzers" Version="x.y.z">
54+
<PrivateAssets>all</PrivateAssets>
55+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
56+
</PackageReference>
57+
```
58+
59+
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.
60+
61+
## Compatibility
62+
63+
- C# projects targeting any .NET version (.NET Framework, .NET Core, .NET 5+)
64+
- Requires the [Utf8StringInterpolation](https://www.nuget.org/packages/Utf8StringInterpolation) NuGet package
65+
- Tested with Visual Studio, VS Code, and `dotnet build`
66+
67+
## How it works
68+
69+
The analyzer registers on every `InvocationExpression` and checks:
70+
71+
1. The method name is `Append`
72+
2. At least one argument is an `InterpolatedStringExpressionSyntax` — meaning the compiler resolved it to `Append(string?)`, not to an interpolated string handler
73+
3. The receiver type is `Utf8StringInterpolation.Utf8StringWriter<T>` (verified via semantic model, not string matching)
74+
75+
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.
76+
77+
## Related
78+
79+
- [Utf8StringInterpolation](https://github.com/Cysharp/Utf8StringInterpolation) — the library this package guards
80+
- [ZString](https://github.com/Cysharp/ZString) — the predecessor library (`Utf8StringInterpolation` is its successor)
81+
- [InterpolatedStringHandler](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler) — the C# feature that makes zero-allocation interpolation possible
82+
83+
## Contributing
84+
85+
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.
86+
87+
## Versioning
88+
89+
Every push to `main` 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`.
90+
91+
## License
92+
93+
[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)