Skip to content

Commit 37ed0ee

Browse files
Allow specifying IndentCharacter and IndentSize when writing JSON (#95292)
* Add IndentText json option * Add IndentText for json source generator * Add tests * IndentText must be non-nullable * Improve performance * Add extra tests * Cleanup * Apply suggestions from code review Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com> * Fixes following code review * Fixes following code review #2 * Add tests for invalid characters * Handle RawIndent length * Move all to RawIndentation * Update documentation * Additional fixes from code review * Move to the new API * Extra fixes and enhancements * Fixes from code review * Avoid introducing extra fields in JsonWriterOptions * Fix OOM error * Use bitwise logic for IndentedOrNotSkipValidation * Cache indentation options in Utf8JsonWriter * Add missing test around indentation options * New fixes from code review * Update src/libraries/System.Text.Json/src/System/Text/Json/Writer/Utf8JsonWriter.cs * Add test to check default values of the JsonWriterOptions properties * Fix comment --------- Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com>
1 parent 0823c5c commit 37ed0ee

File tree

45 files changed

+1087
-1131
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1087
-1131
lines changed

src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public void EnsureConsoleLoggerOptions_ConfigureOptions_SupportsAllProperties()
2525
Assert.Equal(3, typeof(ConsoleFormatterOptions).GetProperties(flags).Length);
2626
Assert.Equal(5, typeof(SimpleConsoleFormatterOptions).GetProperties(flags).Length);
2727
Assert.Equal(4, typeof(JsonConsoleFormatterOptions).GetProperties(flags).Length);
28-
Assert.Equal(4, typeof(JsonWriterOptions).GetProperties(flags).Length);
28+
Assert.Equal(6, typeof(JsonWriterOptions).GetProperties(flags).Length);
2929
}
3030

3131
[Theory]

src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerExtensionsTests.cs

+1
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,7 @@ private static void VerifyHasOnlySimpleProperties(Type type)
597597
// or else NativeAOT would break
598598
Assert.True(prop.PropertyType == typeof(string) ||
599599
prop.PropertyType == typeof(bool) ||
600+
prop.PropertyType == typeof(char) ||
600601
prop.PropertyType == typeof(int) ||
601602
prop.PropertyType.IsEnum, $"ConsoleOptions property '{type.Name}.{prop.Name}' must be a simple type in order for NativeAOT to work");
602603
}

src/libraries/System.Text.Json/Common/JsonSourceGenerationOptionsAttribute.cs

+10
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,16 @@ public JsonSourceGenerationOptionsAttribute(JsonSerializerDefaults defaults)
125125
/// </summary>
126126
public bool WriteIndented { get; set; }
127127

128+
/// <summary>
129+
/// Specifies the default value of <see cref="JsonSerializerOptions.IndentCharacter"/> when set.
130+
/// </summary>
131+
public char IndentCharacter { get; set; }
132+
133+
/// <summary>
134+
/// Specifies the default value of <see cref="JsonSerializerOptions.IndentCharacter"/> when set.
135+
/// </summary>
136+
public int IndentSize { get; set; }
137+
128138
/// <summary>
129139
/// Specifies the default source generation mode for type declarations that don't set a <see cref="JsonSerializableAttribute.GenerationMode"/>.
130140
/// </summary>

src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs

