583 lines
21 KiB
C#
583 lines
21 KiB
C#
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<string> _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<string> 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<string, " + GetTypeName(type.InternalType, config) + ">",
|
|
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<string> 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>" : "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();
|
|
}
|
|
|
|
/// <summary>Converts an identifier from JSON into a C#-safe PascalCase identifier.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Converts a camelCase identifier from JSON into a C#-safe camelCase identifier.</summary>
|
|
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 + "/// <summary>");
|
|
_ = sw.AppendFormat(indentMembers + "/// Examples: " + field.GetExamplesText());
|
|
_ = sw.AppendFormat(indentMembers + "/// </summary>");
|
|
_ = 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)
|
|
{
|
|
_ = sw.AppendFormat(indentMembers + "public {0} {1} {{ init; get; }}{2}", GetCollectionTypeName(elementTypeName: GetTypeName(field.Type.InternalType, config), config.CollectionType), classPropertyName, Environment.NewLine);
|
|
}
|
|
else
|
|
{
|
|
_ = sw.AppendFormat(indentMembers + "public {0} {1} {{ init; 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.UseThisKeyWord)
|
|
_ = 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();
|
|
}
|
|
|
|
} |