diff --git a/src/RazorSdk/Tool/DiscoverCommand.cs b/src/RazorSdk/Tool/DiscoverCommand.cs index d84727d4bcc2..cabec47f92db 100644 --- a/src/RazorSdk/Tool/DiscoverCommand.cs +++ b/src/RazorSdk/Tool/DiscoverCommand.cs @@ -9,7 +9,7 @@ using Microsoft.CodeAnalysis.Razor; using Microsoft.NET.Sdk.Razor.Tool.CommandLineUtils; using Microsoft.NET.Sdk.Razor.Tool.Json; -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.NET.Sdk.Razor.Tool { @@ -244,13 +244,7 @@ private bool HashesEqual(byte[] x, byte[] y) private static void Serialize(Stream stream, IReadOnlyList tagHelpers) { - using (var writer = new StreamWriter(stream, Encoding.UTF8, bufferSize: 4096, leaveOpen: true)) - { - var serializer = new JsonSerializer(); - serializer.Converters.Add(TagHelperDescriptorJsonConverter.Instance); - - serializer.Serialize(writer, tagHelpers); - } + JsonSerializer.Serialize(stream, tagHelpers, TagHelperDescriptorJsonConverter.SerializerOptions); } } } diff --git a/src/RazorSdk/Tool/GenerateCommand.cs b/src/RazorSdk/Tool/GenerateCommand.cs index ee542977d8d8..6f7e63f8fd29 100644 --- a/src/RazorSdk/Tool/GenerateCommand.cs +++ b/src/RazorSdk/Tool/GenerateCommand.cs @@ -9,7 +9,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.NET.Sdk.Razor.Tool.CommandLineUtils; using Microsoft.NET.Sdk.Razor.Tool.Json; -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.NET.Sdk.Razor.Tool { @@ -306,12 +306,7 @@ private static TagHelperCollection GetTagHelpers(string tagHelperManifest) using (var stream = File.OpenRead(tagHelperManifest)) { - var reader = new JsonTextReader(new StreamReader(stream)); - - var serializer = new JsonSerializer(); - serializer.Converters.Add(TagHelperDescriptorJsonConverter.Instance); - - var tagHelpers = serializer.Deserialize>(reader); + var tagHelpers = JsonSerializer.Deserialize>(stream, TagHelperDescriptorJsonConverter.SerializerOptions); return TagHelperCollection.Create(tagHelpers); } diff --git a/src/RazorSdk/Tool/Json/JsonDataReader.cs b/src/RazorSdk/Tool/Json/JsonDataReader.cs index 5528b848ae85..962f9e6878c9 100644 --- a/src/RazorSdk/Tool/Json/JsonDataReader.cs +++ b/src/RazorSdk/Tool/Json/JsonDataReader.cs @@ -3,7 +3,7 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.NET.Sdk.Razor.Tool.Json; @@ -11,93 +11,48 @@ namespace Microsoft.NET.Sdk.Razor.Tool.Json; internal delegate T ReadProperties(JsonDataReader reader); /// -/// This is an abstraction used to read JSON data. Currently, this -/// wraps a from JSON.NET. +/// This is an abstraction used to read JSON data. This wraps a +/// from System.Text.Json, providing +/// sequential-style property access over the tree model. /// -internal readonly ref struct JsonDataReader(JsonReader reader) +internal ref struct JsonDataReader { - private readonly JsonReader _reader = reader; + private readonly JsonElement _element; + private JsonElement _currentValue; - public bool IsInteger => _reader.TokenType == JsonToken.Integer; - public bool IsObjectStart => _reader.TokenType == JsonToken.StartObject; - public bool IsString => _reader.TokenType == JsonToken.String; + public JsonDataReader(JsonElement element) + { + _element = element; + _currentValue = element; + } - public bool IsPropertyName(string propertyName) - => _reader.TokenType == JsonToken.PropertyName && - (string?)_reader.Value == propertyName; + public bool IsInteger => _currentValue.ValueKind == JsonValueKind.Number; + public bool IsObjectStart => _currentValue.ValueKind == JsonValueKind.Object; + public bool IsString => _currentValue.ValueKind == JsonValueKind.String; public void ReadPropertyName(string propertyName) { - if (!IsPropertyName(propertyName)) + if (!_element.TryGetProperty(propertyName, out _currentValue)) { - ThrowUnexpectedPropertyException(propertyName, (string?)_reader.Value); + ThrowUnexpectedPropertyException(propertyName); } - _reader.Read(); - [DoesNotReturn] - static void ThrowUnexpectedPropertyException(string expectedPropertyName, string? actualPropertyName) + static void ThrowUnexpectedPropertyException(string expectedPropertyName) { throw new InvalidOperationException( - Strings.FormatExpected_JSON_property_0_but_it_was_1(expectedPropertyName, actualPropertyName)); + Strings.FormatExpected_JSON_property_0_but_it_was_1(expectedPropertyName, null)); } } public bool TryReadPropertyName(string propertyName) - { - if (IsPropertyName(propertyName)) - { - _reader.Read(); - return true; - } - - return false; - } - - public bool TryReadNextPropertyName([NotNullWhen(true)] out string? propertyName) - { - if (_reader.TokenType != JsonToken.PropertyName) - { - propertyName = null; - return false; - } - - propertyName = (string)_reader.Value.AssumeNotNull(); - _reader.Read(); - - return true; - } + => _element.TryGetProperty(propertyName, out _currentValue); public bool TryReadNull() - { - if (_reader.TokenType == JsonToken.Null) - { - _reader.Read(); - return true; - } - - return false; - } + => _currentValue.ValueKind == JsonValueKind.Null; public bool ReadBoolean() - { - _reader.CheckToken(JsonToken.Boolean); - - var result = Convert.ToBoolean(_reader.Value); - _reader.Read(); - - return result; - } - - public bool ReadBoolean(string propertyName) - { - ReadPropertyName(propertyName); - - return ReadBoolean(); - } - - public bool ReadBooleanOrDefault(string propertyName, bool defaultValue = default) - => TryReadPropertyName(propertyName) ? ReadBoolean() : defaultValue; + => _currentValue.GetBoolean(); public bool ReadBooleanOrTrue(string propertyName) => !TryReadPropertyName(propertyName) || ReadBoolean(); @@ -105,27 +60,8 @@ public bool ReadBooleanOrTrue(string propertyName) public bool ReadBooleanOrFalse(string propertyName) => TryReadPropertyName(propertyName) && ReadBoolean(); - public bool TryReadBoolean(string propertyName, out bool value) - { - if (TryReadPropertyName(propertyName)) - { - value = ReadBoolean(); - return true; - } - - value = default; - return false; - } - public byte ReadByte() - { - _reader.CheckToken(JsonToken.Integer); - - var result = Convert.ToByte(_reader.Value); - _reader.Read(); - - return result; - } + => _currentValue.GetByte(); public byte ReadByteOrDefault(string propertyName, byte defaultValue = default) => TryReadPropertyName(propertyName) ? ReadByte() : defaultValue; @@ -133,95 +69,24 @@ public byte ReadByteOrDefault(string propertyName, byte defaultValue = default) public byte ReadByteOrZero(string propertyName) => TryReadPropertyName(propertyName) ? ReadByte() : (byte)0; - public bool TryReadByte(string propertyName, out byte value) - { - if (TryReadPropertyName(propertyName)) - { - value = ReadByte(); - return true; - } - - value = default; - return false; - } - public byte ReadByte(string propertyName) { ReadPropertyName(propertyName); - return ReadByte(); } public int ReadInt32() - { - _reader.CheckToken(JsonToken.Integer); - - var result = Convert.ToInt32(_reader.Value); - _reader.Read(); - - return result; - } - - public int ReadInt32OrDefault(string propertyName, int defaultValue = default) - => TryReadPropertyName(propertyName) ? ReadInt32() : defaultValue; + => _currentValue.GetInt32(); public int ReadInt32OrZero(string propertyName) => TryReadPropertyName(propertyName) ? ReadInt32() : 0; - public bool TryReadInt32(string propertyName, out int value) - { - if (TryReadPropertyName(propertyName)) - { - value = ReadInt32(); - return true; - } - - value = default; - return false; - } - public int ReadInt32(string propertyName) { ReadPropertyName(propertyName); - return ReadInt32(); } - public long ReadInt64() - { - _reader.CheckToken(JsonToken.Integer); - - var result = Convert.ToInt64(_reader.Value); - _reader.Read(); - - return result; - } - - public long ReadInt64OrDefault(string propertyName, int defaultValue = default) - => TryReadPropertyName(propertyName) ? ReadInt64() : defaultValue; - - public long ReadInt64OrZero(string propertyName) - => TryReadPropertyName(propertyName) ? ReadInt64() : 0; - - public bool TryReadInt64(string propertyName, out long value) - { - if (TryReadPropertyName(propertyName)) - { - value = ReadInt64(); - return true; - } - - value = default; - return false; - } - - public long ReadInt64(string propertyName) - { - ReadPropertyName(propertyName); - - return ReadInt64(); - } - public string? ReadString() { if (TryReadNull()) @@ -229,142 +94,53 @@ public long ReadInt64(string propertyName) return null; } - _reader.CheckToken(JsonToken.String); - - var result = Convert.ToString(_reader.Value); - _reader.Read(); - - return result; + return _currentValue.GetString(); } public string? ReadString(string propertyName) { ReadPropertyName(propertyName); - return ReadString(); } - public string? ReadStringOrDefault(string propertyName, string? defaultValue = default) - => TryReadPropertyName(propertyName) ? ReadString() : defaultValue; - public string? ReadStringOrNull(string propertyName) => TryReadPropertyName(propertyName) ? ReadString() : null; - public bool TryReadString(string propertyName, out string? value) - { - if (TryReadPropertyName(propertyName)) - { - value = ReadString(); - return true; - } - - value = null; - return false; - } - public string ReadNonNullString() - { - _reader.CheckToken(JsonToken.String); - - var result = Convert.ToString(_reader.Value).AssumeNotNull(); - _reader.Read(); - - return result; - } + => _currentValue.GetString().AssumeNotNull(); public string ReadNonNullString(string propertyName) { ReadPropertyName(propertyName); - return ReadNonNullString(); } public object? ReadValue() { - return _reader.TokenType switch + return _currentValue.ValueKind switch { - JsonToken.String => ReadString(), - JsonToken.Integer => ReadInt32(), - JsonToken.Boolean => ReadBoolean(), + JsonValueKind.String => ReadString(), + JsonValueKind.Number => (object)ReadInt32(), + JsonValueKind.True or JsonValueKind.False => (object)ReadBoolean(), + JsonValueKind.Null => null, - var token => ThrowNotSupported(token) + var kind => ThrowNotSupported(kind) }; [DoesNotReturn] - static object? ThrowNotSupported(JsonToken token) + static object? ThrowNotSupported(JsonValueKind kind) { throw new NotSupportedException( - Strings.FormatCould_not_read_value_JSON_token_was_0(token)); - } - } - - public Uri? ReadUri(string propertyName) - { - ReadPropertyName(propertyName); - - return ReadUri(); - } - - public Uri? ReadUri() - { - return ReadString() is string uriString - ? new Uri(uriString) - : null; - } - - public Uri ReadNonNullUri(string propertyName) - { - ReadPropertyName(propertyName); - - return ReadNonNullUri(); - } - - public Uri ReadNonNullUri() - { - var uriString = ReadNonNullString(); - return new Uri(uriString); - } - - [return: MaybeNull] - public T ReadObject(ReadProperties readProperties) - { - if (TryReadNull()) - { - return default; + $"Could not read value - JSON value kind was '{kind}'."); } - - return ReadNonNullObject(readProperties); } - [return: MaybeNull] - public T ReadObject(string propertyName, ReadProperties readProperties) - { - ReadPropertyName(propertyName); - - return ReadObject(readProperties); - } - - [return: MaybeNull] - public T ReadObjectOrDefault(string propertyName, ReadProperties readProperties, T defaultValue) - => TryReadPropertyName(propertyName) ? ReadObject(readProperties) : defaultValue; - - public T? ReadObjectOrNull(string propertyName, ReadProperties readProperties) - where T : class - => ReadObjectOrDefault(propertyName, readProperties!, defaultValue: null); - public T ReadNonNullObject(ReadProperties readProperties) - { - _reader.ReadToken(JsonToken.StartObject); - var result = readProperties(this); - _reader.ReadToken(JsonToken.EndObject); - - return result; - } + => readProperties(new JsonDataReader(_currentValue)); public T ReadNonNullObject(string propertyName, ReadProperties readProperties) { ReadPropertyName(propertyName); - return ReadNonNullObject(readProperties); } @@ -378,122 +154,39 @@ public T ReadNonNullObjectOrDefault(string propertyName, ReadProperties re return null; } - _reader.ReadToken(JsonToken.StartArray); - - // First special case, is this an empty array? - if (_reader.TokenType == JsonToken.EndArray) + var length = _currentValue.GetArrayLength(); + if (length == 0) { - _reader.Read(); return []; } - // Second special case, is this an array of one element? - var firstElement = readElement(this); - - if (_reader.TokenType == JsonToken.EndArray) + var result = new T[length]; + var i = 0; + foreach (var item in _currentValue.EnumerateArray()) { - _reader.Read(); - return [firstElement]; + result[i++] = readElement(new JsonDataReader(item)); } - // There's more than one element, so we use a builder to - // read the rest of the array elements. - var elements = ImmutableArray.CreateBuilder(); - - // Be sure to add the element that we already read. - elements.Add(firstElement); - - ReadArrayElements(elements, readElement); - - return elements.ToArray(); - } - - public T[]? ReadArray(string propertyName, ReadValue readElement) - { - ReadPropertyName(propertyName); - return ReadArray(readElement); + return result; } - public T[] ReadArrayOrEmpty(string propertyName, ReadValue readElement) - => TryReadPropertyName(propertyName) ? ReadArray(readElement) ?? [] : []; - public ImmutableArray ReadImmutableArray(ReadValue readElement) { - _reader.ReadToken(JsonToken.StartArray); - - // First special case, is this an empty array? - if (_reader.TokenType == JsonToken.EndArray) + var length = _currentValue.GetArrayLength(); + if (length == 0) { - _reader.Read(); return []; } - // Second special case, is this an array of one element? - var firstElement = readElement(this); - - if (_reader.TokenType == JsonToken.EndArray) + var builder = ImmutableArray.CreateBuilder(length); + foreach (var item in _currentValue.EnumerateArray()) { - _reader.Read(); - return [firstElement]; + builder.Add(readElement(new JsonDataReader(item))); } - // There's more than one element, so we use a builder to - // read the rest of the array elements. - var elements = ImmutableArray.CreateBuilder(); - - // Be sure to add the element that we already read. - elements.Add(firstElement); - - ReadArrayElements(elements, readElement); - - return elements.ToImmutable(); - } - - private void ReadArrayElements(ImmutableArray.Builder elements, ReadValue readElement) - { - do - { - var element = readElement(this); - elements.Add(element); - } - while (_reader.TokenType != JsonToken.EndArray); - - _reader.Read(); - } - - public ImmutableArray ReadImmutableArray(string propertyName, ReadValue readElement) - { - ReadPropertyName(propertyName); - return ReadImmutableArray(readElement); + return builder.ToImmutable(); } public ImmutableArray ReadImmutableArrayOrEmpty(string propertyName, ReadValue readElement) => TryReadPropertyName(propertyName) ? ReadImmutableArray(readElement) : []; - - public void ReadToEndOfCurrentObject() - { - var nestingLevel = 0; - - while (_reader.Read()) - { - switch (_reader.TokenType) - { - case JsonToken.StartObject: - nestingLevel++; - break; - - case JsonToken.EndObject: - nestingLevel--; - - if (nestingLevel == -1) - { - return; - } - - break; - } - } - - throw new JsonSerializationException(Strings.Encountered_end_of_stream_before_end_of_object); - } } diff --git a/src/RazorSdk/Tool/Json/JsonDataWriter.cs b/src/RazorSdk/Tool/Json/JsonDataWriter.cs index 7705c6309885..9d2c1c5bc7c6 100644 --- a/src/RazorSdk/Tool/Json/JsonDataWriter.cs +++ b/src/RazorSdk/Tool/Json/JsonDataWriter.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.NET.Sdk.Razor.Tool.Json; @@ -11,21 +11,21 @@ namespace Microsoft.NET.Sdk.Razor.Tool.Json; /// /// This is an abstraction used to write JSON data. Currently, this -/// wraps a from JSON.NET. +/// wraps a from System.Text.Json. /// -internal readonly ref struct JsonDataWriter(JsonWriter writer) +internal readonly ref struct JsonDataWriter(Utf8JsonWriter writer) { - private readonly JsonWriter _writer = writer; + private readonly Utf8JsonWriter _writer = writer; public void Write(bool value) { - _writer.WriteValue(value); + _writer.WriteBooleanValue(value); } public void Write(string propertyName, bool value) { _writer.WritePropertyName(propertyName); - _writer.WriteValue(value); + _writer.WriteBooleanValue(value); } public void WriteIfNotTrue(string propertyName, bool value) @@ -44,15 +44,10 @@ public void WriteIfNotFalse(string propertyName, bool value) } } - public void Write(byte value) - { - _writer.WriteValue(value); - } - public void Write(string propertyName, byte value) { _writer.WritePropertyName(propertyName); - _writer.WriteValue(value); + _writer.WriteNumberValue((int)value); } public void WriteIfNotZero(string propertyName, byte value) @@ -70,13 +65,13 @@ public void WriteIfNotDefault(string propertyName, byte value, byte defaultValue public void Write(int value) { - _writer.WriteValue(value); + _writer.WriteNumberValue(value); } public void Write(string propertyName, int value) { _writer.WritePropertyName(propertyName); - _writer.WriteValue(value); + _writer.WriteNumberValue(value); } public void WriteIfNotZero(string propertyName, int value) @@ -92,47 +87,15 @@ public void WriteIfNotDefault(string propertyName, int value, int defaultValue) } } - public void Write(long value) - { - _writer.WriteValue(value); - } - - public void Write(string propertyName, long value) - { - _writer.WritePropertyName(propertyName); - _writer.WriteValue(value); - } - - public void WriteIfNotZero(string propertyName, long value) - { - WriteIfNotDefault(propertyName, value, defaultValue: 0); - } - - public void WriteIfNotDefault(string propertyName, long value, long defaultValue) - { - if (value != defaultValue) - { - Write(propertyName, value); - } - } - public void Write(string? value) { - _writer.WriteValue(value); + _writer.WriteStringValue(value); } public void Write(string propertyName, string? value) { _writer.WritePropertyName(propertyName); - _writer.WriteValue(value); - } - - public void WriteIfNotDefault(string propertyName, string? value, string? defaultValue) - { - if (value != defaultValue) - { - Write(propertyName, value); - } + _writer.WriteStringValue(value); } public void WriteIfNotNull(string propertyName, string? value) @@ -168,24 +131,6 @@ public void WriteValue(object? value) } } - public void Write(string propertyName, Uri? value) - { - _writer.WritePropertyName(propertyName); - Write(value); - } - - public void Write(Uri? value) - { - if (value is null) - { - _writer.WriteNull(); - } - else - { - _writer.WriteValue(value.AbsoluteUri); - } - } - public void WriteObject(string propertyName, T? value, WriteProperties writeProperties) { _writer.WritePropertyName(propertyName); @@ -196,7 +141,7 @@ public void WriteObject(T? value, WriteProperties writeProperties) { if (value is null) { - _writer.WriteNull(); + _writer.WriteNullValue(); return; } @@ -205,55 +150,13 @@ public void WriteObject(T? value, WriteProperties writeProperties) _writer.WriteEndObject(); } - public void WriteObjectIfNotDefault(string propertyName, T? value, T? defaultValue, WriteProperties writeProperties) - { - if (!EqualityComparer.Default.Equals(value, defaultValue)) - { - WriteObject(propertyName, value, writeProperties); - } - } - - public void WriteObjectIfNotNull(string propertyName, T? value, WriteProperties writeProperties) - { - if (value is not null) - { - WriteObject(propertyName, value, writeProperties); - } - } - - public void WriteArray(IEnumerable? elements, WriteValue writeElement) - { - ArgumentNullException.ThrowIfNull(writeElement); - - if (elements is null) - { - _writer.WriteNull(); - return; - } - - _writer.WriteStartArray(); - - foreach (var element in elements) - { - writeElement(this, element); - } - - _writer.WriteEndArray(); - } - - public void WriteArray(string propertyName, IEnumerable? elements, WriteValue writeElement) - { - _writer.WritePropertyName(propertyName); - WriteArray(elements, writeElement); - } - public void WriteArray(IReadOnlyList? elements, WriteValue writeElement) { ArgumentNullException.ThrowIfNull(writeElement); if (elements is null) { - _writer.WriteNull(); + _writer.WriteNullValue(); return; } @@ -295,14 +198,6 @@ public void WriteArray(string propertyName, ImmutableArray elements, Write WriteArray(elements, writeElement); } - public void WriteArrayIfNotNullOrEmpty(string propertyName, IEnumerable? elements, WriteValue writeElement) - { - if (elements?.Any() == true) - { - WriteArray(propertyName, elements, writeElement); - } - } - public void WriteArrayIfNotDefaultOrEmpty(string propertyName, ImmutableArray elements, WriteValue writeElement) { if (!elements.IsDefaultOrEmpty) diff --git a/src/RazorSdk/Tool/Json/JsonReaderExtensions.cs b/src/RazorSdk/Tool/Json/JsonReaderExtensions.cs deleted file mode 100644 index 539d02a82fb8..000000000000 --- a/src/RazorSdk/Tool/Json/JsonReaderExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using Newtonsoft.Json; - -namespace Microsoft.NET.Sdk.Razor.Tool.Json; - -internal static class JsonReaderExtensions -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void CheckToken(this JsonReader reader, JsonToken expectedToken) - { - if (reader.TokenType != expectedToken) - { - ThrowUnexpectedTokenException(expectedToken, reader.TokenType); - } - - [DoesNotReturn] - static void ThrowUnexpectedTokenException(JsonToken expectedToken, JsonToken actualToken) - { - throw new InvalidOperationException( - Strings.FormatExpected_JSON_token_0_but_it_was_1(expectedToken, actualToken)); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ReadToken(this JsonReader reader, JsonToken expectedToken) - { - reader.CheckToken(expectedToken); - reader.Read(); - } -} diff --git a/src/RazorSdk/Tool/Json/ObjectJsonConverter`1.cs b/src/RazorSdk/Tool/Json/ObjectJsonConverter`1.cs index 92c42c710443..07eb2e3d57dc 100644 --- a/src/RazorSdk/Tool/Json/ObjectJsonConverter`1.cs +++ b/src/RazorSdk/Tool/Json/ObjectJsonConverter`1.cs @@ -1,7 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Microsoft.NET.Sdk.Razor.Tool.Json; @@ -11,32 +12,26 @@ internal abstract class ObjectJsonConverter : JsonConverter protected abstract T ReadFromProperties(JsonDataReader reader); protected abstract void WriteProperties(JsonDataWriter writer, T value); - public sealed override T? ReadJson(JsonReader reader, Type objectType, T? existingValue, bool hasExistingValue, JsonSerializer serializer) + public sealed override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.TokenType == JsonToken.Null) + if (reader.TokenType == JsonTokenType.Null) { return null; } - reader.ReadToken(JsonToken.StartObject); + // Parse the current JSON value into a JsonDocument/JsonElement. + // This advances the reader past the entire value automatically. + using var doc = JsonDocument.ParseValue(ref reader); - T result; - - var dataReader = new JsonDataReader(reader); - result = ReadFromProperties(dataReader); - - // JSON.NET serialization expects that we don't advance passed the end object token, - // but we should verify that it's there. - reader.CheckToken(JsonToken.EndObject); - - return result; + var dataReader = new JsonDataReader(doc.RootElement); + return ReadFromProperties(dataReader); } - public sealed override void WriteJson(JsonWriter writer, T? value, JsonSerializer serializer) + public sealed override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { if (value is null) { - writer.WriteNull(); + writer.WriteNullValue(); return; } diff --git a/src/RazorSdk/Tool/Json/ObjectReaders_TagHelpers.cs b/src/RazorSdk/Tool/Json/ObjectReaders_TagHelpers.cs index 5fcfe1f919a6..33a7d473b578 100644 --- a/src/RazorSdk/Tool/Json/ObjectReaders_TagHelpers.cs +++ b/src/RazorSdk/Tool/Json/ObjectReaders_TagHelpers.cs @@ -10,9 +10,6 @@ namespace Microsoft.NET.Sdk.Razor.Tool.Json; internal static partial class ObjectReaders { - public static TagHelperDescriptor ReadTagHelper(JsonDataReader reader) - => reader.ReadNonNullObject(ReadTagHelperFromProperties); - public static TagHelperDescriptor ReadTagHelperFromProperties(JsonDataReader reader) { var flags = (TagHelperFlags)reader.ReadByte(nameof(TagHelperDescriptor.Flags)); diff --git a/src/RazorSdk/Tool/Json/ObjectWriters.cs b/src/RazorSdk/Tool/Json/ObjectWriters.cs index faade448d806..a6d71e1adf20 100644 --- a/src/RazorSdk/Tool/Json/ObjectWriters.cs +++ b/src/RazorSdk/Tool/Json/ObjectWriters.cs @@ -24,12 +24,4 @@ public static void WriteProperties(JsonDataWriter writer, RazorDiagnostic value) writer.WriteIfNotZero(nameof(span.CharacterIndex), span.CharacterIndex); writer.WriteIfNotZero(nameof(span.Length), span.Length); } - - public static void Write(JsonDataWriter writer, RazorExtension? value) - => writer.WriteObject(value, WriteProperties); - - public static void WriteProperties(JsonDataWriter writer, RazorExtension value) - { - writer.Write(nameof(value.ExtensionName), value.ExtensionName); - } } diff --git a/src/RazorSdk/Tool/Json/ObjectWriters_TagHelpers.cs b/src/RazorSdk/Tool/Json/ObjectWriters_TagHelpers.cs index cd5e9554a2e1..266b51bed47b 100644 --- a/src/RazorSdk/Tool/Json/ObjectWriters_TagHelpers.cs +++ b/src/RazorSdk/Tool/Json/ObjectWriters_TagHelpers.cs @@ -10,9 +10,6 @@ namespace Microsoft.NET.Sdk.Razor.Tool.Json; internal static partial class ObjectWriters { - public static void Write(JsonDataWriter writer, TagHelperDescriptor? value) - => writer.WriteObject(value, WriteProperties); - public static void WriteProperties(JsonDataWriter writer, TagHelperDescriptor value) { writer.Write(nameof(value.Flags), (byte)value.Flags); diff --git a/src/RazorSdk/Tool/Json/Strings.cs b/src/RazorSdk/Tool/Json/Strings.cs index afb0b3ff1ccc..4027ea183c9c 100644 --- a/src/RazorSdk/Tool/Json/Strings.cs +++ b/src/RazorSdk/Tool/Json/Strings.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.NET.Sdk.Razor.Tool.Json; @@ -20,19 +20,19 @@ internal static class Strings public const string File_0_Line_1 = " File='{0}', Line={1}"; public const string This_program_location_is_thought_to_be_unreachable = "This program location is thought to be unreachable."; - public static string FormatCould_not_read_value_JSON_token_was_0(JsonToken token) + public static string FormatCould_not_read_value_JSON_token_was_0(JsonTokenType token) => string.Format(Could_not_read_value_JSON_token_was_0, token); public static string FormatEncountered_unexpected_JSON_property_0(string propertyName) => string.Format(Encountered_unexpected_JSON_property_0, propertyName); - public static string FormatEncountered_unexpected_JSON_token_0(JsonToken token) + public static string FormatEncountered_unexpected_JSON_token_0(JsonTokenType token) => string.Format(Encountered_unexpected_JSON_token_0, token); public static string FormatExpected_JSON_property_0_but_it_was_1(string expectedPropertyName, string? actualPropertyName) => string.Format(Expected_JSON_property_0_but_it_was_1, expectedPropertyName, actualPropertyName); - public static string FormatExpected_JSON_token_0_but_it_was_1(JsonToken expectedToken, JsonToken actualToken) + public static string FormatExpected_JSON_token_0_but_it_was_1(JsonTokenType expectedToken, JsonTokenType actualToken) => string.Format(Expected_JSON_token_0_but_it_was_1, expectedToken, actualToken); public static string FormatExpected_0_to_be_non_null(string? name) diff --git a/src/RazorSdk/Tool/Json/TagHelperDescriptorJsonConverter.cs b/src/RazorSdk/Tool/Json/TagHelperDescriptorJsonConverter.cs index 126b96280d75..4b96f12b0803 100644 --- a/src/RazorSdk/Tool/Json/TagHelperDescriptorJsonConverter.cs +++ b/src/RazorSdk/Tool/Json/TagHelperDescriptorJsonConverter.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Microsoft.AspNetCore.Razor.Language; namespace Microsoft.NET.Sdk.Razor.Tool.Json; @@ -9,10 +10,19 @@ internal sealed class TagHelperDescriptorJsonConverter : ObjectJsonConverter ObjectReaders.ReadTagHelperFromProperties(reader); diff --git a/src/RazorSdk/Tool/Microsoft.NET.Sdk.Razor.Tool.csproj b/src/RazorSdk/Tool/Microsoft.NET.Sdk.Razor.Tool.csproj index 19fb2f2610d7..0a8969594412 100644 --- a/src/RazorSdk/Tool/Microsoft.NET.Sdk.Razor.Tool.csproj +++ b/src/RazorSdk/Tool/Microsoft.NET.Sdk.Razor.Tool.csproj @@ -28,7 +28,6 @@ `Roslyn/bincore` for our consolidate loading logic. --> - diff --git a/test/Microsoft.NET.Sdk.Razor.Tool.Tests/CommandRoundTripTest.cs b/test/Microsoft.NET.Sdk.Razor.Tool.Tests/CommandRoundTripTest.cs new file mode 100644 index 000000000000..d3172f60ca45 --- /dev/null +++ b/test/Microsoft.NET.Sdk.Razor.Tool.Tests/CommandRoundTripTest.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.CodeAnalysis; +using Moq; + +namespace Microsoft.NET.Sdk.Razor.Tool +{ + /// + /// Verifies that the discover command produces JSON that the generate command can consume. + /// + public class CommandRoundTripTest + { + [Fact] + public void DiscoverThenGenerate_ManifestIsConsumedByGenerateCommand() + { + // Arrange - find framework ref assemblies + var runtimeAssemblyDir = Path.GetDirectoryName(typeof(object).Assembly.Location); + var dotnetRoot = Path.GetFullPath(Path.Combine(runtimeAssemblyDir, "..", "..", "..")); + + var runtimeRefDir = GetLatestRefPackDirectory(dotnetRoot, "Microsoft.NETCore.App.Ref"); + var aspnetRefDir = GetLatestRefPackDirectory(dotnetRoot, "Microsoft.AspNetCore.App.Ref"); + + Assert.True(Directory.Exists(runtimeRefDir), $"Runtime ref pack not found at {runtimeRefDir}"); + Assert.True(Directory.Exists(aspnetRefDir), $"ASP.NET ref pack not found at {aspnetRefDir}"); + + var assemblies = Directory.GetFiles(runtimeRefDir, "*.dll") + .Concat(Directory.GetFiles(aspnetRefDir, "*.dll")) + .ToArray(); + + var tempDir = Path.Combine(Path.GetTempPath(), "RazorDiscoverGenerateTest", Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + // Run the discover command to produce a manifest + var manifestPath = Path.Combine(tempDir, "manifest.json"); + var discoverExitCode = RunDiscoverCommand(assemblies, tempDir, manifestPath); + Assert.True(discoverExitCode == 0, $"Discover command failed with exit code {discoverExitCode}"); + Assert.True(File.Exists(manifestPath), "Manifest file was not created"); + + // Check that the AnchorTagHelper was discovered + var manifestFile = File.ReadAllText(manifestPath); + Assert.Contains("Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper", manifestFile); + + // Create a simple .cshtml file that uses the anchor tag helper from the manifest + var cshtmlPath = Path.Combine(tempDir, "TestView.cshtml"); + File.WriteAllText(cshtmlPath, """ + @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + Home + """); + + var outputPath = Path.Combine(tempDir, "TestView.cshtml.g.cs"); + + // Run the generate command with the manifest + var errorWriter = new StringWriter(); + var application = CreateApplication(errorWriter: errorWriter); + + var args = new List + { + "generate", + "-s", cshtmlPath, + "-o", outputPath, + "-r", "TestView.cshtml", + "-k", "mvc", + "-p", tempDir, + "-t", manifestPath, + "-v", "Latest", + "-c", "MVC-3.0", + }; + + var generateExitCode = application.Execute(args.ToArray()); + + // Make sure the generate command succeeded and produced output + Assert.True(generateExitCode == 0, $"Generate command failed with exit code {generateExitCode}. Error: {errorWriter}"); + Assert.True(File.Exists(outputPath), "Generated C# file was not created"); + + var generatedCode = File.ReadAllText(outputPath); + Assert.NotEmpty(generatedCode); + + // The generated code should reference the AnchorTagHelper since we used asp-action/asp-controller + Assert.Contains("global::Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper", generatedCode); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + private static Application CreateApplication(TextWriter outputWriter = null, TextWriter errorWriter = null) + { + var checker = new Mock(); + checker.Setup(c => c.Check(It.IsAny>())).Returns(true); + + return new Application( + CancellationToken.None, + Mock.Of(), + checker.Object, + (path, properties) => MetadataReference.CreateFromFile(path, properties), + outputWriter ?? new StringWriter(), + errorWriter ?? new StringWriter()); + } + + private static int RunDiscoverCommand(string[] assemblies, string projectDir, string manifestPath) + { + var application = CreateApplication(); + + var args = new List { "discover" }; + args.AddRange(assemblies); + args.AddRange(["-o", Path.GetFileName(manifestPath), "-p", projectDir, "-v", "Latest", "-c", "MVC-3.0"]); + + return application.Execute(args.ToArray()); + } + + private static string GetLatestRefPackDirectory(string dotnetRoot, string packName) + { + var packsDir = Path.Combine(dotnetRoot, "packs", packName); + if (!Directory.Exists(packsDir)) + { + return packsDir; + } + + var latestVersion = Directory.GetDirectories(packsDir) + .OrderByDescending(d => d) + .FirstOrDefault(); + + if (latestVersion == null) + { + return packsDir; + } + + var refDir = Path.Combine(latestVersion, "ref"); + if (!Directory.Exists(refDir)) + { + return refDir; + } + + // Get the TFM-specific subdirectory (e.g., net11.0) + return Directory.GetDirectories(refDir) + .OrderByDescending(d => d) + .FirstOrDefault() ?? refDir; + } + } +} diff --git a/test/Microsoft.NET.Sdk.Razor.Tool.Tests/TagHelperJsonSerializationTest.cs b/test/Microsoft.NET.Sdk.Razor.Tool.Tests/TagHelperJsonSerializationTest.cs new file mode 100644 index 000000000000..03eb0a6ec478 --- /dev/null +++ b/test/Microsoft.NET.Sdk.Razor.Tool.Tests/TagHelperJsonSerializationTest.cs @@ -0,0 +1,760 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Text.Json; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.NET.Sdk.Razor.Tool.Json; + +namespace Microsoft.NET.Sdk.Razor.Tool +{ + /// + /// Tests that validate JSON serialization round-tripping for TagHelperDescriptor, + /// which is the core data structure used by DiscoverCommand (serialization) and + /// GenerateCommand (deserialization). + /// + /// + /// These tests validate that the JSON serialization format produced by DiscoverCommand + /// can be correctly deserialized by GenerateCommand. The tests use predefined JSON + /// strings that represent various TagHelperDescriptor configurations. + /// + public class TagHelperJsonSerializationTest + { + [Fact] + public void RoundTrip_SimpleTagHelper_PreservesData() + { + // Arrange - JSON representing a simple tag helper + var json = """ + [ + { + "Flags": 1, + "Name": "TestTagHelper", + "AssemblyName": "TestAssembly", + "DisplayName": "Test Tag Helper", + "TypeName": "TestNamespace.TestTagHelper", + "TagMatchingRules": [ + { + "TagName": "test-tag" + } + ] + } + ] + """; + + // Act - Deserialize then reserialize + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + Assert.Equal("TestTagHelper", roundTripped[0].Name); + Assert.Equal("TestAssembly", roundTripped[0].AssemblyName); + Assert.Equal("Test Tag Helper", roundTripped[0].DisplayName); + Assert.Equal("TestNamespace.TestTagHelper", roundTripped[0].TypeName); + Assert.Single(roundTripped[0].TagMatchingRules); + Assert.Equal("test-tag", roundTripped[0].TagMatchingRules[0].TagName); + } + + [Fact] + public void RoundTrip_TagHelperWithBoundAttributes_PreservesData() + { + // Arrange + var json = """ + [ + { + "Flags": 1, + "Name": "BoundTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.BoundTagHelper", + "TagMatchingRules": [ + { + "TagName": "bound-tag" + } + ], + "BoundAttributes": [ + { + "Flags": 0, + "Name": "value", + "PropertyName": "Value", + "TypeName": "System.String", + "DisplayName": "Value Attribute" + }, + { + "Flags": 0, + "Name": "count", + "PropertyName": "Count", + "TypeName": "System.Int32", + "DisplayName": "Count Attribute" + } + ] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + var result = roundTripped[0]; + + Assert.Equal("BoundTagHelper", result.Name); + Assert.Equal(2, result.BoundAttributes.Length); + + Assert.Equal("value", result.BoundAttributes[0].Name); + Assert.Equal("Value", result.BoundAttributes[0].PropertyName); + Assert.Equal("System.String", result.BoundAttributes[0].TypeName); + + Assert.Equal("count", result.BoundAttributes[1].Name); + Assert.Equal("Count", result.BoundAttributes[1].PropertyName); + Assert.Equal("System.Int32", result.BoundAttributes[1].TypeName); + } + + [Fact] + public void RoundTrip_TagHelperWithTagMatchingRules_PreservesData() + { + // Arrange + // TagStructure enum: Unspecified=0, NormalOrSelfClosing=1, WithoutEndTag=2 + var json = """ + [ + { + "Flags": 1, + "Name": "MultiRuleTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.MultiRuleTagHelper", + "TagMatchingRules": [ + { + "TagName": "my-tag", + "ParentTag": "parent-tag", + "TagStructure": 2, + "CaseSensitive": false + }, + { + "TagName": "alternate-tag", + "TagStructure": 1 + } + ] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + var result = roundTripped[0]; + + Assert.Equal(2, result.TagMatchingRules.Length); + + Assert.Equal("my-tag", result.TagMatchingRules[0].TagName); + Assert.Equal("parent-tag", result.TagMatchingRules[0].ParentTag); + Assert.Equal(TagStructure.WithoutEndTag, result.TagMatchingRules[0].TagStructure); + Assert.False(result.TagMatchingRules[0].CaseSensitive); + + Assert.Equal("alternate-tag", result.TagMatchingRules[1].TagName); + Assert.Null(result.TagMatchingRules[1].ParentTag); + Assert.Equal(TagStructure.NormalOrSelfClosing, result.TagMatchingRules[1].TagStructure); + } + + [Fact] + public void RoundTrip_TagHelperWithRequiredAttributes_PreservesData() + { + // Arrange + var json = """ + [ + { + "Flags": 1, + "Name": "RequiredAttrTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.RequiredAttrTagHelper", + "TagMatchingRules": [ + { + "TagName": "required-tag", + "Attributes": [ + { + "Flags": 0, + "Name": "required-attr", + "NameComparison": 0, + "Value": "expected-value", + "ValueComparison": 1 + } + ] + } + ] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + var result = roundTripped[0]; + + Assert.Single(result.TagMatchingRules); + Assert.Single(result.TagMatchingRules[0].Attributes); + + var attr = result.TagMatchingRules[0].Attributes[0]; + Assert.Equal("required-attr", attr.Name); + Assert.Equal(RequiredAttributeNameComparison.FullMatch, attr.NameComparison); + Assert.Equal("expected-value", attr.Value); + Assert.Equal(RequiredAttributeValueComparison.FullMatch, attr.ValueComparison); + } + + [Fact] + public void RoundTrip_TagHelperWithAllowedChildTags_PreservesData() + { + // Arrange + var json = """ + [ + { + "Flags": 1, + "Name": "ParentTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.ParentTagHelper", + "TagMatchingRules": [ + { + "TagName": "parent-tag" + } + ], + "AllowedChildTags": [ + { + "Name": "child-one", + "DisplayName": "Child One", + "Diagnostics": [] + }, + { + "Name": "child-two", + "DisplayName": "Child Two", + "Diagnostics": [] + } + ] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + var result = roundTripped[0]; + + Assert.Equal(2, result.AllowedChildTags.Length); + Assert.Equal("child-one", result.AllowedChildTags[0].Name); + Assert.Equal("Child One", result.AllowedChildTags[0].DisplayName); + Assert.Equal("child-two", result.AllowedChildTags[1].Name); + Assert.Equal("Child Two", result.AllowedChildTags[1].DisplayName); + } + + [Fact] + public void RoundTrip_ComponentTagHelper_PreservesMetadata() + { + // Arrange + // When Kind is not specified, it defaults to Component. + // The serializer uses WriteIfNotDefault, so it won't write Kind if it's the default (Component). + // For this test, we omit Kind to rely on the default behavior. + var json = """ + [ + { + "Flags": 1, + "Name": "MyComponent", + "AssemblyName": "ComponentAssembly", + "TypeName": "ComponentNamespace.MyComponent", + "TagMatchingRules": [ + { + "TagName": "MyComponent" + } + ], + "MetadataKind": 5 + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + var result = roundTripped[0]; + + Assert.Equal(TagHelperKind.Component, result.Kind); + Assert.Equal(RuntimeKind.IComponent, result.RuntimeKind); + Assert.Equal("MyComponent", result.Name); + } + + [Fact] + public void RoundTrip_MultipleTagHelpers_PreservesAll() + { + // Arrange + var json = """ + [ + { + "Flags": 1, + "Name": "FirstTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.FirstTagHelper", + "TagMatchingRules": [{ "TagName": "first-tag" }] + }, + { + "Flags": 1, + "Name": "SecondTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.SecondTagHelper", + "TagMatchingRules": [{ "TagName": "second-tag" }] + }, + { + "Flags": 1, + "Name": "ThirdTagHelper", + "AssemblyName": "OtherAssembly", + "TypeName": "OtherNamespace.ThirdTagHelper", + "TagMatchingRules": [{ "TagName": "third-tag" }] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Equal(3, roundTripped.Count); + Assert.Equal("FirstTagHelper", roundTripped[0].Name); + Assert.Equal("SecondTagHelper", roundTripped[1].Name); + Assert.Equal("ThirdTagHelper", roundTripped[2].Name); + Assert.Equal("OtherAssembly", roundTripped[2].AssemblyName); + } + + [Fact] + public void RoundTrip_EmptyTagHelperList_PreservesEmpty() + { + // Arrange + var json = "[]"; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Empty(roundTripped); + } + + [Fact] + public void RoundTrip_TagHelperWithDocumentation_PreservesDocumentation() + { + // Arrange - Documentation as a string + var json = """ + [ + { + "Flags": 1, + "Name": "DocumentedTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.DocumentedTagHelper", + "Documentation": "This is a documented tag helper.", + "TagMatchingRules": [{ "TagName": "doc-tag" }] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + Assert.Equal("This is a documented tag helper.", roundTripped[0].Documentation); + } + + [Fact] + public void RoundTrip_TagHelperWithDocumentationDescriptor_PreservesDocumentation() + { + // Arrange - Documentation as a DocumentationDescriptor with Id and Args + var json = """ + [ + { + "Flags": 1, + "Name": "DocumentedTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.DocumentedTagHelper", + "Documentation": { + "Id": 1, + "Args": ["SomeArg"] + }, + "TagMatchingRules": [{ "TagName": "doc-tag" }] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + // Documentation should be preserved (the exact format depends on the DocumentationDescriptor) + Assert.NotNull(roundTripped[0].Documentation); + } + + [Fact] + public void RoundTrip_TagHelperWithBoundAttributeParameters_PreservesParameters() + { + // Arrange + var json = """ + [ + { + "Flags": 1, + "Name": "ParameterTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.ParameterTagHelper", + "TagMatchingRules": [{ "TagName": "param-tag" }], + "BoundAttributes": [ + { + "Flags": 0, + "Name": "bind-value", + "PropertyName": "Value", + "TypeName": "System.String", + "DisplayName": "Bind Value", + "Parameters": [ + { + "Flags": 0, + "Name": "format", + "PropertyName": "Format", + "TypeName": "System.String" + }, + { + "Flags": 0, + "Name": "culture", + "PropertyName": "Culture", + "TypeName": "System.Globalization.CultureInfo" + } + ] + } + ] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + var result = roundTripped[0]; + + Assert.Single(result.BoundAttributes); + var attr = result.BoundAttributes[0]; + + Assert.Equal(2, attr.Parameters.Length); + Assert.Equal("format", attr.Parameters[0].Name); + Assert.Equal("Format", attr.Parameters[0].PropertyName); + Assert.Equal("System.String", attr.Parameters[0].TypeName); + Assert.Equal("culture", attr.Parameters[1].Name); + Assert.Equal("Culture", attr.Parameters[1].PropertyName); + } + + [Fact] + public void Serialize_ProducesValidJson() + { + // Arrange + var json = """ + [ + { + "Flags": 1, + "Name": "TestTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.TestTagHelper", + "TagMatchingRules": [{ "TagName": "test-tag" }] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var serialized = Serialize(deserialized); + + // Assert - should be valid JSON + Assert.NotNull(serialized); + Assert.StartsWith("[", serialized); + Assert.EndsWith("]", serialized); + + // Should parse without error + using var document = JsonDocument.Parse(serialized); + Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind); + Assert.Equal(1, document.RootElement.GetArrayLength()); + } + + [Fact] + public void Deserialize_InvalidJson_Throws() + { + // Arrange + var invalidJson = "{ not valid json"; + + // Act & Assert + Assert.Throws(() => + { + Deserialize(invalidJson); + }); + } + + [Fact] + public void Deserialize_EmptyArray_ReturnsEmptyCollection() + { + // Arrange + var json = "[]"; + + // Act + var result = Deserialize(json); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void RoundTrip_TagHelperFlags_PreservesFlags() + { + // Arrange - CaseSensitive flag (value 1) + var json = """ + [ + { + "Flags": 1, + "Name": "CaseSensitiveTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.CaseSensitiveTagHelper", + "TagMatchingRules": [{ "TagName": "case-tag" }] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + Assert.True(roundTripped[0].CaseSensitive); + } + + [Fact] + public void RoundTrip_TagHelperWithIndexerAttribute_PreservesIndexer() + { + // Arrange + var json = """ + [ + { + "Flags": 1, + "Name": "IndexerTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.IndexerTagHelper", + "TagMatchingRules": [{ "TagName": "indexer-tag" }], + "BoundAttributes": [ + { + "Flags": 0, + "Name": "items", + "PropertyName": "Items", + "TypeName": "System.Collections.Generic.Dictionary`2[[System.String],[System.Object]]", + "IndexerNamePrefix": "item-", + "IndexerTypeName": "System.Object", + "DisplayName": "Items Dictionary" + } + ] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + var attr = roundTripped[0].BoundAttributes[0]; + Assert.Equal("item-", attr.IndexerNamePrefix); + Assert.Equal("System.Object", attr.IndexerTypeName); + } + + [Fact] + public void RoundTrip_TagHelperWithTypeNameObject_PreservesTypeInfo() + { + // Arrange - TypeName as an object with FullName, Namespace, and Name + var json = """ + [ + { + "Flags": 1, + "Name": "TypedTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": { + "FullName": "TestNamespace.Nested.TypedTagHelper", + "Namespace": "TestNamespace.Nested", + "Name": "TypedTagHelper" + }, + "TagMatchingRules": [{ "TagName": "typed-tag" }] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + Assert.Equal("TestNamespace.Nested.TypedTagHelper", roundTripped[0].TypeName); + Assert.Equal("TestNamespace.Nested", roundTripped[0].TypeNamespace); + Assert.Equal("TypedTagHelper", roundTripped[0].TypeNameIdentifier); + } + + [Fact] + public void RoundTrip_TagHelperWithTagOutputHint_PreservesHint() + { + // Arrange + var json = """ + [ + { + "Flags": 1, + "Name": "OutputHintTagHelper", + "AssemblyName": "TestAssembly", + "TypeName": "TestNamespace.OutputHintTagHelper", + "TagOutputHint": "div", + "TagMatchingRules": [{ "TagName": "custom-tag" }] + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + Assert.Equal("div", roundTripped[0].TagOutputHint); + } + + [Fact] + public void RoundTrip_ComplexTagHelper_PreservesAllData() + { + // Arrange - A complex tag helper with multiple features + // TagStructure enum: Unspecified=0, NormalOrSelfClosing=1, WithoutEndTag=2 + // Kind and RuntimeKind are omitted to use defaults (Component and IComponent) + var json = """ + [ + { + "Flags": 1, + "Name": "ComplexComponent", + "AssemblyName": "ComplexAssembly", + "DisplayName": "Complex Component", + "TypeName": { + "FullName": "ComplexNamespace.ComplexComponent", + "Namespace": "ComplexNamespace", + "Name": "ComplexComponent" + }, + "Documentation": "A complex component with many features.", + "TagOutputHint": "section", + "TagMatchingRules": [ + { + "TagName": "ComplexComponent", + "ParentTag": "div", + "TagStructure": 1, + "Attributes": [ + { + "Flags": 0, + "Name": "id" + } + ] + } + ], + "BoundAttributes": [ + { + "Flags": 0, + "Name": "title", + "PropertyName": "Title", + "TypeName": "System.String", + "DisplayName": "Title" + } + ], + "AllowedChildTags": [ + { + "Name": "content", + "DisplayName": "Content", + "Diagnostics": [] + } + ], + "MetadataKind": 5 + } + ] + """; + + // Act + var deserialized = Deserialize(json); + var reserialized = Serialize(deserialized); + var roundTripped = Deserialize(reserialized); + + // Assert + Assert.Single(roundTripped); + var result = roundTripped[0]; + + Assert.Equal("ComplexComponent", result.Name); + Assert.Equal("ComplexAssembly", result.AssemblyName); + Assert.Equal("Complex Component", result.DisplayName); + Assert.Equal("ComplexNamespace.ComplexComponent", result.TypeName); + Assert.Equal("ComplexNamespace", result.TypeNamespace); + Assert.Equal("A complex component with many features.", result.Documentation); + Assert.Equal("section", result.TagOutputHint); + Assert.True(result.CaseSensitive); + + Assert.Single(result.TagMatchingRules); + Assert.Equal("ComplexComponent", result.TagMatchingRules[0].TagName); + Assert.Equal("div", result.TagMatchingRules[0].ParentTag); + Assert.Equal(TagStructure.NormalOrSelfClosing, result.TagMatchingRules[0].TagStructure); + + Assert.Single(result.BoundAttributes); + Assert.Equal("title", result.BoundAttributes[0].Name); + + Assert.Single(result.AllowedChildTags); + Assert.Equal("content", result.AllowedChildTags[0].Name); + } + + #region Helper Methods + + private static string Serialize(IReadOnlyList tagHelpers) + { + using var stream = new MemoryStream(); + JsonSerializer.Serialize(stream, tagHelpers, TagHelperDescriptorJsonConverter.SerializerOptions); + stream.Position = 0; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + private static IReadOnlyList Deserialize(string json) + { + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + return JsonSerializer.Deserialize>(stream, TagHelperDescriptorJsonConverter.SerializerOptions); + } + + #endregion + } +}