+7
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,12 @@ private static void GetLogicForDefaultSerializerOptionsInit(SourceGenerationOpti
11681168
if (optionsSpec.WriteIndented is bool writeIndented)
11691169
writer.WriteLine($"WriteIndented = {FormatBool(writeIndented)},");
11701170

1171+
if (optionsSpec.IndentCharacter is char indentCharacter)
1172+
writer.WriteLine($"IndentCharacter = {FormatIndentChar(indentCharacter)},");
1173+
1174+
if (optionsSpec.IndentSize is int indentSize)
1175+
writer.WriteLine($"IndentSize = {indentSize},");
1176+
11711177
writer.Indentation--;
11721178
writer.WriteLine("};");
11731179

@@ -1344,6 +1350,7 @@ private static string FormatJsonSerializerDefaults(JsonSerializerDefaults defaul
13441350

13451351
private static string FormatBool(bool value) => value ? "true" : "false";
13461352
private static string FormatStringLiteral(string? value) => value is null ? "null" : $"\"{value}\"";
1353+
private static string FormatIndentChar(char value) => value is '\t' ? "'\\t'" : $"'{value}'";
13471354

13481355
/// <summary>
13491356
/// Method used to generate JsonTypeInfo given options instance

src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs

+12
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,8 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
280280
JsonUnmappedMemberHandling? unmappedMemberHandling = null;
281281
bool? useStringEnumConverter = null;
282282
bool? writeIndented = null;
283+
char? indentCharacter = null;
284+
int? indentSize = null;
283285

284286
if (attributeData.ConstructorArguments.Length > 0)
285287
{
@@ -373,6 +375,14 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
373375
writeIndented = (bool)namedArg.Value.Value!;
374376
break;
375377

378+
case nameof(JsonSourceGenerationOptionsAttribute.IndentCharacter):
379+
indentCharacter = (char)namedArg.Value.Value!;
380+
break;
381+
382+
case nameof(JsonSourceGenerationOptionsAttribute.IndentSize):
383+
indentSize = (int)namedArg.Value.Value!;
384+
break;
385+
376386
case nameof(JsonSourceGenerationOptionsAttribute.GenerationMode):
377387
generationMode = (JsonSourceGenerationMode)namedArg.Value.Value!;
378388
break;
@@ -404,6 +414,8 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
404414
UnmappedMemberHandling = unmappedMemberHandling,
405415
UseStringEnumConverter = useStringEnumConverter,
406416
WriteIndented = writeIndented,
417+
IndentCharacter = indentCharacter,
418+
IndentSize = indentSize,
407419
};
408420
}
409421

src/libraries/System.Text.Json/gen/Model/SourceGenerationOptionsSpec.cs

+4
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ public sealed record SourceGenerationOptionsSpec
5252

5353
public required bool? WriteIndented { get; init; }
5454

55+
public required char? IndentCharacter { get; init; }
56+
57+
public required int? IndentSize { get; init; }
58+
5559
public JsonKnownNamingPolicy? GetEffectivePropertyNamingPolicy()
5660
=> PropertyNamingPolicy ?? (Defaults is JsonSerializerDefaults.Web ? JsonKnownNamingPolicy.CamelCase : null);
5761
}

src/libraries/System.Text.Json/ref/System.Text.Json.cs

+6
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,8 @@ public JsonSerializerOptions(System.Text.Json.JsonSerializerOptions options) { }
395395
public System.Text.Json.Serialization.JsonUnknownTypeHandling UnknownTypeHandling { get { throw null; } set { } }
396396
public System.Text.Json.Serialization.JsonUnmappedMemberHandling UnmappedMemberHandling { get { throw null; } set { } }
397397
public bool WriteIndented { get { throw null; } set { } }
398+
public char IndentCharacter { get { throw null; } set { } }
399+
public int IndentSize { get { throw null; } set { } }
398400
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
399401
[System.ObsoleteAttribute("JsonSerializerOptions.AddContext is obsolete. To register a JsonSerializerContext, use either the TypeInfoResolver or TypeInfoResolverChain properties.", DiagnosticId="SYSLIB0049", UrlFormat="https://aka.ms/dotnet-warnings/{0}")]
400402
public void AddContext<TContext>() where TContext : System.Text.Json.Serialization.JsonSerializerContext, new() { }
@@ -440,6 +442,8 @@ public partial struct JsonWriterOptions
440442
private int _dummyPrimitive;
441443
public System.Text.Encodings.Web.JavaScriptEncoder? Encoder { readonly get { throw null; } set { } }
442444
public bool Indented { get { throw null; } set { } }
445+
public char IndentCharacter { get { throw null; } set { } }
446+
public int IndentSize { get { throw null; } set { } }
443447
public int MaxDepth { readonly get { throw null; } set { } }
444448
public bool SkipValidation { get { throw null; } set { } }
445449
}
@@ -1075,6 +1079,8 @@ public JsonSourceGenerationOptionsAttribute(System.Text.Json.JsonSerializerDefau
10751079
public System.Text.Json.Serialization.JsonUnmappedMemberHandling UnmappedMemberHandling { get { throw null; } set { } }
10761080
public bool UseStringEnumConverter { get { throw null; } set { } }
10771081
public bool WriteIndented { get { throw null; } set { } }
1082+
public char IndentCharacter { get { throw null; } set { } }
1083+
public int IndentSize { get { throw null; } set { } }
10781084
}
10791085
[System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute("JsonStringEnumConverter cannot be statically analyzed and requires runtime code generation. Applications should use the generic JsonStringEnumConverter<TEnum> instead.")]
10801086
public partial class JsonStringEnumConverter : System.Text.Json.Serialization.JsonConverterFactory

