Skip to content

Commit

Permalink
Allow specifying IndentCharacter and IndentSize when writing JSON (do…
Browse files Browse the repository at this point in the history
…tnet#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 <[email protected]>

* 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 <[email protected]>
  • Loading branch information
manandre and eiriktsarpalis authored Jan 8, 2024
1 parent 0823c5c commit 37ed0ee
Show file tree
Hide file tree
Showing 45 changed files with 1,087 additions and 1,131 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public void EnsureConsoleLoggerOptions_ConfigureOptions_SupportsAllProperties()
Assert.Equal(3, typeof(ConsoleFormatterOptions).GetProperties(flags).Length);
Assert.Equal(5, typeof(SimpleConsoleFormatterOptions).GetProperties(flags).Length);
Assert.Equal(4, typeof(JsonConsoleFormatterOptions).GetProperties(flags).Length);
Assert.Equal(4, typeof(JsonWriterOptions).GetProperties(flags).Length);
Assert.Equal(6, typeof(JsonWriterOptions).GetProperties(flags).Length);
}

[Theory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ private static void VerifyHasOnlySimpleProperties(Type type)
// or else NativeAOT would break
Assert.True(prop.PropertyType == typeof(string) ||
prop.PropertyType == typeof(bool) ||
prop.PropertyType == typeof(char) ||
prop.PropertyType == typeof(int) ||
prop.PropertyType.IsEnum, $"ConsoleOptions property '{type.Name}.{prop.Name}' must be a simple type in order for NativeAOT to work");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ public JsonSourceGenerationOptionsAttribute(JsonSerializerDefaults defaults)
/// </summary>
public bool WriteIndented { get; set; }

/// <summary>
/// Specifies the default value of <see cref="JsonSerializerOptions.IndentCharacter"/> when set.
/// </summary>
public char IndentCharacter { get; set; }

/// <summary>
/// Specifies the default value of <see cref="JsonSerializerOptions.IndentCharacter"/> when set.
/// </summary>
public int IndentSize { get; set; }

/// <summary>
/// Specifies the default source generation mode for type declarations that don't set a <see cref="JsonSerializableAttribute.GenerationMode"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,12 @@ private static void GetLogicForDefaultSerializerOptionsInit(SourceGenerationOpti
if (optionsSpec.WriteIndented is bool writeIndented)
writer.WriteLine($"WriteIndented = {FormatBool(writeIndented)},");

if (optionsSpec.IndentCharacter is char indentCharacter)
writer.WriteLine($"IndentCharacter = {FormatIndentChar(indentCharacter)},");

if (optionsSpec.IndentSize is int indentSize)
writer.WriteLine($"IndentSize = {indentSize},");

writer.Indentation--;
writer.WriteLine("};");

Expand Down Expand Up @@ -1344,6 +1350,7 @@ private static string FormatJsonSerializerDefaults(JsonSerializerDefaults defaul

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

/// <summary>
/// Method used to generate JsonTypeInfo given options instance
Expand Down
12 changes: 12 additions & 0 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
JsonUnmappedMemberHandling? unmappedMemberHandling = null;
bool? useStringEnumConverter = null;
bool? writeIndented = null;
char? indentCharacter = null;
int? indentSize = null;

if (attributeData.ConstructorArguments.Length > 0)
{
Expand Down Expand Up @@ -373,6 +375,14 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
writeIndented = (bool)namedArg.Value.Value!;
break;

case nameof(JsonSourceGenerationOptionsAttribute.IndentCharacter):
indentCharacter = (char)namedArg.Value.Value!;
break;

case nameof(JsonSourceGenerationOptionsAttribute.IndentSize):
indentSize = (int)namedArg.Value.Value!;
break;

case nameof(JsonSourceGenerationOptionsAttribute.GenerationMode):
generationMode = (JsonSourceGenerationMode)namedArg.Value.Value!;
break;
Expand Down Expand Up @@ -404,6 +414,8 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
UnmappedMemberHandling = unmappedMemberHandling,
UseStringEnumConverter = useStringEnumConverter,
WriteIndented = writeIndented,
IndentCharacter = indentCharacter,
IndentSize = indentSize,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ public sealed record SourceGenerationOptionsSpec

public required bool? WriteIndented { get; init; }

public required char? IndentCharacter { get; init; }

public required int? IndentSize { get; init; }

public JsonKnownNamingPolicy? GetEffectivePropertyNamingPolicy()
=> PropertyNamingPolicy ?? (Defaults is JsonSerializerDefaults.Web ? JsonKnownNamingPolicy.CamelCase : null);
}
Expand Down
6 changes: 6 additions & 0 deletions src/libraries/System.Text.Json/ref/System.Text.Json.cs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ public JsonSerializerOptions(System.Text.Json.JsonSerializerOptions options) { }
public System.Text.Json.Serialization.JsonUnknownTypeHandling UnknownTypeHandling { get { throw null; } set { } }
public System.Text.Json.Serialization.JsonUnmappedMemberHandling UnmappedMemberHandling { get { throw null; } set { } }
public bool WriteIndented { get { throw null; } set { } }
public char IndentCharacter { get { throw null; } set { } }
public int IndentSize { get { throw null; } set { } }
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[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}")]
public void AddContext<TContext>() where TContext : System.Text.Json.Serialization.JsonSerializerContext, new() { }
Expand Down Expand Up @@ -440,6 +442,8 @@ public partial struct JsonWriterOptions
private int _dummyPrimitive;
public System.Text.Encodings.Web.JavaScriptEncoder? Encoder { readonly get { throw null; } set { } }
public bool Indented { get { throw null; } set { } }
public char IndentCharacter { get { throw null; } set { } }
public int IndentSize { get { throw null; } set { } }
public int MaxDepth { readonly get { throw null; } set { } }
public bool SkipValidation { get { throw null; } set { } }
}
Expand Down Expand Up @@ -1075,6 +1079,8 @@ public JsonSourceGenerationOptionsAttribute(System.Text.Json.JsonSerializerDefau
public System.Text.Json.Serialization.JsonUnmappedMemberHandling UnmappedMemberHandling { get { throw null; } set { } }
public bool UseStringEnumConverter { get { throw null; } set { } }
public bool WriteIndented { get { throw null; } set { } }
public char IndentCharacter { get { throw null; } set { } }
public int IndentSize { get { throw null; } set { } }
}
[System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute("JsonStringEnumConverter cannot be statically analyzed and requires runtime code generation. Applications should use the generic JsonStringEnumConverter<TEnum> instead.")]
public partial class JsonStringEnumConverter : System.Text.Json.Serialization.JsonConverterFactory
Expand Down
6 changes: 6 additions & 0 deletions src/libraries/System.Text.Json/src/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -708,4 +708,10 @@
<data name="FormatHalf" xml:space="preserve">
<value>Either the JSON value is not in a supported format, or is out of bounds for a Half.</value>
</data>
<data name="InvalidIndentCharacter" xml:space="preserve">
<value>Supported indentation characters are space and horizontal tab.</value>
</data>
<data name="InvalidIndentSize" xml:space="preserve">
<value>Indentation size must be between {0} and {1}.</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ internal static partial class JsonConstants
// Explicitly skipping ReverseSolidus since that is handled separately
public static ReadOnlySpan<byte> EscapableChars => "\"nrt/ubf"u8;

public const int SpacesPerIndent = 2;
public const int RemoveFlagsBitMask = 0x7FFFFFFF;

// In the worst case, an ASCII character represented as a single utf-8 byte could expand 6x when escaped.
Expand Down Expand Up @@ -110,5 +109,13 @@ internal static partial class JsonConstants
// The maximum number of parameters a constructor can have where it can be considered
// for a path on deserialization where we don't box the constructor arguments.
public const int UnboxedParameterCountThreshold = 4;

// Two space characters is the default indentation.
public const char DefaultIndentCharacter = ' ';
public const char TabIndentCharacter = '\t';
public const int DefaultIndentSize = 2;
public const int MinimumIndentSize = 0;
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.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,8 @@ public bool Equals(JsonSerializerOptions? left, JsonSerializerOptions? right)
left._includeFields == right._includeFields &&
left._propertyNameCaseInsensitive == right._propertyNameCaseInsensitive &&
left._writeIndented == right._writeIndented &&
left._indentCharacter == right._indentCharacter &&
left._indentSize == right._indentSize &&
left._typeInfoResolver == right._typeInfoResolver &&
CompareLists(left._converters, right._converters);

Expand Down Expand Up @@ -565,6 +567,8 @@ public int GetHashCode(JsonSerializerOptions options)
AddHashCode(ref hc, options._includeFields);
AddHashCode(ref hc, options._propertyNameCaseInsensitive);
AddHashCode(ref hc, options._writeIndented);
AddHashCode(ref hc, options._indentCharacter);
AddHashCode(ref hc, options._indentSize);
AddHashCode(ref hc, options._typeInfoResolver);
AddListHashCode(ref hc, options._converters);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ public static JsonSerializerOptions Web
private bool _includeFields;
private bool _propertyNameCaseInsensitive;
private bool _writeIndented;
private char _indentCharacter = JsonConstants.DefaultIndentCharacter;
private int _indentSize = JsonConstants.DefaultIndentSize;

/// <summary>
/// Constructs a new <see cref="JsonSerializerOptions"/> instance.
Expand Down Expand Up @@ -139,6 +141,8 @@ public JsonSerializerOptions(JsonSerializerOptions options)
_includeFields = options._includeFields;
_propertyNameCaseInsensitive = options._propertyNameCaseInsensitive;
_writeIndented = options._writeIndented;
_indentCharacter = options._indentCharacter;
_indentSize = options._indentSize;
_typeInfoResolver = options._typeInfoResolver;
EffectiveMaxDepth = options.EffectiveMaxDepth;
ReferenceHandlingStrategy = options.ReferenceHandlingStrategy;
Expand Down Expand Up @@ -660,6 +664,50 @@ public bool WriteIndented
}
}

