diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelReaderWriterContextDefinition.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelReaderWriterContextDefinition.cs index 7d5cc8976e6..065e53f4df6 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelReaderWriterContextDefinition.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelReaderWriterContextDefinition.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.TypeSpec.Generator.Expressions; using Microsoft.TypeSpec.Generator.Primitives; @@ -40,7 +41,45 @@ protected override TypeSignatureModifiers BuildDeclarationModifiers() protected override IReadOnlyList BuildAttributesForWrite() { var visitorAttributes = base.BuildAttributesForWrite().Where(static attribute => !IsBuildableAttribute(attribute)); - return [.. BuildAttributes(), .. visitorAttributes]; + return MergeLastContractAttributesForWrite([.. BuildAttributes(), .. visitorAttributes]); + } + + protected override bool ShouldPreserveLastContractAttributeForWrite(AttributeStatement attribute) + => attribute.Type.Equals(typeof(ModelReaderWriterBuildableAttribute)) + && attribute.Arguments is [TypeOfExpression typeOf] + && IsTypeAvailableForWrite(typeOf.Type); + + protected override MethodBodyStatement BuildLastContractAttributeForWrite(AttributeStatement attribute) + { + if (attribute.Arguments is not [TypeOfExpression typeOf]) + { + return attribute; + } + + var type = typeOf.Type; + var suppressions = new List(); + foreach (var componentType in EnumerateTypeComponents(type).Distinct()) + { + var experimentalTypeJustification = $"{componentType} is experimental and may change in future versions."; + var obsoleteTypeJustification = $"{componentType} is obsolete and may be removed in future versions."; + + if (componentType.IsFrameworkType) + { + suppressions.AddRange(GetDiagnosticSuppressions( + componentType.FrameworkType, + experimentalTypeJustification, + obsoleteTypeJustification)); + } + else if (FindCurrentTypeProvider(componentType) is { } typeProvider) + { + suppressions.AddRange(GetDiagnosticSuppressions( + typeProvider, + experimentalTypeJustification, + obsoleteTypeJustification)); + } + } + + return ApplyDiagnosticSuppressions(attribute, suppressions); } protected override IReadOnlyList BuildAttributes() @@ -100,16 +139,17 @@ protected override IReadOnlyList BuildAttributes() private static bool IsBuildableAttribute(MethodBodyStatement statement) { - var attribute = statement switch + return GetAttributeStatement(statement)?.Type.Equals(typeof(ModelReaderWriterBuildableAttribute)) == true; + } + + private static AttributeStatement? GetAttributeStatement(MethodBodyStatement statement) => + statement switch { - AttributeStatement directAttribute => directAttribute, - SuppressionStatement suppression => suppression.AsStatement(), + AttributeStatement attribute => attribute, + SuppressionStatement { Inner: { } inner } => GetAttributeStatement(inner), _ => null }; - return attribute?.Type.Equals(typeof(ModelReaderWriterBuildableAttribute)) == true; - } - private HashSet GetCustomizedBuildableTypes() { var customizedTypes = new HashSet(StringComparer.Ordinal); @@ -526,23 +566,30 @@ private static void AddAttributeForType( TypeProvider typeProvider, string experimentalTypeJustification, string obsoleteTypeJustification) - { - AttributeStatement? experimentalOrObsoleteAttribute = typeProvider.CanonicalView.Attributes - .FirstOrDefault(a => a.Type.Equals(typeof(ExperimentalAttribute)) || a.Type.Equals(typeof(ObsoleteAttribute))); - - var key = typeProvider.Type.FullyQualifiedName; + => attributes.Add( + typeProvider.Type.FullyQualifiedName, + ApplyDiagnosticSuppressions( + attributeStatement, + GetDiagnosticSuppressions( + typeProvider, + experimentalTypeJustification, + obsoleteTypeJustification))); - if (experimentalOrObsoleteAttribute?.Type.Equals(typeof(ExperimentalAttribute)) == true) - { - attributes.Add(key, new SuppressionStatement(attributeStatement, experimentalOrObsoleteAttribute.Arguments[0], experimentalTypeJustification)); - } - else if (experimentalOrObsoleteAttribute?.Type.Equals(typeof(ObsoleteAttribute)) == true) - { - attributes.Add(key, new SuppressionStatement(attributeStatement, Literal(DefaultObsoleteDiagnosticId), obsoleteTypeJustification)); - } - else + private static IEnumerable GetDiagnosticSuppressions( + TypeProvider typeProvider, + string experimentalTypeJustification, + string obsoleteTypeJustification) + { + foreach (var attribute in typeProvider.CanonicalView.Attributes) { - attributes.Add(key, attributeStatement); + if (attribute.Type.Equals(typeof(ExperimentalAttribute)) && attribute.Arguments.Count > 0) + { + yield return new DiagnosticSuppression(attribute.Arguments[0], experimentalTypeJustification); + } + else if (attribute.Type.Equals(typeof(ObsoleteAttribute))) + { + yield return new DiagnosticSuppression(Literal(DefaultObsoleteDiagnosticId), obsoleteTypeJustification); + } } } @@ -552,16 +599,26 @@ private static void AddAttributeForType( Type frameworkType, string experimentalTypeJustification, string obsoleteTypeJustification) - { - var key = frameworkType.FullName ?? frameworkType.Name; + => attributes.Add( + frameworkType.FullName ?? frameworkType.Name, + ApplyDiagnosticSuppressions( + attributeStatement, + GetDiagnosticSuppressions( + frameworkType, + experimentalTypeJustification, + obsoleteTypeJustification))); + private static IEnumerable GetDiagnosticSuppressions( + Type frameworkType, + string experimentalTypeJustification, + string obsoleteTypeJustification) + { var experimentalAttr = frameworkType.GetCustomAttributes(typeof(ExperimentalAttribute), false) .FirstOrDefault(); if (experimentalAttr != null) { var diagnosticId = experimentalAttr.GetType().GetProperty("DiagnosticId")?.GetValue(experimentalAttr); - attributes.Add(key, new SuppressionStatement(attributeStatement, Literal(diagnosticId), experimentalTypeJustification)); - return; + yield return new DiagnosticSuppression(Literal(diagnosticId), experimentalTypeJustification); } var obsoleteAttr = frameworkType.GetCustomAttributes(typeof(ObsoleteAttribute), false) @@ -570,13 +627,128 @@ private static void AddAttributeForType( { var diagnosticId = obsoleteAttr.GetType().GetProperty("DiagnosticId")?.GetValue(obsoleteAttr) ?? DefaultObsoleteDiagnosticId; - attributes.Add(key, new SuppressionStatement(attributeStatement, Literal(diagnosticId), obsoleteTypeJustification)); - return; + yield return new DiagnosticSuppression(Literal(diagnosticId), obsoleteTypeJustification); } + } - attributes.Add(key, attributeStatement); + private static MethodBodyStatement ApplyDiagnosticSuppressions( + MethodBodyStatement statement, + IEnumerable suppressions) + { + var appliedDiagnosticIds = new HashSet(); + foreach (var suppression in suppressions) + { + if (appliedDiagnosticIds.Add(suppression.DiagnosticId)) + { + statement = new SuppressionStatement(statement, suppression.DiagnosticId, suppression.Justification); + } + } + + return statement; } + private readonly record struct DiagnosticSuppression(ValueExpression DiagnosticId, string Justification); + + private static bool IsTypeAvailableForWrite(CSharpType type) + { + foreach (var componentType in EnumerateTypeComponents(type).Distinct()) + { + if (componentType.IsFrameworkType) + { + if (componentType.FrameworkType.GetCustomAttribute()?.IsError == true) + { + return false; + } + continue; + } + + var typeProvider = FindCurrentTypeProvider(componentType); + if (typeProvider is null || + HasErrorObsoleteAttribute(typeProvider) || + !IsResolvableBuildableType(componentType)) + { + return false; + } + } + + return true; + } + + private static bool HasErrorObsoleteAttribute(TypeProvider typeProvider) => + typeProvider.CanonicalView.Attributes.Any(attribute => + attribute.Type.Equals(typeof(ObsoleteAttribute)) && + attribute.Arguments.Count > 1 && + attribute.Arguments[1] is LiteralExpression { Literal: true }); + + private static IEnumerable EnumerateTypeComponents(CSharpType type) + { + if (type.DeclaringType is not null) + { + foreach (var declaringType in EnumerateTypeComponents(type.DeclaringType)) + { + yield return declaringType; + } + } + + foreach (var argument in type.Arguments) + { + foreach (var argumentType in EnumerateTypeComponents(argument)) + { + yield return argumentType; + } + } + + yield return type; + } + + private static TypeProvider? FindCurrentTypeProvider(CSharpType type) => + FindCurrentTypeProvider(CodeModelGenerator.Instance.OutputLibrary.TypeProviders, type) + ?? CodeModelGenerator.Instance.SourceInputModel.FindForTypeInCustomization( + type.Namespace, + GetTypeMetadataName(type), + GetDeclaringTypeMetadataName(type.DeclaringType), + includeReferencedAssemblies: true); + + private static TypeProvider? FindCurrentTypeProvider(IEnumerable providers, CSharpType type) + { + foreach (var provider in providers) + { + if (provider.Type.Equals(type)) + { + return provider; + } + + var nestedProvider = FindCurrentTypeProvider(provider.NestedTypes, type); + if (nestedProvider is not null) + { + return nestedProvider; + } + + var serializationProvider = FindCurrentTypeProvider(provider.SerializationProviders, type); + if (serializationProvider is not null) + { + return serializationProvider; + } + } + + return null; + } + + private static string? GetDeclaringTypeMetadataName(CSharpType? declaringType) + { + if (declaringType is null) + { + return null; + } + + var parentName = GetDeclaringTypeMetadataName(declaringType.DeclaringType); + var metadataName = GetTypeMetadataName(declaringType); + return parentName is null ? metadataName : $"{parentName}+{metadataName}"; + } + + private static string GetTypeMetadataName(CSharpType type) => + type.Arguments.Count == 0 ? type.Name : $"{type.Name}`{type.Arguments.Count}"; + private static bool IsModelReaderWriterInterfaceType(CSharpType type) { return type.Name.StartsWith("IPersistableModel") || type.Name.StartsWith("IJsonModel"); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/ModelReaderWriterContextDefinitionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/ModelReaderWriterContextDefinitionTests.cs index d55537cc0d0..d8dbed66997 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/ModelReaderWriterContextDefinitionTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/ModelReaderWriterContextDefinitionTests.cs @@ -9,6 +9,8 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.TypeSpec.Generator.ClientModel.Providers; using Microsoft.TypeSpec.Generator.Input; using Microsoft.TypeSpec.Generator.Primitives; @@ -71,6 +73,79 @@ public void ValidateModelReaderWriterBuildableAttributesAreGenerated() Assert.AreEqual(1, buildableAttributes.Count(), "Exactly one ModelReaderWriterBuildableAttribute should be generated for TestModel"); } + [Test] + public async Task PreservesPreviousBuildableAttributesButNotUnrelatedAttributes() + { + MockHelpers.LoadMockGenerator(); + var compilation = await Helpers.GetCompilationFromDirectoryAsync(parameters: "Custom"); + await MockHelpers.LoadMockGeneratorAsync( + inputModels: () => + [ + InputFactory.Model("CurrentModel", properties: + [ + InputFactory.Property("Name", InputPrimitiveType.String) + ]) + ], + compilation: () => Task.FromResult(compilation), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var contextDefinition = new ModelReaderWriterContextDefinition(); + var file = new TypeProviderWriter(contextDefinition).Write(); + + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + StringAssert.DoesNotContain("RemovedModel", file.Content); + StringAssert.DoesNotContain("ErrorObsoleteModel", file.Content); + StringAssert.DoesNotContain("ErrorObsoleteOuter", file.Content); + StringAssert.DoesNotContain("ErrorObsoleteTypeArgument", file.Content); + + AssertCompilesWithoutWarnings(compilation, file.Content); + } + + [Test] + public async Task PreservesDiagnosticSuppressionForDeeplyNestedBuildableType() + { + MockHelpers.LoadMockGenerator(); + var compilation = await Helpers.GetCompilationFromDirectoryAsync(parameters: "Custom"); + await MockHelpers.LoadMockGeneratorAsync( + compilation: () => Task.FromResult(compilation), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var contextDefinition = new ModelReaderWriterContextDefinition(); + var file = new TypeProviderWriter(contextDefinition).Write(); + + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + AssertCompilesWithoutWarnings(compilation, file.Content); + } + + [Test] + public async Task PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType() + { + MockHelpers.LoadMockGenerator(); + var compilation = await Helpers.GetCompilationFromDirectoryAsync(parameters: "Custom"); + await MockHelpers.LoadMockGeneratorAsync( + compilation: () => Task.FromResult(compilation), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var contextDefinition = new ModelReaderWriterContextDefinition(); + var file = new TypeProviderWriter(contextDefinition).Write(); + + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + AssertCompilesWithoutWarnings(compilation, file.Content); + } + + private static void AssertCompilesWithoutWarnings(Compilation compilation, string generatedCode) + { + var generatedTree = CSharpSyntaxTree.ParseText(generatedCode); + var diagnostics = compilation + .AddSyntaxTrees(generatedTree) + .GetDiagnostics() + .Where(diagnostic => + diagnostic.Severity >= DiagnosticSeverity.Warning && + diagnostic.Location.SourceTree == generatedTree) + .ToList(); + Assert.That(diagnostics, Is.Empty, string.Join(Environment.NewLine, diagnostics)); + } + [TestCase(true)] [TestCase(false)] public void ValidateModelReaderWriterBuildableAttributesAreGeneratedForNonModelsThatImplementMRW(bool implementsIPersistable) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType(Custom)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType(Custom)/Models.cs new file mode 100644 index 00000000000..14fb393acb1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType(Custom)/Models.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Sample.Models +{ + [Experimental("ARG001")] + [Obsolete("Use another type argument instead.")] + public class TypeArgument + { + } + + [Experimental("ARG001")] + public class Outer + { + public class Middle + { + public class DeepModel + { + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType.cs new file mode 100644 index 00000000000..25b12b7ef6c --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType.cs @@ -0,0 +1,18 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; +using Sample.Models; + +namespace Sample +{ +#pragma warning disable CS0618 // global::Sample.Models.TypeArgument is obsolete and may be removed in future versions. +#pragma warning disable ARG001 // global::Sample.Models.TypeArgument is experimental and may change in future versions. + [global::System.ClientModel.Primitives.ModelReaderWriterBuildableAttribute(typeof(global::Sample.Models.Outer.Middle.DeepModel))] +#pragma warning restore ARG001 // global::Sample.Models.TypeArgument is experimental and may change in future versions. +#pragma warning restore CS0618 // global::Sample.Models.TypeArgument is obsolete and may be removed in future versions. + public partial class SampleContext : global::System.ClientModel.Primitives.ModelReaderWriterContext + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType/SampleContext.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType/SampleContext.cs new file mode 100644 index 00000000000..97c1ebb9e81 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForConstructedGenericNestedBuildableType/SampleContext.cs @@ -0,0 +1,26 @@ +using System.ClientModel.Primitives; + +namespace Sample.Models +{ + public class TypeArgument + { + } + + public class Outer + { + public class Middle + { + public class DeepModel + { + } + } + } +} + +namespace Sample +{ + [ModelReaderWriterBuildable(typeof(Models.Outer.Middle.DeepModel))] + public partial class SampleContext : ModelReaderWriterContext + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType(Custom)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType(Custom)/Models.cs new file mode 100644 index 00000000000..08e097a0838 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType(Custom)/Models.cs @@ -0,0 +1,15 @@ +using System; + +namespace Sample.Models +{ + public class Outer + { + public class Middle + { + [Obsolete("Use another model instead.")] + public class DeepModel + { + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType.cs new file mode 100644 index 00000000000..ab871ac209d --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType.cs @@ -0,0 +1,16 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; +using Sample.Models; + +namespace Sample +{ +#pragma warning disable CS0618 // global::Sample.Models.Outer.Middle.DeepModel is obsolete and may be removed in future versions. + [global::System.ClientModel.Primitives.ModelReaderWriterBuildableAttribute(typeof(global::Sample.Models.Outer.Middle.DeepModel))] +#pragma warning restore CS0618 // global::Sample.Models.Outer.Middle.DeepModel is obsolete and may be removed in future versions. + public partial class SampleContext : global::System.ClientModel.Primitives.ModelReaderWriterContext + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType/SampleContext.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType/SampleContext.cs new file mode 100644 index 00000000000..76341bb9fe6 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesDiagnosticSuppressionForDeeplyNestedBuildableType/SampleContext.cs @@ -0,0 +1,22 @@ +using System.ClientModel.Primitives; + +namespace Sample.Models +{ + public class Outer + { + public class Middle + { + public class DeepModel + { + } + } + } +} + +namespace Sample +{ + [ModelReaderWriterBuildable(typeof(Models.Outer.Middle.DeepModel))] + public partial class SampleContext : ModelReaderWriterContext + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes(Custom)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes(Custom)/Models.cs new file mode 100644 index 00000000000..26442a7628b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes(Custom)/Models.cs @@ -0,0 +1,41 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Sample.Models +{ + public class CurrentModel + { + } + + [Obsolete("Use CurrentModel instead.")] + public class PreviousModel + { + } + + [Experimental("TEST001")] + public class ExperimentalPreviousModel + { + } + + [Obsolete("This model cannot be used.", true)] + public class ErrorObsoleteModel + { + } + + [Obsolete("This enclosing type cannot be used.", true)] + public class ErrorObsoleteOuter + { + public class NestedModel + { + } + } + + public class GenericModel + { + } + + [Obsolete("This type argument cannot be used.", true)] + public class ErrorObsoleteTypeArgument + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes.cs new file mode 100644 index 00000000000..daa92bcece9 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes.cs @@ -0,0 +1,20 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; +using Sample.Models; + +namespace Sample +{ + [global::System.ClientModel.Primitives.ModelReaderWriterBuildableAttribute(typeof(global::Sample.Models.CurrentModel))] +#pragma warning disable CS0618 // global::Sample.Models.PreviousModel is obsolete and may be removed in future versions. + [global::System.ClientModel.Primitives.ModelReaderWriterBuildableAttribute(typeof(global::Sample.Models.PreviousModel))] +#pragma warning restore CS0618 // global::Sample.Models.PreviousModel is obsolete and may be removed in future versions. +#pragma warning disable TEST001 // global::Sample.Models.ExperimentalPreviousModel is experimental and may change in future versions. + [global::System.ClientModel.Primitives.ModelReaderWriterBuildableAttribute(typeof(global::Sample.Models.ExperimentalPreviousModel))] +#pragma warning restore TEST001 // global::Sample.Models.ExperimentalPreviousModel is experimental and may change in future versions. + public partial class SampleContext : global::System.ClientModel.Primitives.ModelReaderWriterContext + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes/SampleContext.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes/SampleContext.cs new file mode 100644 index 00000000000..4d8c86360e3 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/PreservesPreviousBuildableAttributesButNotUnrelatedAttributes/SampleContext.cs @@ -0,0 +1,55 @@ +using System.ClientModel.Primitives; +using System.ComponentModel; + +namespace Sample.Models +{ + public class CurrentModel + { + } + + public class PreviousModel + { + } + + public class ExperimentalPreviousModel + { + } + + public class RemovedModel + { + } + + public class ErrorObsoleteModel + { + } + + public class ErrorObsoleteOuter + { + public class NestedModel + { + } + } + + public class GenericModel + { + } + + public class ErrorObsoleteTypeArgument + { + } +} + +namespace Sample +{ + [ModelReaderWriterBuildable(typeof(Models.CurrentModel))] + [ModelReaderWriterBuildable(typeof(Models.PreviousModel))] + [ModelReaderWriterBuildable(typeof(Models.ExperimentalPreviousModel))] + [ModelReaderWriterBuildable(typeof(Models.RemovedModel))] + [ModelReaderWriterBuildable(typeof(Models.ErrorObsoleteModel))] + [ModelReaderWriterBuildable(typeof(Models.ErrorObsoleteOuter.NestedModel))] + [ModelReaderWriterBuildable(typeof(Models.GenericModel))] + [EditorBrowsable(EditorBrowsableState.Never)] + public partial class SampleContext : ModelReaderWriterContext + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs index f2bc01a85e0..16a0b0931dc 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs @@ -179,7 +179,7 @@ internal CSharpType( /// public string FullyQualifiedName => DeclaringType is null ? $"{Namespace}.{Name}" - : $"{Namespace}.{DeclaringType.Name}.{Name}"; + : $"{DeclaringType.FullyQualifiedName}.{Name}"; public CSharpType? DeclaringType { get; private init; } public bool IsValueType { get; private init; } public bool IsEnum => _underlyingType is not null; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 31bc0d3fd63..e33f2d5525b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -368,7 +368,70 @@ public IReadOnlyList Attributes /// Builds the attributes emitted by the writer. Providers whose generated attributes depend on final /// generation decisions can override this without replacing attributes updated by visitors. /// - protected internal virtual IReadOnlyList BuildAttributesForWrite() => GetAttributes(); + protected internal virtual IReadOnlyList BuildAttributesForWrite() + => MergeLastContractAttributesForWrite(GetAttributes()); + + /// + /// Adds selected attributes from the last contract to the attributes emitted by the writer. + /// This write-time merge does not update or participate in visitor + /// and reference-map processing. + /// + protected internal virtual IReadOnlyList MergeLastContractAttributesForWrite( + IEnumerable originalAttributes) + { + var original = originalAttributes as IReadOnlyList ?? [.. originalAttributes]; + if (LastContractView?.Attributes is not { Count: > 0 } lastContractAttributes) + { + return original; + } + + var attributesToPreserve = lastContractAttributes + .Where(ShouldPreserveLastContractAttributeForWrite) + .ToList(); + if (attributesToPreserve.Count == 0) + { + return original; + } + + var seen = new HashSet( + original.Select(GetAttributeStatement) + .Where(static attribute => attribute is not null) + .Select(static attribute => attribute!.ToDisplayString()), + StringComparer.Ordinal); + + List? merged = null; + foreach (var attribute in attributesToPreserve) + { + if (!seen.Add(attribute.ToDisplayString())) + { + continue; + } + + merged ??= [.. original]; + merged.Add(BuildLastContractAttributeForWrite(attribute)); + } + + return merged ?? original; + } + + /// + /// Determines whether an attribute from the last contract should be emitted by the writer. + /// + protected internal virtual bool ShouldPreserveLastContractAttributeForWrite(AttributeStatement attribute) => false; + + /// + /// Builds the write-time statement for a selected attribute from the last contract. + /// + protected internal virtual MethodBodyStatement BuildLastContractAttributeForWrite(AttributeStatement attribute) + => attribute; + + private static AttributeStatement? GetAttributeStatement(MethodBodyStatement statement) => + statement switch + { + AttributeStatement attribute => attribute, + SuppressionStatement { Inner: { } inner } => GetAttributeStatement(inner), + _ => null + }; /// /// Indicates whether this provider's attributes should contribute to reference-map analysis. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/TypeSymbolExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/TypeSymbolExtensions.cs index 65924a3c308..fa245942365 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/TypeSymbolExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/TypeSymbolExtensions.cs @@ -224,7 +224,7 @@ private static CSharpType ConstructCSharpTypeFromSymbol( if (typeSymbol.ContainingType != null && typeSymbol.TypeKind != TypeKind.TypeParameter) { containingType = GetCSharpType(typeSymbol.ContainingType, visited); - ns = string.Join('.', pieces.Take(pieces.Length - 2)); + ns = typeSymbol.ContainingNamespace.GetFullyQualifiedNameFromDisplayString(); } CSharpType? baseType = null; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs index ac5f214e785..5aa3e55165d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs @@ -679,12 +679,23 @@ private void AppendType(CSharpType type, bool isDeclaration, bool writeTypeNameO AppendRaw("."); if (type.DeclaringType is not null) { - AppendRaw($"{type.DeclaringType.Name}."); + AppendDeclaringTypeName(type.DeclaringType, writeTypeNameOnly, genericDepth); } AppendRaw(type.Name); } + AppendTypeArguments(type, writeTypeNameOnly, genericDepth); + + // Add '?' for nullable value types, but skip if we're writing new instance UNLESS we're inside generic type arguments + if ((!_writingNewInstance || genericDepth > 0) && !isDeclaration && type is { IsNullable: true, IsValueType: true }) + { + AppendRaw("?"); + } + } + + private void AppendTypeArguments(CSharpType type, bool writeTypeNameOnly, int genericDepth) + { if (type.Arguments.Any()) { AppendRaw(_writingXmlDocumentation ? "{" : "<"); @@ -698,12 +709,18 @@ private void AppendType(CSharpType type, bool isDeclaration, bool writeTypeNameO } AppendRaw(_writingXmlDocumentation ? "}" : ">"); } + } - // Add '?' for nullable value types, but skip if we're writing new instance UNLESS we're inside generic type arguments - if ((!_writingNewInstance || genericDepth > 0) && !isDeclaration && type is { IsNullable: true, IsValueType: true }) + private void AppendDeclaringTypeName(CSharpType declaringType, bool writeTypeNameOnly, int genericDepth) + { + if (declaringType.DeclaringType is not null) { - AppendRaw("?"); + AppendDeclaringTypeName(declaringType.DeclaringType, writeTypeNameOnly, genericDepth); } + + AppendRaw(declaringType.Name); + AppendTypeArguments(declaringType, writeTypeNameOnly, genericDepth); + AppendRaw("."); } public CodeWriter WriteLine(FormattableString formattableString)