src/libraries/System.Text.Json/src/Resources/Strings.resx

+6
Original file line numberDiff line numberDiff line change
@@ -708,4 +708,10 @@
708708
<data name="FormatHalf" xml:space="preserve">
709709
<value>Either the JSON value is not in a supported format, or is out of bounds for a Half.</value>
710710
</data>
711+
<data name="InvalidIndentCharacter" xml:space="preserve">
712+
<value>Supported indentation characters are space and horizontal tab.</value>
713+
</data>
714+
<data name="InvalidIndentSize" xml:space="preserve">
715+
<value>Indentation size must be between {0} and {1}.</value>
716+
</data>
711717
</root>

src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs

+8-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ internal static partial class JsonConstants
4747
// Explicitly skipping ReverseSolidus since that is handled separately
4848
public static ReadOnlySpan<byte> EscapableChars => "\"nrt/ubf"u8;
4949

50-
public const int SpacesPerIndent = 2;
5150
public const int RemoveFlagsBitMask = 0x7FFFFFFF;
5251

5352
// In the worst case, an ASCII character represented as a single utf-8 byte could expand 6x when escaped.
@@ -110,5 +109,13 @@ internal static partial class JsonConstants
110109
// The maximum number of parameters a constructor can have where it can be considered
111110
// for a path on deserialization where we don't box the constructor arguments.
112111
public const int UnboxedParameterCountThreshold = 4;
112+
113+
// Two space characters is the default indentation.
114+
public const char DefaultIndentCharacter = ' ';
115+
public const char TabIndentCharacter = '\t';
116+
public const int DefaultIndentSize = 2;
117+
public const int MinimumIndentSize = 0;
118+
public const int MaximumIndentSize = 127; // If this value is changed, the impact on the options masking used in the JsonWriterOptions struct must be checked carefully.
119+
113120
}
114121
}

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Caching.cs

+4
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,8 @@ public bool Equals(JsonSerializerOptions? left, JsonSerializerOptions? right)
511511
left._includeFields == right._includeFields &&
512512
left._propertyNameCaseInsensitive == right._propertyNameCaseInsensitive &&
513513
left._writeIndented == right._writeIndented &&
514+
left._indentCharacter == right._indentCharacter &&
515+
left._indentSize == right._indentSize &&
514516
left._typeInfoResolver == right._typeInfoResolver &&
515517
CompareLists(left._converters, right._converters);
516518

@@ -565,6 +567,8 @@ public int GetHashCode(JsonSerializerOptions options)
565567
AddHashCode(ref hc, options._includeFields);
566568
AddHashCode(ref hc, options._propertyNameCaseInsensitive);
567569
AddHashCode(ref hc, options._writeIndented);
570+
AddHashCode(ref hc, options._indentCharacter);
571+
AddHashCode(ref hc, options._indentSize);
568572
AddHashCode(ref hc, options._typeInfoResolver);
569573
AddListHashCode(ref hc, options._converters);
570574

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs

+50
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ public static JsonSerializerOptions Web
9090
private bool _includeFields;
9191
private bool _propertyNameCaseInsensitive;
9292
private bool _writeIndented;
93+
private char _indentCharacter = JsonConstants.DefaultIndentCharacter;
94+
private int _indentSize = JsonConstants.DefaultIndentSize;
9395

9496
/// <summary>
9597
/// Constructs a new <see cref="JsonSerializerOptions"/> instance.
@@ -139,6 +141,8 @@ public JsonSerializerOptions(JsonSerializerOptions options)
139141
_includeFields = options._includeFields;
140142
_propertyNameCaseInsensitive = options._propertyNameCaseInsensitive;
141143
_writeIndented = options._writeIndented;
144+
_indentCharacter = options._indentCharacter;
145+
_indentSize = options._indentSize;
142146
_typeInfoResolver = options._typeInfoResolver;
143147
EffectiveMaxDepth = options.EffectiveMaxDepth;
144148
ReferenceHandlingStrategy = options.ReferenceHandlingStrategy;
@@ -660,6 +664,50 @@ public bool WriteIndented
660664
}
661665
}
662666