/// <summary>
/// Defines the indentation character being used when <see cref="WriteIndented" /> is enabled. Defaults to the space character.
/// </summary>
/// <remarks>Allowed characters are space and horizontal tab.</remarks>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> contains an invalid character.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown if this property is set after serialization or deserialization has occurred.
/// </exception>
public char IndentCharacter
{
get
{
return _indentCharacter;
}
set
{
JsonWriterHelper.ValidateIndentCharacter(value);
VerifyMutable();
_indentCharacter = value;
}
}

/// <summary>
/// Defines the indentation size being used when <see cref="WriteIndented" /> is enabled. Defaults to two.
/// </summary>
/// <remarks>Allowed values are all integers between 0 and 127, included.</remarks>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> is out of the allowed range.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown if this property is set after serialization or deserialization has occurred.
/// </exception>
public int IndentSize
{
get
{
return _indentSize;
}
set
{
JsonWriterHelper.ValidateIndentSize(value);
VerifyMutable();
_indentSize = value;
}
}

/// <summary>
/// Configures how object references are handled when reading and writing JSON.
/// </summary>
Expand Down Expand Up @@ -891,6 +939,8 @@ internal JsonWriterOptions GetWriterOptions()
{
Encoder = Encoder,
Indented = WriteIndented,
IndentCharacter = IndentCharacter,
IndentSize = IndentSize,
MaxDepth = EffectiveMaxDepth,
#if !DEBUG
SkipValidation = true
Expand Down
12 changes: 12 additions & 0 deletions src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ internal static partial class ThrowHelper
// If the exception source is this value, the serializer will re-throw as JsonException.
public const string ExceptionSourceValueToRethrowAsJsonException = "System.Text.Json.Rethrowable";

[DoesNotReturn]
public static void ThrowArgumentOutOfRangeException_IndentCharacter(string parameterName)
{
throw GetArgumentOutOfRangeException(parameterName, SR.InvalidIndentCharacter);
}

[DoesNotReturn]
public static void ThrowArgumentOutOfRangeException_IndentSize(string parameterName, int minimumSize, int maximumSize)
{
throw GetArgumentOutOfRangeException(parameterName, SR.Format(SR.InvalidIndentSize, minimumSize, maximumSize));
}

[DoesNotReturn]
public static void ThrowArgumentOutOfRangeException_MaxDepthMustBePositive(string parameterName)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,46 @@ namespace System.Text.Json
{
internal static partial class JsonWriterHelper
{
public static void WriteIndentation(Span<byte> buffer, int indent)
public static void WriteIndentation(Span<byte> buffer, int indent, byte indentByte)
{
Debug.Assert(indent % JsonConstants.SpacesPerIndent == 0);
Debug.Assert(buffer.Length >= indent);

// Based on perf tests, the break-even point where vectorized Fill is faster
// than explicitly writing the space in a loop is 8.
if (indent < 8)
{
int i = 0;
while (i < indent)
while (i + 1 < indent)
{
buffer[i++] = JsonConstants.Space;
buffer[i++] = JsonConstants.Space;
buffer[i++] = indentByte;
buffer[i++] = indentByte;
}

if (i < indent)
{
buffer[i] = indentByte;
}
}
else
{
buffer.Slice(0, indent).Fill(JsonConstants.Space);
buffer.Slice(0, indent).Fill(indentByte);
}
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ValidateIndentCharacter(char value)
{
if (value is not JsonConstants.DefaultIndentCharacter and not JsonConstants.TabIndentCharacter)
ThrowHelper.ThrowArgumentOutOfRangeException_IndentCharacter(nameof(value));
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ValidateIndentSize(int value)
{
if (value is < JsonConstants.MinimumIndentSize or > JsonConstants.MaximumIndentSize)
ThrowHelper.ThrowArgumentOutOfRangeException_IndentSize(nameof(value), JsonConstants.MinimumIndentSize, JsonConstants.MaximumIndentSize);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ValidateProperty(ReadOnlySpan<byte> propertyName)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,48 @@ public bool Indented
}
}

/// <summary>
/// Defines the indentation character used by <see cref="Utf8JsonWriter"/> when <see cref="Indented"/> is enabled. Defaults to the space character.
/// </summary>
/// <remarks>Allowed characters are space and horizontal tab.</remarks>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> contains an invalid character.</exception>
public char IndentCharacter
{
readonly get => (_optionsMask & IndentCharacterBit) != 0 ? JsonConstants.TabIndentCharacter : JsonConstants.DefaultIndentCharacter;
set
{
JsonWriterHelper.ValidateIndentCharacter(value);
if (value is not JsonConstants.DefaultIndentCharacter)
_optionsMask |= IndentCharacterBit;
else
_optionsMask &= ~IndentCharacterBit;
}
}

/// <summary>
/// Defines the indentation size used by <see cref="Utf8JsonWriter"/> when <see cref="Indented"/> is enabled. Defaults to two.
/// </summary>
/// <remarks>Allowed values are integers between 0 and 127, included.</remarks>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> is out of the allowed range.</exception>
public int IndentSize
{
readonly get => EncodeIndentSize((_optionsMask & IndentSizeMask) >> 3);
set
{
JsonWriterHelper.ValidateIndentSize(value);
_optionsMask = (_optionsMask & ~IndentSizeMask) | (EncodeIndentSize(value) << 3);
}
}

// Encoding is applied by swapping 0 with the default value to ensure default(JsonWriterOptions) instances are well-defined.
// As this operation is symmetrical, it can also be used to decode.
private static int EncodeIndentSize(int value) => value switch
{
0 => JsonConstants.DefaultIndentSize,
JsonConstants.DefaultIndentSize => 0,
_ => value
};

/// <summary>
/// Gets or sets the maximum depth allowed when writing JSON, with the default (i.e. 0) indicating a max depth of 1000.
/// </summary>
Expand Down Expand Up @@ -93,9 +135,11 @@ public bool SkipValidation
}
}

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

private const int IndentBit = 1;
private const int SkipValidationBit = 2;
private const int IndentCharacterBit = 4;
private const int IndentSizeMask = JsonConstants.MaximumIndentSize << 3;
}
}
Loading

0 comments on commit 37ed0ee

Please sign in to comment.