From 326c3b9dfbf33722ca5f195e219298cd2f48b219 Mon Sep 17 00:00:00 2001 From: Corniel Nobel Date: Fri, 14 Jun 2024 15:17:41 +0200 Subject: [PATCH] CodeName and its conventions. --- .../Code_name_covention_specs.cs | 75 +++++++ .../OpenApiEnumNameConvention.cs | 42 ++++ .../OpenApiTypeResolver.Resolve.cs | 4 +- .../OpenApiTypeResolver.cs | 9 +- .../Qowaiv.CodeGeneration.OpenApi.csproj | 2 +- src/Qowaiv.CodeGeneration/CodeName.cs | 74 +++++++ .../CodeNameConvention.cs | 190 ++++++++++++++++++ src/Qowaiv.CodeGeneration/NamingStrategy.cs | 53 ----- .../Qowaiv.CodeGeneration.csproj | 6 +- 9 files changed, 390 insertions(+), 65 deletions(-) create mode 100644 specs/Qowaiv.CodeGeneration.Specs/Code_name_covention_specs.cs create mode 100644 src/Qowaiv.CodeGeneration.OpenApi/OpenApiEnumNameConvention.cs create mode 100644 src/Qowaiv.CodeGeneration/CodeName.cs create mode 100644 src/Qowaiv.CodeGeneration/CodeNameConvention.cs delete mode 100644 src/Qowaiv.CodeGeneration/NamingStrategy.cs diff --git a/specs/Qowaiv.CodeGeneration.Specs/Code_name_covention_specs.cs b/specs/Qowaiv.CodeGeneration.Specs/Code_name_covention_specs.cs new file mode 100644 index 0000000..5c2cb9d --- /dev/null +++ b/specs/Qowaiv.CodeGeneration.Specs/Code_name_covention_specs.cs @@ -0,0 +1,75 @@ +namespace Code_name_covention_specs; + +public class Splits +{ + [TestCase("HelloWorld", "Hello", "World")] + [TestCase("helloWorld", "Hello", "World")] + [TestCase("iAmNuts", "I", "Am", "Nuts")] + [TestCase("XMLIsOld", "XML", "Is", "Old")] + [TestCase("XMLIsOLD", "XML", "Is", "OLD")] + public void With_Pascal_case(string str, params string[] parts) + => CodeNameConvention.PascalCase.Split(str).Should().BeEquivalentTo(parts); + + [TestCase("HelloWorld", "hello", "World")] + [TestCase("helloWorld", "hello", "World")] + [TestCase("iAmNuts", "i", "Am", "Nuts")] + [TestCase("XMLIsOld", "xml", "Is", "Old")] + [TestCase("XMLIsOLD", "xml", "Is", "OLD")] + public void With_Camel_case(string str, params string[] parts) + => CodeNameConvention.CamelCase.Split(str).Should().BeEquivalentTo(parts); + + [TestCase("Hello-World", "hello", "world")] + [TestCase("hello--World", "hello", "world")] + public void With_Kebab_case(string str, params string[] parts) + => CodeNameConvention.KebabCase.Split(str).Should().BeEquivalentTo(parts); + + [TestCase("Hello_World", "hello", "world")] + [TestCase("hello__World", "hello", "world")] + public void With_Snake_case(string str, params string[] parts) + => CodeNameConvention.SnakeCase.Split(str).Should().BeEquivalentTo(parts); + + [TestCase("Hello_World", "HELLO", "WORLD")] + [TestCase("hello__World", "HELLO", "WORLD")] + public void With_Screaming_Snake_case(string str, params string[] parts) + => CodeNameConvention.ScreamingSnakeCase.Split(str).Should().BeEquivalentTo(parts); + + [TestCase("Hello World", "Hello", "World")] + [TestCase("Hello\tWorld", "Hello", "World")] + [TestCase("Hello \tWorld", "Hello", "World")] + [TestCase("Hello_World", "Hello", "World")] + [TestCase("Hello World non-breaking", "Hello", "World", "non-breaking")] + public void With_Sentence_case(string str, params string[] parts) + => CodeNameConvention.SentenceCase.Split(str).Should().BeEquivalentTo(parts); +} + +public class Formats +{ + [TestCase("HelloWorld", "hello", "World")] + [TestCase("HelloWorld", "Hello", "World")] + public void With_Pacal_case(string str, params string[] parts) + => CodeNameConvention.PascalCase.ToString(parts).Should().Be(str); + + [TestCase("helloWorld", "hello", "World")] + [TestCase("helloWorld", "Hello", "World")] + public void With_Dromedary_case(string str, params string[] parts) + => CodeNameConvention.CamelCase.ToString(parts).Should().Be(str); + + [TestCase("hello-world", "hello", "world")] + [TestCase("hello-world", "Hello", "World")] + public void With_Kebab_case(string str, params string[] parts) + => CodeNameConvention.KebabCase.ToString(parts).Should().Be(str); + + [TestCase("hello_world", "hello", "world")] + [TestCase("hello_world", "Hello", "World")] + public void With_Snake_case(string str, params string[] parts) + => CodeNameConvention.SnakeCase.ToString(parts).Should().Be(str); + + [TestCase("HELLO_WORLD", "hello", "world")] + [TestCase("HELLO_WORLD", "Hello", "World")] + public void With_Screaming_Snake_case(string str, params string[] parts) + => CodeNameConvention.ScreamingSnakeCase.ToString(parts).Should().Be(str); + + [TestCase("Hello World", "Hello", "World")] + public void With_Sentence_case(string str, params string[] parts) + => CodeNameConvention.SentenceCase.ToString(parts).Should().Be(str); +} diff --git a/src/Qowaiv.CodeGeneration.OpenApi/OpenApiEnumNameConvention.cs b/src/Qowaiv.CodeGeneration.OpenApi/OpenApiEnumNameConvention.cs new file mode 100644 index 0000000..3c20b53 --- /dev/null +++ b/src/Qowaiv.CodeGeneration.OpenApi/OpenApiEnumNameConvention.cs @@ -0,0 +1,42 @@ +namespace Qowaiv.CodeGeneration.OpenApi; + +/// Implements Open API specific name convention for enum values. +public class OpenApiEnumNameConvention : CodeNameConvention +{ + internal static readonly OpenApiEnumNameConvention Instance = new(); + + /// + public override string Name => "Open API enum name"; + + /// Splitters used to seperates parts of a name. + protected virtual IReadOnlyCollection Splitters { get; } = [' ', '-', '_', '\t', '\r', '\n', '.', '!', ',', ';', '#', (char)160]; + + /// + [Pure] + public override IReadOnlyCollection Split(IEnumerable parts) + => Guard.NotNull(parts) + .OfType() + .Where(p => p is { Length: > 0 }) + .Select(Format) + .SelectMany(p => p.Split([.. Splitters], StringSplitOptions.RemoveEmptyEntries)) + .ToArray(); + + /// Formats a part. + [Pure] + protected virtual string Format(string part, int index) => part + .Replace("+", " pls ") + .Replace("(", string.Empty) + .Replace(")", string.Empty); + + /// + [Pure] + public override string ToString(IReadOnlyCollection parts) + { + var name = string.Join('_', Guard.NotNull(parts).Where(p => p is { Length: > 0 })); + if (char.IsAsciiDigit(name[0])) + { + name = '_' + name; + } + return CodeName.EscapeKeyword(name); + } +} diff --git a/src/Qowaiv.CodeGeneration.OpenApi/OpenApiTypeResolver.Resolve.cs b/src/Qowaiv.CodeGeneration.OpenApi/OpenApiTypeResolver.Resolve.cs index 9298d4c..50c208c 100644 --- a/src/Qowaiv.CodeGeneration.OpenApi/OpenApiTypeResolver.Resolve.cs +++ b/src/Qowaiv.CodeGeneration.OpenApi/OpenApiTypeResolver.Resolve.cs @@ -18,8 +18,8 @@ public partial class OpenApiTypeResolver var name = schema.ReferenceId ?? schema.Path.Last; var lastDot = name.LastIndexOf('.'); var ns = lastDot == -1 ? DefaultNamespace : DefaultNamespace.Child(name[..lastDot]); - name = NamingStrategy.PascalCase((lastDot == -1 ? name : name[(lastDot + 1)..]).TrimStart('_')); - return new(ns, name); + var codeName = CodeName.Create(lastDot == -1 ? name : name[(lastDot + 1)..], CodeNameConvention.PascalCase); + return new(ns, codeName.ToString()); } [Pure] diff --git a/src/Qowaiv.CodeGeneration.OpenApi/OpenApiTypeResolver.cs b/src/Qowaiv.CodeGeneration.OpenApi/OpenApiTypeResolver.cs index 48b6071..f4d81c7 100644 --- a/src/Qowaiv.CodeGeneration.OpenApi/OpenApiTypeResolver.cs +++ b/src/Qowaiv.CodeGeneration.OpenApi/OpenApiTypeResolver.cs @@ -47,13 +47,14 @@ protected virtual EnumerationField ResolveEnumField(OpenApiString @enum, Enumera [Pure] protected virtual string PropertyName(ResolveOpenApiSchema schema) - => NamingStrategy.PascalCase(schema.Path.Last, schema.Model!); + => CodeName.Create(schema.Path.Last, CodeNameConvention.PascalCase).PropertyFor(schema.Model!); [Pure] - protected virtual string EnumValueName(Enumeration @enum, OpenApiString enumValue) - => NamingStrategy.Enum(Guard.NotNull(enumValue).Value); + protected virtual string EnumValueName(Enumeration @enum, OpenApiString enumValue) => CodeName + .Create(Guard.NotNull(enumValue).Value, OpenApiEnumNameConvention.Instance) + .ToString(); [Pure] protected virtual string NormalizeFormat(string? str) - => (str ?? string.Empty).ToUpperInvariant().Replace("-", ""); + => (str ?? string.Empty).ToUpperInvariant().Replace("-", string.Empty); } diff --git a/src/Qowaiv.CodeGeneration.OpenApi/Qowaiv.CodeGeneration.OpenApi.csproj b/src/Qowaiv.CodeGeneration.OpenApi/Qowaiv.CodeGeneration.OpenApi.csproj index 13b79ba..d9cbca9 100644 --- a/src/Qowaiv.CodeGeneration.OpenApi/Qowaiv.CodeGeneration.OpenApi.csproj +++ b/src/Qowaiv.CodeGeneration.OpenApi/Qowaiv.CodeGeneration.OpenApi.csproj @@ -8,7 +8,7 @@ true - 0.0.1-alpha-013 + 0.0.1-alpha-014 Qowaiv.CodeGeneration.OpenApi ToBeReleased diff --git a/src/Qowaiv.CodeGeneration/CodeName.cs b/src/Qowaiv.CodeGeneration/CodeName.cs new file mode 100644 index 0000000..6419ebb --- /dev/null +++ b/src/Qowaiv.CodeGeneration/CodeName.cs @@ -0,0 +1,74 @@ +namespace Qowaiv.CodeGeneration; + +/// Represents the name of a piece of code (class, property, field, method, etc.). +[DebuggerDisplay("{ToString()}, Covention = {Convention.Name}")] +public readonly struct CodeName : IFormattable +{ + private CodeName(IReadOnlyCollection nameParts, CodeNameConvention nameConvention) + { + parts = nameParts; + convention = nameConvention; + } + + private readonly IReadOnlyCollection parts; + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private readonly CodeNameConvention? convention; + + /// The convention applied to create this (code) name. + public CodeNameConvention Convention => convention ?? CodeNameConvention.None; + + /// Creates a new (code) name applying the specified convention. + [Pure] + public static CodeName Create(string? name, CodeNameConvention convention) + => new(Guard.NotNull(convention).Split(name), convention); + + /// + [Pure] + public override string ToString() => ToString(Convention); + + /// + [Pure] + public string ToString(string? format, IFormatProvider? formatProvider) => format?.ToUpperInvariant() switch + { + null or "" => ToString(Convention), + "PASCAL" or "PASCALCASE" => ToString(CodeNameConvention.CamelCase), + "CAMEL" or "CAMELCASE" => ToString(CodeNameConvention.CamelCase), + "KEBAB" or "KEBABCASE" => ToString(CodeNameConvention.KebabCase), + "SNAKE" or "SNAKECASE" => ToString(CodeNameConvention.SnakeCase), + "SCREAMINGSNAKE" => ToString(CodeNameConvention.ScreamingSnakeCase), + _ => throw new FormatException($"Format '{format}' is unknown."), + }; + + /// Represents the code name as string according to the naming convention. + [Pure] + public string ToString(CodeNameConvention convention) => Guard.NotNull(convention).ToString(parts); + + /// Represents the code name as property name for the specified enclosing type. + /// + /// The enclosing type. + /// + /// + /// The optional naming convention, if not specified. + /// + [Pure] + public string PropertyFor(Type enclosing, CodeNameConvention? convention = null) + { + Guard.NotNull(enclosing); + convention ??= CodeNameConvention.PascalCase; + + var name = convention.ToString(parts); + return enclosing.Name == name || char.IsAsciiDigit(name[0]) + ? '_' + name + : EscapeKeyword(name); + } + + /// Escapes the name with an '@' if it a C# keyword. + [Pure] + public static string EscapeKeyword(string name) + => keywords.Contains(Guard.NotNullOrEmpty(name)) + ? "@" + name + : name; + + private static readonly string[] keywords = ["default", "new", "class"]; +} diff --git a/src/Qowaiv.CodeGeneration/CodeNameConvention.cs b/src/Qowaiv.CodeGeneration/CodeNameConvention.cs new file mode 100644 index 0000000..e8bc69d --- /dev/null +++ b/src/Qowaiv.CodeGeneration/CodeNameConvention.cs @@ -0,0 +1,190 @@ +namespace Qowaiv.CodeGeneration; + +/// Convention on how to write a name describing code. +public abstract class CodeNameConvention +{ + /// No naming convention. + public static readonly CodeNameConvention None = new NoCovention(); + + /// Parts are written without spaces with capitalized words. + public static readonly CodeNameConvention PascalCase = new PascalCasing(); + + /// Parts are written without spaces with capitalized words, starting lowercase. + public static readonly CodeNameConvention CamelCase = new CamelCasing(); + + /// Parts are written lowercase and connected using hyphens. + public static readonly CodeNameConvention KebabCase = new KebabCasing(); + + /// Parts are written lowercase and connected using underscores. + public static readonly CodeNameConvention SnakeCase = new SnakeCasing(); + + /// Parts are written uppercase and connected using underscores. + public static readonly CodeNameConvention ScreamingSnakeCase = new ScreamingSnakeCasing(); + + /// Parts are written as is connected with spaces. + public static readonly CodeNameConvention SentenceCase = new SentenceCasing(); + + /// The name of the convention. + public abstract string Name { get; } + + /// Represents the parts as string according to the naming convention. + [Pure] + public abstract string ToString(IReadOnlyCollection parts); + + /// Splits the string in parts according to the naming convention. + [Pure] + public IReadOnlyCollection Split(string? part) => Split([part]); + + /// Splits the parts in parts according to the naming convention. + [Pure] + public abstract IReadOnlyCollection Split(IEnumerable parts); + + /// Applies formatting to the parts. + [Pure] + private static IEnumerable Trim(IEnumerable parts) => parts.OfType().Where(p => p is { Length: > 0 }); + + private class PascalCasing : CodeNameConvention + { + /// + public override string Name => "Pascal case"; + + /// + [Pure] + public override IReadOnlyCollection Split(IEnumerable parts) + => Trim(parts.SelectMany(SplitPart)) + .Select((p, i) => i == 0 ? Formatted(p, i) : p) + .ToArray(); + + [Pure] + private IEnumerable SplitPart(string part) + { + part ??= string.Empty; + var i = 0; + var upper = 0; + + while (++i < part.Length) + { + if (char.IsUpper(part[i]) && !char.IsUpper(part[i - 1])) + { + yield return part[upper..i]; + upper = i; + } + // multiple Uppercases + else if (!char.IsUpper(part[i]) && char.IsUpper(part[i - 1])) + { + yield return part[upper..(i - 1)]; + upper = i - 1; + } + } + yield return part[upper..]; + } + + /// + [Pure] + public sealed override string ToString(IReadOnlyCollection parts) => string.Concat(Trim(parts).Select(Formatted)); + + [Pure] + protected virtual string Formatted(string part, int index) => char.ToUpperInvariant(part[0]) + part[1..]; + } + + private sealed class CamelCasing : PascalCasing + { + /// + public override string Name => "Camel case"; + + [Pure] + protected override string Formatted(string part, int index) + { + if (index == 0) + { + var start = new string(part.TakeWhile(char.IsUpper).ToArray()); + return start.ToLowerInvariant() + part[start.Length..]; + } + else return base.Formatted(part, index); + } + } + + private sealed class KebabCasing : CodeNameConvention + { + /// + public override string Name => "Kebab case"; + + /// + [Pure] + public override string ToString(IReadOnlyCollection parts) => string.Join('-', Trim(parts).Select(p => p.ToLowerInvariant())); + + /// + [Pure] + public override IReadOnlyCollection Split(IEnumerable parts) + => Trim(parts.SelectMany(p => (p ?? string.Empty).Split('-'))) + .Select(p => p.ToLowerInvariant()) + .ToArray(); + } + + [Inheritable] + private class SnakeCasing : CodeNameConvention + { + /// + public override string Name => "Snake case"; + + /// + [Pure] + public override string ToString(IReadOnlyCollection parts) => string.Join('_', Trim(parts).Select(p => p.ToLowerInvariant())); + + /// + [Pure] + public override IReadOnlyCollection Split(IEnumerable parts) + => Trim(parts.SelectMany(p => (p ?? string.Empty).Split('_'))) + .Select(p => p.ToLowerInvariant()) + .ToArray(); + } + + private sealed class ScreamingSnakeCasing : SnakeCasing + { + /// + public override string Name => "Screaming snake case"; + + /// + [Pure] + public override string ToString(IReadOnlyCollection parts) => base.ToString(parts).ToUpperInvariant(); + + /// + [Pure] + public override IReadOnlyCollection Split(IEnumerable parts) + => base.Split(parts) + .Select(p => p.ToUpperInvariant()) + .ToArray(); + } + + private sealed class SentenceCasing : CodeNameConvention + { + private readonly char[] Splitters = [' ', '_', '\t', '\r', '\n', (char)160]; + + /// + public override string Name => "Sentence case"; + + /// + [Pure] + public override IReadOnlyCollection Split(IEnumerable parts) + => Trim(parts.SelectMany(p => (p ?? string.Empty).Split(Splitters))) + .ToArray(); + + /// + [Pure] + public override string ToString(IReadOnlyCollection parts) => string.Join(' ', Trim(parts)); + } + + private sealed class NoCovention : CodeNameConvention + { + /// + public override string Name => "None"; + + /// + [Pure] + public override IReadOnlyCollection Split(IEnumerable parts) => Trim(parts).ToArray(); + + /// + [Pure] + public override string ToString(IReadOnlyCollection parts) => string.Concat(Trim(parts)); + } +} diff --git a/src/Qowaiv.CodeGeneration/NamingStrategy.cs b/src/Qowaiv.CodeGeneration/NamingStrategy.cs deleted file mode 100644 index 930fcb5..0000000 --- a/src/Qowaiv.CodeGeneration/NamingStrategy.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace Qowaiv.CodeGeneration; - -public static class NamingStrategy -{ - [Pure] - public static string None(string name) => name; - - [Pure] - public static string PascalCase(string name) - => (char.ToUpperInvariant(name[0]) + name[1..]); - - [Pure] - public static string PascalCase(string name, Type type) - => PascalCase(name).Enclosing(type); - - [Pure] - public static string CamelCase(string name, Type type) - => (char.ToLowerInvariant(name[0]) + name[1..]).Enclosing(type); - - [Pure] - public static string Enum(string name) - { - Guard.NotNullOrEmpty(name); - - if (name[0] >= '0' && name[0] <= '9') - { - name = '_' + name; - } - name = name - .Replace("+", "_pls") - .Replace(" ", "_") - .Replace("-", "_") - .Replace(";", "_") - .Replace("(", string.Empty) - .Replace(")", string.Empty); - - return EscapeKeywords(name); - } - - [Pure] - private static string Enclosing(this string name, Type type) - => type.Name == name - ? '_' + name - : name; - - [Pure] - public static string EscapeKeywords(this string name) - => keywords.Contains(name) - ? "@" + name - : name; - - private static readonly string[] keywords = ["default", "new", "class"]; -} diff --git a/src/Qowaiv.CodeGeneration/Qowaiv.CodeGeneration.csproj b/src/Qowaiv.CodeGeneration/Qowaiv.CodeGeneration.csproj index 7fafa2a..1f152a1 100644 --- a/src/Qowaiv.CodeGeneration/Qowaiv.CodeGeneration.csproj +++ b/src/Qowaiv.CodeGeneration/Qowaiv.CodeGeneration.csproj @@ -8,7 +8,7 @@ true - 0.0.1-alpha-013 + 0.0.1-alpha-014 Qowaiv.CodeGeneration ToBeReleased @@ -27,8 +27,4 @@ ToBeReleased - - - -