using System.Text; namespace Json2CSharpCodeGenerator.Lib.CodeWriters; public class CSharpCodeWriter : ICodeBuilder { public string FileExtension => ".cs"; public string DisplayName => "C#"; private const string _NoRenameAttribute = "[Obfuscation(Feature = \"renaming\", Exclude = true)]"; private const string _NoPruneAttribute = "[Obfuscation(Feature = \"trigger\", Exclude = false)]"; private static readonly HashSet _ReservedKeywords = new(comparer: StringComparer.Ordinal) { "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void", "volatile", "while" }; public bool IsReservedKeyword(string word) => _ReservedKeywords.Contains(word ?? string.Empty); IReadOnlyCollection ICodeBuilder.ReservedKeywords => _ReservedKeywords; public string GetTypeName(JsonType type, IJsonClassGeneratorConfig config) { return type.Type switch { JsonTypeEnum.Anything => "object", JsonTypeEnum.Array => GetCollectionTypeName(elementTypeName: GetTypeName(type.InternalType, config), config.CollectionType), JsonTypeEnum.Dictionary => "Dictionary", JsonTypeEnum.Boolean => "bool", JsonTypeEnum.Float => "double", JsonTypeEnum.Integer => "int", JsonTypeEnum.Long => "long", JsonTypeEnum.Date => "DateTime", JsonTypeEnum.NonConstrained => "object", JsonTypeEnum.NullableBoolean => "bool?", JsonTypeEnum.NullableFloat => "double?", JsonTypeEnum.NullableInteger => "int?", JsonTypeEnum.NullableLong => "long?", JsonTypeEnum.NullableDate => "DateTime?", JsonTypeEnum.NullableSomething => "object", JsonTypeEnum.Object => type.NewAssignedName, JsonTypeEnum.String => "string", _ => throw new NotSupportedException("Unsupported json type: " + type.Type), }; } private static string GetCollectionTypeName(string elementTypeName, OutputCollectionType type) { return type switch { OutputCollectionType.Array => elementTypeName + "[]", OutputCollectionType.MutableList => "List<" + elementTypeName + ">", OutputCollectionType.IReadOnlyList => "IReadOnlyList<" + elementTypeName + ">", OutputCollectionType.ImmutableArray => "ImmutableArray<" + elementTypeName + ">", _ => throw new ArgumentOutOfRangeException(paramName: nameof(type), actualValue: type, message: "Invalid " + nameof(OutputCollectionType) + " enum value."), }; } private bool ShouldApplyNoRenamingAttribute(IJsonClassGeneratorConfig config) => config.ApplyObfuscationAttributes && !config.UsePascalCase; private bool ShouldApplyNoPruneAttribute(IJsonClassGeneratorConfig config) => config.ApplyObfuscationAttributes && config.OutputType == OutputTypes.MutableClass && config.MutableClasses.Members == OutputMembers.AsPublicFields; public void WriteFileStart(IJsonClassGeneratorConfig config, StringBuilder sw) { if (config.HasNamespace()) { List importNamespaces = new() { "System", "System.Collections.Generic" }; if (ShouldApplyNoPruneAttribute(config) || ShouldApplyNoRenamingAttribute(config)) { importNamespaces.Add("System.Reflection"); } switch (config.AttributeLibrary) { case JsonLibrary.NewtonsoftJson: importNamespaces.Add("Newtonsoft.Json"); importNamespaces.Add("Newtonsoft.Json.Linq"); break; case JsonLibrary.SystemTextJson: importNamespaces.Add("System.Text.Json"); break; } if (!string.IsNullOrWhiteSpace(config.SecondaryNamespace) && !config.UseNestedClasses) { importNamespaces.Add(config.SecondaryNamespace); } importNamespaces.Sort(CompareNamespacesSystemFirst); foreach (string ns in importNamespaces) // NOTE: Using `.Distinct()` after sorting may cause out-of-order results. { _ = sw.AppendFormat("using {0};{1}", ns, Environment.NewLine); } } if (config.UseNestedClasses) { _ = sw.AppendFormat(" {0} class {1}", config.InternalVisibility ? "internal" : "public", config.MainClass); _ = sw.AppendLine(" {"); } } private static int CompareNamespacesSystemFirst(string x, string y) { if (x == "System") return -1; if (y == "System") return 1; if (x.StartsWith("System.", StringComparison.Ordinal)) { if (y.StartsWith("System.", StringComparison.Ordinal)) { // Both start with "System." - so compare them normally. return StringComparer.Ordinal.Compare(x, y); } else { // Only `x` starts with "System", so `x` should always come first (i.e. `x < y` or `y > x`). return -1; } } else { // Only `y` starts with "System", so `y` should always come first (i.e. `x > y` or `y < x`). if (y.StartsWith("System.", StringComparison.Ordinal)) { return 1; } else { // Neither are "System." namespaces - so compare them normally. return StringComparer.Ordinal.Compare(x, y); } } } public void WriteFileEnd(IJsonClassGeneratorConfig config, StringBuilder sw) { if (config.UseNestedClasses) { _ = sw.AppendLine(" }"); } } public void WriteDeserializationComment(IJsonClassGeneratorConfig config, StringBuilder sw, bool rootIsArray = false) { string deserializer; switch (config.AttributeLibrary) { case JsonLibrary.NewtonsoftJson: deserializer = "JsonConvert.DeserializeObject"; break; case JsonLibrary.SystemTextJson: deserializer = "JsonSerializer.Deserialize"; break; default: return; } string rootType = rootIsArray ? "List" : "Root"; _ = sw.AppendLine($"// Root myDeserializedClass = {deserializer}<{rootType}>(myJsonResponse);"); } public void WriteNamespaceStart(IJsonClassGeneratorConfig config, StringBuilder sw, bool root) { _ = sw.AppendLine(); _ = sw.AppendFormat("namespace {0}", root && !config.UseNestedClasses ? config.Namespace : (config.SecondaryNamespace ?? config.Namespace)); _ = sw.AppendLine("{"); _ = sw.AppendLine(); } public void WriteNamespaceEnd(IJsonClassGeneratorConfig config, StringBuilder sw, bool root) => sw.AppendLine("}"); private static string GetTypeIndent(IJsonClassGeneratorConfig config, bool typeIsRoot) { if (config.UseNestedClasses) { if (typeIsRoot) { return " "; // 4x } else { return " "; // 8x } } else { return " "; // 4x } } public void WriteClass(IJsonClassGeneratorConfig config, StringBuilder sw, JsonType type) { string indentTypes = GetTypeIndent(config, type.IsRoot); string indentMembers = indentTypes + " "; string indentBodies = indentMembers + " "; const string visibility = "public"; string className = type.AssignedName; if (config.OutputType == OutputTypes.ImmutableRecord) { sw.AppendFormat(indentTypes + "{0} record {1}({2}", visibility, className, Environment.NewLine); } else { sw.AppendFormat(indentTypes + "{0} class {1}{2}", visibility, className, Environment.NewLine); sw.AppendLine(indentTypes + "{"); } #if CAN_SUPRESS var shouldSuppressWarning = config.InternalVisibility && !config.UseProperties && !config.ExplicitDeserialization; if (shouldSuppressWarning) { sw.AppendFormat("#pragma warning disable 0649"); if (!config.UsePascalCase) sw.AppendLine(); } if (config.ExplicitDeserialization) { if (config.UseProperties) WriteClassWithPropertiesExplicitDeserialization(sw, type, prefix); else WriteClassWithFieldsExplicitDeserialization(sw, type, prefix); } else #endif { if (config.OutputType == OutputTypes.ImmutableClass) { WriteClassConstructor(config, sw, type, indentMembers: indentMembers, indentBodies: indentBodies); } WriteClassMembers(config, sw, type, indentMembers); } #if CAN_SUPPRESS if (shouldSuppressWarning) { sw.WriteLine(); sw.WriteLine("#pragma warning restore 0649"); sw.WriteLine(); } #endif if (config.OutputType == OutputTypes.ImmutableRecord) { sw.AppendLine(indentTypes + ");"); } else if ((!config.UseNestedClasses) || (config.UseNestedClasses && !type.IsRoot)) { sw.AppendLine(indentTypes + "}"); } sw.AppendLine(); } /// Converts an identifier from JSON into a C#-safe PascalCase identifier. private string GetCSharpPascalCaseName(string name) { // Check if property is a reserved keyword if (IsReservedKeyword(name)) name = "@" + name; // Check if property name starts with number if (!string.IsNullOrEmpty(name) && char.IsDigit(name[0])) name = "_" + name; return name; } /// Converts a camelCase identifier from JSON into a C#-safe camelCase identifier. private string GetCSharpCamelCaseName(string camelCaseFromJson) { if (string.IsNullOrEmpty(camelCaseFromJson)) throw new ArgumentException(message: "Value cannot be null or empty.", paramName: nameof(camelCaseFromJson)); string name = camelCaseFromJson; // if (name.Length >= 3) { if (char.IsUpper(name[0]) && char.IsUpper(name[1]) && char.IsLower(name[2])) { // "ABc" --> "abc" // this may be wrong in some cases, if the first two letters are a 2-letter acronym, like "IO". name = name.Substring(startIndex: 0, length: 2).ToLowerInvariant() + name.Substring(startIndex: 2); } else if (char.IsUpper(name[0])) { // "Abc" --> "abc" // "AbC" --> "abC" name = char.ToLower(name[0]) + name.Substring(startIndex: 1); } } else if (name.Length == 2) { if (char.IsUpper(name[0])) { // "AB" --> "ab" // "Ab" --> "ab" name = name.ToLowerInvariant(); } } else // Implicit: name.Length == 1 { // "A" --> "a" name = name.ToLowerInvariant(); } if (!char.IsLetter(name[0])) name = "_" + name; else if (IsReservedKeyword(name)) name = "@" + name; return name; } public void WriteClassMembers(IJsonClassGeneratorConfig config, StringBuilder sw, JsonType type, string indentMembers) { bool first = true; foreach (FieldInfo field in type.Fields) { string classPropertyName = GetCSharpPascalCaseName(field.MemberName); string propertyAttribute = config.GetCSharpJsonAttributeCode(field); // If we are using record types and this is not the first iteration, add a comma and newline to the previous line // this is required because every line except the last should end with a comma if (config.OutputType == OutputTypes.ImmutableRecord && !first) { _ = sw.AppendLine(","); } if (!first && ((propertyAttribute.Length > 0 && config.OutputType != OutputTypes.ImmutableRecord) || config.ExamplesInDocumentation)) { // If rendering examples/XML comments - or property attributes - then add a newline before the property for readability's sake (except if it's the first property in the class) // For record types, we want all members to be next to each other, unless when using examples _ = sw.AppendLine(); } if (config.ExamplesInDocumentation) { _ = sw.AppendFormat(indentMembers + "/// "); _ = sw.AppendFormat(indentMembers + "/// Examples: " + field.GetExamplesText()); _ = sw.AppendFormat(indentMembers + "/// "); _ = sw.AppendLine(); } if (propertyAttribute.Length > 0) { _ = sw.Append(indentMembers); _ = sw.Append(propertyAttribute); if (config.OutputType != OutputTypes.ImmutableRecord) { _ = sw.AppendLine(); } } // record types is not compatible with UseFields, so it comes first if (config.OutputType == OutputTypes.ImmutableRecord) { // NOTE: not adding newlines here, that happens at the start of the loop. We need this so we can lazily add commas at the end. if (field.Type.Type == JsonTypeEnum.Array) { // TODO: Respect config.CollectionType _ = sw.AppendFormat(" IReadOnlyList<{0}> {1}", GetTypeName(field.Type.InternalType, config), classPropertyName); } else { _ = sw.AppendFormat(" {0} {1}", field.Type.GetTypeName(), classPropertyName); } } else if (config.OutputType == OutputTypes.MutableClass) { if (config.MutableClasses.Members == OutputMembers.AsPublicFields) { // Render a field like `public int Foobar;`: bool useReadonlyModifier = config.OutputType == OutputTypes.ImmutableClass; _ = sw.AppendFormat(indentMembers + "public {0}{1} {2};{3}", useReadonlyModifier ? "readonly " : "", field.Type.GetTypeName(), classPropertyName, Environment.NewLine); } else if (config.MutableClasses.Members == OutputMembers.AsProperties) { string getterSetterPart = "{ get; set; }"; bool addCollectionPropertyInitializer = config.MutableClasses.ReadOnlyCollectionProperties && field.Type.IsCollectionType() && config.CollectionType == OutputCollectionType.MutableList; if (addCollectionPropertyInitializer && field.Type.Type == JsonTypeEnum.Array) { getterSetterPart = "{ get; } = new " + field.Type.GetTypeName() + "();"; } _ = sw.AppendFormat(indentMembers + "public {0} {1} {2}{3}", field.Type.GetTypeName(), classPropertyName, getterSetterPart, Environment.NewLine); } else { const string PATH = nameof(config) + "." + nameof(config.MutableClasses) + "." + nameof(config.MutableClasses.Members); const string MSG_FMT = "Invalid " + nameof(OutputMembers) + " enum value for " + PATH + ": {0}"; throw new InvalidOperationException(MSG_FMT); } } else if (config.OutputType == OutputTypes.ImmutableClass) { if (field.Type.Type == JsonTypeEnum.Array) { // TODO: Respect config.CollectionType _ = sw.AppendFormat(indentMembers + "public IReadOnlyList<{0}> {1} {{ get; }}{2}", GetTypeName(field.Type.InternalType, config), classPropertyName, Environment.NewLine); } else { _ = sw.AppendFormat(indentMembers + "public {0} {1} {{ get; }}{2}", field.Type.GetTypeName(), classPropertyName, Environment.NewLine); } } else { throw new InvalidOperationException("Invalid " + nameof(OutputTypes) + " value: " + config.OutputType); } first = false; } // emit a final newline if we're dealing with record types if (config.OutputType == OutputTypes.ImmutableRecord) { _ = sw.AppendLine(); } } private void WriteClassConstructor(IJsonClassGeneratorConfig config, StringBuilder sw, JsonType type, string indentMembers, string indentBodies) { // Write an empty constructor on a single-line: if (type.Fields.Count == 0) { _ = sw.AppendFormat(indentMembers + "public {0}() {{}}{1}", type.AssignedName, Environment.NewLine); return; } // Constructor signature: { switch (config.AttributeLibrary) { case JsonLibrary.NewtonsoftJson: case JsonLibrary.SystemTextJson: // Both libraries use the same attribute name: [JsonConstructor] _ = sw.AppendLine(indentMembers + "[JsonConstructor]"); break; } _ = sw.AppendFormat(indentMembers + "public {0}({1}", type.AssignedName, Environment.NewLine); FieldInfo lastField = type.Fields[type.Fields.Count - 1]; foreach (FieldInfo field in type.Fields) { // Writes something like: `[JsonProperty("foobar")] string foobar,` string ctorParameterName = GetCSharpCamelCaseName(field.MemberName); bool isLast = ReferenceEquals(field, lastField); string comma = isLast ? "" : ","; // _ = sw.Append(indentBodies); string attribute = config.GetCSharpJsonAttributeCode(field); if (attribute.Length > 0) { _ = sw.Append(attribute); _ = sw.Append(' '); } // e.g. `String foobar,\r\n` _ = sw.AppendFormat("{0} {1}{2}{3}", /*0:*/ field.Type.GetTypeName(), /*1:*/ ctorParameterName, /*2:*/ comma, /*3:*/ Environment.NewLine); } } _ = sw.AppendLine(indentMembers + ")"); // Constructor body: _ = sw.AppendLine(indentMembers + "{"); foreach (FieldInfo field in type.Fields) { string ctorParameterName = GetCSharpCamelCaseName(field.MemberName); string classPropertyName = GetCSharpPascalCaseName(field.MemberName); if (config.MutableClasses.ReadOnlyCollectionProperties || !string.IsNullOrEmpty(classPropertyName)) _ = sw.AppendFormat(indentBodies + "{0} = {1};{2}", /*0:*/ classPropertyName, /*1:*/ ctorParameterName, /*2:*/ Environment.NewLine); else _ = sw.AppendFormat(indentBodies + "this.{0} = {1};{2}", /*0:*/ classPropertyName, /*1:*/ ctorParameterName, /*2:*/ Environment.NewLine); } _ = sw.AppendLine(indentMembers + "}"); _ = sw.AppendLine(); } }