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
5 changes: 4 additions & 1 deletion src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,12 @@ internal static string ToNullableCSharpStringLiteral(string? value) =>
/// <summary>Appends one escaped C# string-literal character.</summary>
/// <param name="builder">The target builder.</param>
/// <param name="character">The character to append.</param>
/// <param name="isInterpolated">Indicates whether the character is part of an interpolated string.</param>
[SuppressMessage(
"Maintainability",
"SST1442:A function has too many direct branch points",
Justification = "A compact switch avoids a dictionary or repeated helper calls on the generator hot path.")]
internal static void AppendEscapedCharacter(PooledStringBuilder builder, char character) =>
internal static void AppendEscapedCharacter(PooledStringBuilder builder, char character, bool isInterpolated) =>
_ = character switch
{
'\\' => builder.Append(@"\\"),
Expand All @@ -136,6 +137,8 @@ internal static void AppendEscapedCharacter(PooledStringBuilder builder, char ch
'\r' => builder.Append(@"\r"),
'\t' => builder.Append(@"\t"),
'\v' => builder.Append(@"\v"),
'{' when isInterpolated => builder.Append("{{"),
'}' when isInterpolated => builder.Append("}}"),

// Line terminators that would break out of a regular C# string literal (CS1010).
'\u0085' => builder.Append(@"\u0085"),
Expand Down
16 changes: 10 additions & 6 deletions src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Object.cs
Original file line number Diff line number Diff line change
Expand Up @@ -310,22 +310,26 @@ internal static string BuildNestedKeyExpression(
in InlineValueEmission emission,
in QueryObjectContext context)
{
var prefixExpr = $"{parentKeyExpr} + {ToCSharpStringLiteral(delimiter + (property.PrefixSegment ?? string.Empty))}";
var interpolatedString = new InterpolatedStringBuilder()
.AppendExpression(parentKeyExpr)
.AppendLiteral(delimiter)
.AppendLiteral(property.PrefixSegment ?? string.Empty);

// An [AliasAs] name always wins and bypasses the key formatter.
if (property.ExplicitName is { } alias)
{
return $"{prefixExpr} + {ToCSharpStringLiteral(alias)}";
return interpolatedString.AppendLiteral(alias).Build();
}

var propertyNameExpr = ToCSharpStringLiteral(property.ClrName);
var rawStringLiteral = interpolatedString.BuildRaw();
const string methodCalled = "global::Refit.GeneratedRequestRunner.BuildQueryKey";
var formatterCall = context.PreEscapedKeys
? $"{prefixExpr} + {propertyNameExpr}"
: $"global::Refit.GeneratedRequestRunner.BuildQueryKey({emission.SettingsLocal}, {propertyNameExpr}, null, {prefixExpr})";
? new InterpolatedStringBuilder().FromRaw(rawStringLiteral).AppendLiteral(property.ClrName).Build()
: $"{methodCalled}({emission.SettingsLocal}, {ToCSharpStringLiteral(property.ClrName)}, null, {new InterpolatedStringBuilder().FromRaw(rawStringLiteral).Build()})";

// A [JsonPropertyName] name is honored only when the runtime setting is enabled.
return property.SerializerName is { } serializerName
? $"{emission.SettingsLocal}.{HonorSerializerNamesFlag} ? ({prefixExpr} + {ToCSharpStringLiteral(serializerName)}) : ({formatterCall})"
? $"{emission.SettingsLocal}.{HonorSerializerNamesFlag} ? ({new InterpolatedStringBuilder().FromRaw(rawStringLiteral).AppendLiteral(serializerName).Build()}) : ({formatterCall})"
: formatterCall;
}

Expand Down
2 changes: 1 addition & 1 deletion src/InterfaceStubGenerator.Shared/Emitter.Inline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ internal static string BuildInlineHeaders(in RequestModel request, string reques
{
// An [Authorize] parameter carries a "{scheme} " prefix; a plain [Header] has none.
var headerValueExpression = parameter.HeaderValuePrefix is { } valuePrefix
? $"{ToCSharpStringLiteral(valuePrefix)} + {BuildHeaderValueExpression(parameter)}"
? new InterpolatedStringBuilder().AppendLiteral(valuePrefix).AppendExpression(BuildHeaderValueExpression(parameter)).Build()
: BuildHeaderValueExpression(parameter);
sb ??= new PooledStringBuilder();
var headerName = ToCSharpStringLiteral(parameter.HeaderName);
Expand Down
72 changes: 71 additions & 1 deletion src/InterfaceStubGenerator.Shared/Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ internal static string ToCSharpStringLiteral(string value)
_ = builder.Append('"');
foreach (var c in value)
{
AppendEscapedCharacter(builder, c);
AppendEscapedCharacter(builder, c, false);
}

_ = builder.Append('"');
Expand Down Expand Up @@ -599,4 +599,74 @@ internal static void AppendDisposableMethod(PooledStringBuilder builder, bool sh

""");
}

/// <summary>Represents a builder for interpolated strings.</summary>
internal sealed class InterpolatedStringBuilder
{
/// <summary>The internal string builder used to accumulate the interpolated string content.</summary>
private readonly PooledStringBuilder _builder;

/// <summary>Indicates if the builder has any content.</summary>
private bool _hasContent;

/// <summary>Initializes a new instance of the <see cref="InterpolatedStringBuilder"/> class.</summary>
internal InterpolatedStringBuilder()
{
_builder = new();
}

/// <summary>Appends a c# expression to the interpolated string.</summary>
/// <param name="expression">The C# expression.</param>
/// <returns>The interpolated string builder.</returns>
internal InterpolatedStringBuilder AppendExpression(string expression)
{
Initialize();
_ = _builder.Append('{').Append(expression).Append('}');
return this;
}

/// <summary>Appends a literal.</summary>
/// <param name="literal">The literal string.</param>
/// <returns>The interpolated string builder.</returns>
internal InterpolatedStringBuilder AppendLiteral(string literal)
{
Initialize();
foreach (var c in literal)
{
AppendEscapedCharacter(_builder, c, true);
}

return this;
}

/// <summary>Builds the final interpolated string representation.</summary>
/// <returns>The interpolated string.</returns>
internal string Build() => _builder.Append('"').ToString();

/// <summary>Builds the final raw string representation without the final quote.</summary>
/// <returns>The raw string.</returns>
internal string BuildRaw() => _builder.ToString();

/// <summary>Appends the specified raw string to the builder.</summary>
/// <param name="raw">The raw string.</param>
/// <returns>The interpolated string builder.</returns>
internal InterpolatedStringBuilder FromRaw(string raw)
{
_ = _builder.Append(raw);
_hasContent = true;
return this;
}

/// <summary>Initializes the builder if it has not been initialized.</summary>
private void Initialize()
{
if (_hasContent)
{
return;
}

_hasContent = true;
_ = _builder.Append('$').Append('"');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

Expand Down Expand Up @@ -51,12 +46,12 @@ public async Task AppendEscapedCharacter_HandlesSpecialCharacters()
{
var builder = new PooledStringBuilder();

foreach (var value in new[] { '\\', '"', '\0', '\a', '\b', '\f', '\n', '\r', '\t', '\v', '\u0085', '\u2028', '\u2029', 'x' })
foreach (var value in new[] { '\\', '"', '\0', '\a', '\b', '\f', '\n', '\r', '\t', '\v', '\u0085', '\u2028', '\u2029', '{', '}', 'x' })
{
Emitter.AppendEscapedCharacter(builder, value);
Emitter.AppendEscapedCharacter(builder, value, false);
}

await Assert.That(builder.ToString()).IsEqualTo("""\\\"\0\a\b\f\n\r\t\v\u0085\u2028\u2029x""");
await Assert.That(builder.ToString()).IsEqualTo("""\\\"\0\a\b\f\n\r\t\v\u0085\u2028\u2029{}x""");
}

/// <summary>Verifies unguarded indexed query emission and the shared empty parameter provider.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ public async Task EmptyIndexedDelimiterUsesDotFallback()

await Assert.That(result.CompilesWithoutErrors).IsTrue();
await Assert.That(result.GeneratedSources[Hint]).Contains("items[{");
await Assert.That(result.GeneratedSources[Hint]).Contains("}.Id");
}

/// <summary>Verifies a Indexed collection whose element type has a scalar collection property flattens inline
Expand Down
140 changes: 140 additions & 0 deletions src/tests/Refit.GeneratorTests/InterpolatedStringBuilderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using Refit.Generator;

namespace Refit.GeneratorTests;

/// <summary>Tests interpolated strings emitted by the generator.</summary>
public sealed class InterpolatedStringBuilderTest
{
/// <summary>Verifies literal interpolation braces are doubled.</summary>
/// <returns>A task representing the test.</returns>
[Test]
public async Task BuilderEscapesLiteralBraces()
{
var actual = new Emitter.InterpolatedStringBuilder()
.AppendLiteral("{zip}")
.AppendExpression("value")
.Build();

await Assert.That(actual).IsEqualTo("$\"{{zip}}{value}\"");
}

/// <summary>Verifies braces in a nested alias produce valid generated code.</summary>
/// <returns>A task representing the test.</returns>
[Test]
public Task NestedAliasContainingBracesCompiles() =>
AssertGeneratedCodeCompiles(
"""
using System.Threading.Tasks;
using Refit;

namespace RefitGeneratorTest;

public sealed class Inner
{
[AliasAs("{zip}")]
public string Zip { get; set; } = "";
}

public sealed class QueryModel
{
public Inner Nested { get; set; } = new();
}

public interface IGeneratedClient
{
[Get("/query")]
Task<string> Find([Query] QueryModel query);
}
""");

/// <summary>Verifies braces in a serializer name produce valid generated code.</summary>
/// <returns>A task representing the test.</returns>
[Test]
public Task NestedSerializerNameContainingBracesCompiles() =>
AssertGeneratedCodeCompiles(
"""
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Refit;

namespace RefitGeneratorTest;

public sealed class Inner
{
[JsonPropertyName("{zip}")]
public string Zip { get; set; } = "";
}

public sealed class QueryModel
{
public Inner Nested { get; set; } = new();
}

public interface IGeneratedClient
{
[Get("/query")]
Task<string> Find([Query] QueryModel query);
}
""");

/// <summary>Verifies braces in query prefixes and delimiters produce valid generated code.</summary>
/// <returns>A task representing the test.</returns>
[Test]
public Task QueryPrefixAndDelimiterContainingBracesCompile() =>
AssertGeneratedCodeCompiles(
"""
using System.Threading.Tasks;
using Refit;

namespace RefitGeneratorTest;

public sealed class Inner
{
public string Value { get; set; } = "";
}

public sealed class QueryModel
{
[Query("{delimiter}", "{prefix}")]
public Inner Nested { get; set; } = new();
}

public interface IGeneratedClient
{
[Get("/query")]
Task<string> Find([Query] QueryModel query);
}
""");

/// <summary>Verifies braces in authorization schemes produce valid generated code.</summary>
/// <returns>A task representing the test.</returns>
[Test]
public Task AuthorizationSchemeContainingBracesCompiles() =>
AssertGeneratedCodeCompiles(
"""
using System.Threading.Tasks;
using Refit;

namespace RefitGeneratorTest;

public interface IGeneratedClient
{
[Get("/query")]
Task<string> Find([Authorize("{Bearer}")] string token);
}
""");

/// <summary>Runs the generator and verifies its output compiles.</summary>
/// <param name="source">The source being compiled.</param>
/// <returns>A task representing the assertion.</returns>
private static async Task AssertGeneratedCodeCompiles(string source)
{
var result = Fixture.RunGenerator(source, generatedRequestBuilding: true);

await Assert.That(result.CompilationErrors).IsEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,6 @@ public interface IGeneratedClient

await Assert.That(result.CompilesWithoutErrors).IsTrue();
await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall);
await Assert.That(generated).Contains("\"Bearer \"");
await Assert.That(generated).Contains("\"Bearer {");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@ public interface IGeneratedClient
await Assert.That(result.CompilesWithoutErrors).IsTrue();
await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall);
await Assert.That(generated).Contains("\"Authorization\"");
await Assert.That(generated).Contains("\"Bearer \"");
await Assert.That(generated).Contains("\"Token \"");
await Assert.That(generated).Contains("\"Bearer {");
await Assert.That(generated).Contains("\"Token {");
}

/// <summary>Verifies a dotted path placeholder whose intermediate segment property does not exist falls back.</summary>
Expand Down
Loading