From 5ecc71ceb767a6393f58f1ad21caa89cb13a1c2d Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Thu, 2 Feb 2023 15:55:38 +0100 Subject: [PATCH] Fix #830 Prevent duplicate C# names after normalizing EntitiyIds --- .../CodeGeneration/EntitiesGenerator.cs | 8 +-- .../CodeGeneration/ServiceArguments.cs | 10 ++-- .../Extensions/StringExtensions.cs | 18 ++++--- .../Helpers/NamingHelper.cs | 15 ++---- .../AttributeMetaDataGenerator.cs | 2 +- .../EntityMetaData/EntityDomainMetadata.cs | 8 +-- .../EntityMetaData/EntityMetaDataGenerator.cs | 40 ++++++++++++-- .../CodeGenerator/CodeGeneratorTest.cs | 54 +++++++++++++++++++ .../CodeGenerator/MetaDataMergerTest.cs | 14 ++--- 9 files changed, 122 insertions(+), 47 deletions(-) diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs index 39bc69ffd..e200dc1b6 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs @@ -70,12 +70,8 @@ private static TypeDeclarationSyntax GenerateEntiesForDomainClass(string classNa private static MemberDeclarationSyntax GenerateEntityProperty(EntityMetaData entity, string className) { - var entityName = EntityIdHelper.GetEntity(entity.id); - - var normalizedPascalCase = entityName.ToNormalizedPascalCase((string)"E_"); - - var name = entity.friendlyName; - return PropertyWithExpressionBodyNew(className, normalizedPascalCase, "_haContext", $"\"{entity.id}\"").WithSummaryComment(name); + return PropertyWithExpressionBodyNew(className, entity.cSharpName, "_haContext", $"\"{entity.id}\"") + .WithSummaryComment(entity.friendlyName); } /// diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs index 81fd9f048..3fbb1f656 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs @@ -14,11 +14,10 @@ internal record ServiceArgument public string ParameterTypeName => Required ? TypeName : $"{TypeName}?"; - public string PropertyName => HaName.ToNormalizedPascalCase(); + public string PropertyName => HaName.ToValidCSharpPascalCase(); - public string ParameterName => HaName.ToNormalizedCamelCase(); + public string ParameterName => HaName.ToValidCSharpCamelCase(); - public string ParameterDefault => Required ? "" : " = null"; } @@ -46,7 +45,7 @@ private ServiceArguments(string domain, string serviceName, IReadOnlyCollection< public IEnumerable Arguments { get; } - public string TypeName => $"{_domain.ToNormalizedPascalCase()}{GetServiceMethodName(_serviceName)}Parameters"; + public string TypeName => $"{_domain.ToValidCSharpPascalCase()}{GetServiceMethodName(_serviceName)}Parameters"; public string GetParametersList() { @@ -54,7 +53,7 @@ public string GetParametersList() var anonymousVariableStr = argumentList.Select(x => $"{x.ParameterTypeName} {EscapeIfRequired(x.ParameterName)}{x.ParameterDefault}"); - return $"{string.Join(", ", anonymousVariableStr)}"; + return string.Join(", ", anonymousVariableStr); } public string GetNewServiceArgumentsTypeExpression() @@ -71,5 +70,4 @@ private static string EscapeIfRequired(string name) return match ? "@" + name : name; } - } \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Extensions/StringExtensions.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Extensions/StringExtensions.cs index 62f3c1b5a..533dc3b54 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Extensions/StringExtensions.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Extensions/StringExtensions.cs @@ -6,24 +6,26 @@ namespace NetDaemon.HassModel.CodeGenerator.Extensions; internal static class StringExtensions { - public static string ToNormalizedPascalCase(this string name, string prefix = "HA_") + public static string ToValidCSharpPascalCase(this string name) { - return name.ToPascalCase().ToNormalized(prefix); + return name.ToPascalCase().ToValidCSharpIdentifier(); } - public static string ToNormalizedCamelCase(this string name, string prefix = "HA_") + public static string ToValidCSharpCamelCase(this string name) { - return name.ToCamelCase().ToNormalized(prefix); + return name.ToCamelCase().ToValidCSharpIdentifier(); } - private static string ToNormalized(this string name, string prefix = "HA_") + public static string ToValidCSharpIdentifier(this string name) { name = name.Replace(".", "_", StringComparison.InvariantCulture); - if (!char.IsLetter(name[0]) && name[0] != '_') - name = prefix + name; + name = Regex.Replace(name, "[^a-zA-Z0-9_]+", "", RegexOptions.Compiled); - return Regex.Replace(name, "[^a-zA-Z0-9]+", "", RegexOptions.Compiled); + if (char.IsAsciiDigit(name[0])) + name = "_" + name; + + return name; } public static string ToPascalCase(this string str) diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs index ed3769c4c..bf264a005 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs @@ -10,35 +10,28 @@ internal static class NamingHelper public static string GetEntitiesForDomainClassName(string prefix) { - var normalizedDomain = prefix.ToNormalizedPascalCase(); + var normalizedDomain = prefix.ToValidCSharpPascalCase(); return $"{normalizedDomain}Entities"; } - public static string GetDomainEntityTypeName(string prefix) - { - var normalizedDomain = prefix.ToNormalizedPascalCase(); - - return $"{normalizedDomain}Entity"; - } - public static string GetServicesTypeName(string prefix) { - var normalizedDomain = prefix.ToNormalizedPascalCase(); + var normalizedDomain = prefix.ToValidCSharpPascalCase(); return $"{normalizedDomain}Services"; } public static string GetEntityDomainExtensionMethodClassName(string prefix) { - var normalizedDomain = prefix.ToNormalizedPascalCase(); + var normalizedDomain = prefix.ToValidCSharpPascalCase(); return $"{normalizedDomain}EntityExtensionMethods"; } public static string GetServiceMethodName(string serviceName) { - serviceName = serviceName.ToNormalizedPascalCase(); + serviceName = serviceName.ToValidCSharpPascalCase(); return $"{serviceName}"; } diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/AttributeMetaDataGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/AttributeMetaDataGenerator.cs index fd0f33a92..a8272a224 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/AttributeMetaDataGenerator.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/AttributeMetaDataGenerator.cs @@ -16,7 +16,7 @@ public static IEnumerable GetMetaDataFromEntityStates(I var attributesByJsonName = jsonPropetiesByName .Select(group => new EntityAttributeMetaData( JsonName: group.Key, - CSharpName: group.Key.ToNormalizedPascalCase(), + CSharpName: group.Key.ToValidCSharpPascalCase(), ClrType: GetBestClrType(group.Select(g => g.Value)))); // We ignore possible duplicate CSharp names here, they will be handled later diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityDomainMetadata.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityDomainMetadata.cs index 81e0f4c9a..2decf71f9 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityDomainMetadata.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityDomainMetadata.cs @@ -21,18 +21,18 @@ IReadOnlyList Attributes private readonly string prefixedDomain = (IsNumeric && EntityIdHelper.MixedDomains.Contains(Domain) ? "numeric_" : "") + Domain; [JsonIgnore] - public string EntityClassName => GetDomainEntityTypeName(prefixedDomain); + public string EntityClassName => $"{prefixedDomain}Entity".ToValidCSharpPascalCase(); [JsonIgnore] - public string AttributesClassName => $"{prefixedDomain}Attributes".ToNormalizedPascalCase(); + public string AttributesClassName => $"{prefixedDomain}Attributes".ToValidCSharpPascalCase(); [JsonIgnore] - public string EntitiesForDomainClassName => $"{Domain}Entities".ToNormalizedPascalCase(); + public string EntitiesForDomainClassName => $"{Domain}Entities".ToValidCSharpPascalCase(); [JsonIgnore] public Type? AttributesBaseClass { get; set; } }; -record EntityMetaData(string id, string? friendlyName); +record EntityMetaData(string id, string? friendlyName, string cSharpName); record EntityAttributeMetaData(string JsonName, string CSharpName, Type ClrType); \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataGenerator.cs index ecfeeebab..664cffb11 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataGenerator.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataGenerator.cs @@ -1,4 +1,5 @@ -using NetDaemon.Client.HomeAssistant.Model; +using System.Diagnostics; +using NetDaemon.Client.HomeAssistant.Model; namespace NetDaemon.HassModel.CodeGenerator; @@ -25,9 +26,40 @@ private static EntityDomainMetadata mapEntityDomainMetadata(IGrouping<(string do Entities: MapToEntityMetaData(domainGroup), Attributes: AttributeMetaDataGenerator.GetMetaDataFromEntityStates(domainGroup).ToList()); - private static List MapToEntityMetaData(IEnumerable g) => - g.Select(state => new EntityMetaData(state.EntityId, GetFriendlyName(state))) - .OrderBy(s=>s.id).ToList(); + private static List MapToEntityMetaData(IEnumerable g) + { + var entityMetaDatas = g.Select(state => new EntityMetaData( + id: state.EntityId, + friendlyName: GetFriendlyName(state), + cSharpName: GetPreferredCSharpName(state.EntityId))); + + entityMetaDatas = DeDuplicateCSharpNames(entityMetaDatas); + + return entityMetaDatas.OrderBy(e => e.id).ToList(); + } + + private static IEnumerable DeDuplicateCSharpNames(IEnumerable entityMetaDatas) + { + // The PascalCased EntityId might not be unique because we removed all underscores + // If we have duplicates we will use the original ID instead and only make sure it is a Valid C# identifier + return entityMetaDatas + .ToLookup(e => e.cSharpName) + .SelectMany(e => e.Count() == 1 + ? e + : e.Select(i => i with { cSharpName = GetUniqueCSharpName(i.id) })); + } + + /// + /// We prefer the Property names for Entities to be the id in PascalCase + /// + private static string GetPreferredCSharpName(string id) => EntityIdHelper.GetEntity(id).ToValidCSharpPascalCase(); + + /// + /// HA entity ID's can only contain [a-z0-9_]. Which are all also valid in Csharp identifiers. + /// HA does allow the id to begin with a digit which is not valid for C#. In those cases it will be prefixed with + /// an _ + /// + private static string GetUniqueCSharpName(string id) => EntityIdHelper.GetEntity(id).ToValidCSharpIdentifier(); private static string? GetFriendlyName(HassState hassState) => hassState.AttributesAs()?.friendly_name; diff --git a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs index 6ce15e79f..7b8a4f27d 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs @@ -58,6 +58,60 @@ public void Run(IHaContext ha) CodeGenTestHelper.AssertCodeCompiles(generatedCode.ToString(), appCode); } + [Fact] + public void TestEntityDuplictateNormalizedName() + { + var entityStates = new HassState[] + { + new() { EntityId = "light.light_1_1" }, + new() { EntityId = "light.light_11" }, + }; + + var generatedCode = CodeGenTestHelper.GenerateCompilationUnit(_settings, entityStates, Array.Empty()); + var appCode = """ + using NetDaemon.HassModel.Entities; + using NetDaemon.HassModel; + using RootNameSpace; + + public class Root + { + public void Run(Entities entities) + { + LightEntity l1_1 = entities.Light.light_1_1; + LightEntity l11 = entities.Light.light_11; + } + } + """; + CodeGenTestHelper.AssertCodeCompiles(generatedCode.ToString(), appCode); + } + + [Fact] + public void TestEntityInvalidCSharpName() + { + var entityStates = new HassState[] + { + new() { EntityId = "light.1light" }, + new() { EntityId = "light.li@#ght" }, + }; + + var generatedCode = CodeGenTestHelper.GenerateCompilationUnit(_settings, entityStates, Array.Empty()); + var appCode = """ + using NetDaemon.HassModel.Entities; + using NetDaemon.HassModel; + using RootNameSpace; + + public class Root + { + public void Run(Entities entities) + { + LightEntity l1 = entities.Light._1light; + LightEntity l2 = entities.Light.Light; + } + } + """; + CodeGenTestHelper.AssertCodeCompiles(generatedCode.ToString(), appCode); + } + [Fact] public void TestNumericSensorEntityGeneration() { diff --git a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/MetaDataMergerTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/MetaDataMergerTest.cs index 167a9f6a2..b60c31b09 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/MetaDataMergerTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/MetaDataMergerTest.cs @@ -10,8 +10,8 @@ public void MergeSimple() { var previous = new []{new EntityDomainMetadata("light", false, new [] { - new EntityMetaData("light.living", "Livingroom spots"), - new EntityMetaData("light.kitchen", "Kitchen light") + new EntityMetaData("light.living", "Livingroom spots", "Living"), + new EntityMetaData("light.kitchen", "Kitchen light", "Kitchen") }, new [] { @@ -20,20 +20,20 @@ public void MergeSimple() var current = new []{new EntityDomainMetadata("light", false, new [] { - new EntityMetaData("light.bedroom", "nightlight"), - new EntityMetaData("light.kitchen", "Kitchen light new name") + new EntityMetaData("light.bedroom", "nightlight", "Bedroom"), + new EntityMetaData("light.kitchen", "Kitchen light new name", "Kitchen") }, new [] { new EntityAttributeMetaData("off_brightness", "OffBrightness", typeof(double)) })}; - var result= EntityMetaDataMerger.Merge(new(), new EntitiesMetaData(){Domains = previous}, new EntitiesMetaData{Domains = current}).Domains; + var result = EntityMetaDataMerger.Merge(new(), new EntitiesMetaData { Domains = previous }, new EntitiesMetaData { Domains = current }).Domains; var expected = new []{new EntityDomainMetadata("light", false, new [] { - new EntityMetaData("light.bedroom", "nightlight"), - new EntityMetaData("light.kitchen", "Kitchen light new name") + new EntityMetaData("light.bedroom", "nightlight", "Bedroom"), + new EntityMetaData("light.kitchen", "Kitchen light new name", "Kitchen") }, new [] {