667+
/// <summary>
668+
/// Defines the indentation character being used when <see cref="WriteIndented" /> is enabled. Defaults to the space character.
669+
/// </summary>
670+
/// <remarks>Allowed characters are space and horizontal tab.</remarks>
671+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> contains an invalid character.</exception>
672+
/// <exception cref="InvalidOperationException">
673+
/// Thrown if this property is set after serialization or deserialization has occurred.
674+
/// </exception>
675+
public char IndentCharacter
676+
{
677+
get
678+
{
679+
return _indentCharacter;
680+
}
681+
set
682+
{
683+
JsonWriterHelper.ValidateIndentCharacter(value);
684+
VerifyMutable();
685+
_indentCharacter = value;
686+
}
687+
}
688+
689+
/// <summary>
690+
/// Defines the indentation size being used when <see cref="WriteIndented" /> is enabled. Defaults to two.
691+
/// </summary>
692+
/// <remarks>Allowed values are all integers between 0 and 127, included.</remarks>
693+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> is out of the allowed range.</exception>
694+
/// <exception cref="InvalidOperationException">
695+
/// Thrown if this property is set after serialization or deserialization has occurred.
696+
/// </exception>
697+
public int IndentSize
698+
{
699+
get
700+
{
701+
return _indentSize;
702+
}
703+
set
704+
{
705+
JsonWriterHelper.ValidateIndentSize(value);
706+
VerifyMutable();
707+
_indentSize = value;
708+
}
709+
}
710+
663711
/// <summary>
664712
/// Configures how object references are handled when reading and writing JSON.
665713
/// </summary>
@@ -891,6 +939,8 @@ internal JsonWriterOptions GetWriterOptions()
891939
{
892940
Encoder = Encoder,
893941
Indented = WriteIndented,
942+
IndentCharacter = IndentCharacter,
943+
IndentSize = IndentSize,
894944
MaxDepth = EffectiveMaxDepth,
895945
#if !DEBUG
896946
SkipValidation = true

src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.cs

+12
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ internal static partial class ThrowHelper
1212
// If the exception source is this value, the serializer will re-throw as JsonException.
1313
public const string ExceptionSourceValueToRethrowAsJsonException = "System.Text.Json.Rethrowable";
1414

15+
[DoesNotReturn]
16+
public static void ThrowArgumentOutOfRangeException_IndentCharacter(string parameterName)
17+
{
18+
throw GetArgumentOutOfRangeException(parameterName, SR.InvalidIndentCharacter);
19+
}
20+
21+
[DoesNotReturn]
22+
public static void ThrowArgumentOutOfRangeException_IndentSize(string parameterName, int minimumSize, int maximumSize)
23+
{
24+
throw GetArgumentOutOfRangeException(parameterName, SR.Format(SR.InvalidIndentSize, minimumSize, maximumSize));
25+
}
26+
1527
[DoesNotReturn]
1628
public static void ThrowArgumentOutOfRangeException_MaxDepthMustBePositive(string parameterName)
1729
{

src/libraries/System.Text.Json/src/System/Text/Json/Writer/JsonWriterHelper.cs

+24-6
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,46 @@ namespace System.Text.Json
1010
{
1111
internal static partial class JsonWriterHelper
1212
{
13-
public static void WriteIndentation(Span<byte> buffer, int indent)
13+
public static void WriteIndentation(Span<byte> buffer, int indent, byte indentByte)
1414
{
15-
Debug.Assert(indent % JsonConstants.SpacesPerIndent == 0);
1615
Debug.Assert(buffer.Length >= indent);
1716

1817
// Based on perf tests, the break-even point where vectorized Fill is faster
1918
// than explicitly writing the space in a loop is 8.
2019
if (indent < 8)
2120
{
2221
int i = 0;
23-
while (i < indent)
22+
while (i + 1 < indent)
2423
{
25-
buffer[i++] = JsonConstants.Space;
26-
buffer[i++] = JsonConstants.Space;
24+
buffer[i++] = indentByte;
25+
buffer[i++] = indentByte;
26+
}
27+
28+
if (i < indent)
29+
{
30+
buffer[i] = indentByte;
2731
}
2832
}
2933
else
3034
{
31-
buffer.Slice(0, indent).Fill(JsonConstants.Space);
35+
buffer.Slice(0, indent).Fill(indentByte);
3236
}
3337
}
3438

39+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
40+
public static void ValidateIndentCharacter(char value)
41+
{
42+
if (value is not JsonConstants.DefaultIndentCharacter and not JsonConstants.TabIndentCharacter)
43+
ThrowHelper.ThrowArgumentOutOfRangeException_IndentCharacter(nameof(value));
44+
}
45+
46+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
47+
public static void ValidateIndentSize(int value)
48+
{
49+
if (value is < JsonConstants.MinimumIndentSize or > JsonConstants.MaximumIndentSize)
50+
ThrowHelper.ThrowArgumentOutOfRangeException_IndentSize(nameof(value), JsonConstants.MinimumIndentSize, JsonConstants.MaximumIndentSize);
51+
}
52+
3553
[MethodImpl(MethodImplOptions.AggressiveInlining)]
3654
public static void ValidateProperty(ReadOnlySpan<byte> propertyName)
3755
{

src/libraries/System.Text.Json/src/System/Text/Json/Writer/JsonWriterOptions.cs

+45-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,48 @@ public bool Indented
4343
}
4444
}
4545

46+
/// <summary>
47+
/// Defines the indentation character used by <see cref="Utf8JsonWriter"/> when <see cref="Indented"/> is enabled. Defaults to the space character.
48+
/// </summary>
49+
/// <remarks>Allowed characters are space and horizontal tab.</remarks>
50+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> contains an invalid character.</exception>
51+
public char IndentCharacter
52+
{
53+
readonly get => (_optionsMask & IndentCharacterBit) != 0 ? JsonConstants.TabIndentCharacter : JsonConstants.DefaultIndentCharacter;
54+
set
55+
{
56+
JsonWriterHelper.ValidateIndentCharacter(value);
57+
if (value is not JsonConstants.DefaultIndentCharacter)
58+
_optionsMask |= IndentCharacterBit;
59+
else
60+
_optionsMask &= ~IndentCharacterBit;
61+
}
62+
}
63+
64+
/// <summary>
65+
/// Defines the indentation size used by <see cref="Utf8JsonWriter"/> when <see cref="Indented"/> is enabled. Defaults to two.
66+
/// </summary>
67+
/// <remarks>Allowed values are integers between 0 and 127, included.</remarks>
68+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> is out of the allowed range.</exception>
69+
public int IndentSize
70+
{
71+
readonly get => EncodeIndentSize((_optionsMask & IndentSizeMask) >> 3);
72+
set
73+
{
74+
JsonWriterHelper.ValidateIndentSize(value);
75+
_optionsMask = (_optionsMask & ~IndentSizeMask) | (EncodeIndentSize(value) << 3);
76+
}
77+
}
78+
79+
// Encoding is applied by swapping 0 with the default value to ensure default(JsonWriterOptions) instances are well-defined.
80+
// As this operation is symmetrical, it can also be used to decode.
81+
private static int EncodeIndentSize(int value) => value switch
82+
{
83+
0 => JsonConstants.DefaultIndentSize,
84+
JsonConstants.DefaultIndentSize => 0,
85+
_ => value
86+
};
87+
4688
/// <summary>
4789
/// Gets or sets the maximum depth allowed when writing JSON, with the default (i.e. 0) indicating a max depth of 1000.
4890
/// </summary>
@@ -93,9 +135,11 @@ public bool SkipValidation
93135
}
94136
}
95137

96-
internal bool IndentedOrNotSkipValidation => _optionsMask != SkipValidationBit; // Equivalent to: Indented || !SkipValidation;
138+
internal bool IndentedOrNotSkipValidation => (_optionsMask & (IndentBit | SkipValidationBit)) != SkipValidationBit; // Equivalent to: Indented || !SkipValidation;
97139

98140
private const int IndentBit = 1;
99141
private const int SkipValidationBit = 2;
142+
private const int IndentCharacterBit = 4;
143+
private const int IndentSizeMask = JsonConstants.MaximumIndentSize << 3;
100144
}
101145
}

0 commit comments

Comments
 (0)