Skip to content

Commit 94d6465

Browse files
Fix #830 Prevent duplicate C# names after normalizing EntitiyIds (#832)
1 parent e30122f commit 94d6465

File tree

9 files changed

+122
-47
lines changed

9 files changed

+122
-47
lines changed

src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,8 @@ private static TypeDeclarationSyntax GenerateEntiesForDomainClass(string classNa
7070

7171
private static MemberDeclarationSyntax GenerateEntityProperty(EntityMetaData entity, string className)
7272
{
73-
var entityName = EntityIdHelper.GetEntity(entity.id);
74-
75-
var normalizedPascalCase = entityName.ToNormalizedPascalCase((string)"E_");
76-
77-
var name = entity.friendlyName;
78-
return PropertyWithExpressionBodyNew(className, normalizedPascalCase, "_haContext", $"\"{entity.id}\"").WithSummaryComment(name);
73+
return PropertyWithExpressionBodyNew(className, entity.cSharpName, "_haContext", $"\"{entity.id}\"")
74+
.WithSummaryComment(entity.friendlyName);
7975
}
8076

8177
/// <summary>

src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@ internal record ServiceArgument
1414

1515
public string ParameterTypeName => Required ? TypeName : $"{TypeName}?";
1616

17-
public string PropertyName => HaName.ToNormalizedPascalCase();
17+
public string PropertyName => HaName.ToValidCSharpPascalCase();
1818

19-
public string ParameterName => HaName.ToNormalizedCamelCase();
19+
public string ParameterName => HaName.ToValidCSharpCamelCase();
2020

21-
2221
public string ParameterDefault => Required ? "" : " = null";
2322
}
2423

@@ -46,15 +45,15 @@ private ServiceArguments(string domain, string serviceName, IReadOnlyCollection<
4645

4746
public IEnumerable<ServiceArgument> Arguments { get; }
4847

49-
public string TypeName => $"{_domain.ToNormalizedPascalCase()}{GetServiceMethodName(_serviceName)}Parameters";
48+
public string TypeName => $"{_domain.ToValidCSharpPascalCase()}{GetServiceMethodName(_serviceName)}Parameters";
5049

5150
public string GetParametersList()
5251
{
5352
var argumentList = Arguments.OrderByDescending(arg => arg.Required);
5453

5554
var anonymousVariableStr = argumentList.Select(x => $"{x.ParameterTypeName} {EscapeIfRequired(x.ParameterName)}{x.ParameterDefault}");
5655

57-
return $"{string.Join(", ", anonymousVariableStr)}";
56+
return string.Join(", ", anonymousVariableStr);
5857
}
5958

6059
public string GetNewServiceArgumentsTypeExpression()
@@ -71,5 +70,4 @@ private static string EscapeIfRequired(string name)
7170

7271
return match ? "@" + name : name;
7372
}
74-
7573
}

src/HassModel/NetDaemon.HassModel.CodeGenerator/Extensions/StringExtensions.cs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,26 @@ namespace NetDaemon.HassModel.CodeGenerator.Extensions;
66

77
internal static class StringExtensions
88
{
9-
public static string ToNormalizedPascalCase(this string name, string prefix = "HA_")
9+
public static string ToValidCSharpPascalCase(this string name)
1010
{
11-
return name.ToPascalCase().ToNormalized(prefix);
11+
return name.ToPascalCase().ToValidCSharpIdentifier();
1212
}
1313

14-
public static string ToNormalizedCamelCase(this string name, string prefix = "HA_")
14+
public static string ToValidCSharpCamelCase(this string name)
1515
{
16-
return name.ToCamelCase().ToNormalized(prefix);
16+
return name.ToCamelCase().ToValidCSharpIdentifier();
1717
}
1818

19-
private static string ToNormalized(this string name, string prefix = "HA_")
19+
public static string ToValidCSharpIdentifier(this string name)
2020
{
2121
name = name.Replace(".", "_", StringComparison.InvariantCulture);
2222

23-
if (!char.IsLetter(name[0]) && name[0] != '_')
24-
name = prefix + name;
23+
name = Regex.Replace(name, "[^a-zA-Z0-9_]+", "", RegexOptions.Compiled);
2524

26-
return Regex.Replace(name, "[^a-zA-Z0-9]+", "", RegexOptions.Compiled);
25+
if (char.IsAsciiDigit(name[0]))
26+
name = "_" + name;
27+
28+
return name;
2729
}
2830

2931
public static string ToPascalCase(this string str)

src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,28 @@ internal static class NamingHelper
1010

1111
public static string GetEntitiesForDomainClassName(string prefix)
1212
{
13-
var normalizedDomain = prefix.ToNormalizedPascalCase();
13+
var normalizedDomain = prefix.ToValidCSharpPascalCase();
1414

1515
return $"{normalizedDomain}Entities";
1616
}
1717

18-
public static string GetDomainEntityTypeName(string prefix)
19-
{
20-
var normalizedDomain = prefix.ToNormalizedPascalCase();
21-
22-
return $"{normalizedDomain}Entity";
23-
}
24-
2518
public static string GetServicesTypeName(string prefix)
2619
{
27-
var normalizedDomain = prefix.ToNormalizedPascalCase();
20+
var normalizedDomain = prefix.ToValidCSharpPascalCase();
2821

2922
return $"{normalizedDomain}Services";
3023
}
3124

3225
public static string GetEntityDomainExtensionMethodClassName(string prefix)
3326
{
34-
var normalizedDomain = prefix.ToNormalizedPascalCase();
27+
var normalizedDomain = prefix.ToValidCSharpPascalCase();
3528

3629
return $"{normalizedDomain}EntityExtensionMethods";
3730
}
3831

3932
public static string GetServiceMethodName(string serviceName)
4033
{
41-
serviceName = serviceName.ToNormalizedPascalCase();
34+
serviceName = serviceName.ToValidCSharpPascalCase();
4235

4336
return $"{serviceName}";
4437
}

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/AttributeMetaDataGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public static IEnumerable<EntityAttributeMetaData> GetMetaDataFromEntityStates(I
1616
var attributesByJsonName = jsonPropetiesByName
1717
.Select(group => new EntityAttributeMetaData(
1818
JsonName: group.Key,
19-
CSharpName: group.Key.ToNormalizedPascalCase(),
19+
CSharpName: group.Key.ToValidCSharpPascalCase(),
2020
ClrType: GetBestClrType(group.Select(g => g.Value))));
2121

2222
// We ignore possible duplicate CSharp names here, they will be handled later

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityDomainMetadata.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,18 @@ IReadOnlyList<EntityAttributeMetaData> Attributes
2121
private readonly string prefixedDomain = (IsNumeric && EntityIdHelper.MixedDomains.Contains(Domain) ? "numeric_" : "") + Domain;
2222

2323
[JsonIgnore]
24-
public string EntityClassName => GetDomainEntityTypeName(prefixedDomain);
24+
public string EntityClassName => $"{prefixedDomain}Entity".ToValidCSharpPascalCase();
2525

2626
[JsonIgnore]
27-
public string AttributesClassName => $"{prefixedDomain}Attributes".ToNormalizedPascalCase();
27+
public string AttributesClassName => $"{prefixedDomain}Attributes".ToValidCSharpPascalCase();
2828

2929
[JsonIgnore]
30-
public string EntitiesForDomainClassName => $"{Domain}Entities".ToNormalizedPascalCase();
30+
public string EntitiesForDomainClassName => $"{Domain}Entities".ToValidCSharpPascalCase();
3131

3232
[JsonIgnore]
3333
public Type? AttributesBaseClass { get; set; }
3434
};
3535

36-
record EntityMetaData(string id, string? friendlyName);
36+
record EntityMetaData(string id, string? friendlyName, string cSharpName);
3737

3838
record EntityAttributeMetaData(string JsonName, string CSharpName, Type ClrType);

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataGenerator.cs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using NetDaemon.Client.HomeAssistant.Model;
1+
using System.Diagnostics;
2+
using NetDaemon.Client.HomeAssistant.Model;
23

34
namespace NetDaemon.HassModel.CodeGenerator;
45

@@ -25,9 +26,40 @@ private static EntityDomainMetadata mapEntityDomainMetadata(IGrouping<(string do
2526
Entities: MapToEntityMetaData(domainGroup),
2627
Attributes: AttributeMetaDataGenerator.GetMetaDataFromEntityStates(domainGroup).ToList());
2728

28-
private static List<EntityMetaData> MapToEntityMetaData(IEnumerable<HassState> g) =>
29-
g.Select(state => new EntityMetaData(state.EntityId, GetFriendlyName(state)))
30-
.OrderBy(s=>s.id).ToList();
29+
private static List<EntityMetaData> MapToEntityMetaData(IEnumerable<HassState> g)
30+
{
31+
var entityMetaDatas = g.Select(state => new EntityMetaData(
32+
id: state.EntityId,
33+
friendlyName: GetFriendlyName(state),
34+
cSharpName: GetPreferredCSharpName(state.EntityId)));
35+
36+
entityMetaDatas = DeDuplicateCSharpNames(entityMetaDatas);
37+
38+
return entityMetaDatas.OrderBy(e => e.id).ToList();
39+
}
40+
41+
private static IEnumerable<EntityMetaData> DeDuplicateCSharpNames(IEnumerable<EntityMetaData> entityMetaDatas)
42+
{
43+
// The PascalCased EntityId might not be unique because we removed all underscores
44+
// If we have duplicates we will use the original ID instead and only make sure it is a Valid C# identifier
45+
return entityMetaDatas
46+
.ToLookup(e => e.cSharpName)
47+
.SelectMany(e => e.Count() == 1
48+
? e
49+
: e.Select(i => i with { cSharpName = GetUniqueCSharpName(i.id) }));
50+
}
51+
52+
/// <summary>
53+
/// We prefer the Property names for Entities to be the id in PascalCase
54+
/// </summary>
55+
private static string GetPreferredCSharpName(string id) => EntityIdHelper.GetEntity(id).ToValidCSharpPascalCase();
56+
57+
/// <summary>
58+
/// HA entity ID's can only contain [a-z0-9_]. Which are all also valid in Csharp identifiers.
59+
/// HA does allow the id to begin with a digit which is not valid for C#. In those cases it will be prefixed with
60+
/// an _
61+
/// </summary>
62+
private static string GetUniqueCSharpName(string id) => EntityIdHelper.GetEntity(id).ToValidCSharpIdentifier();
3163

3264
private static string? GetFriendlyName(HassState hassState) => hassState.AttributesAs<Attributes>()?.friendly_name;
3365

src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,60 @@ public void Run(IHaContext ha)
5858
CodeGenTestHelper.AssertCodeCompiles(generatedCode.ToString(), appCode);
5959
}
6060

61+
[Fact]
62+
public void TestEntityDuplictateNormalizedName()
63+
{
64+
var entityStates = new HassState[]
65+
{
66+
new() { EntityId = "light.light_1_1" },
67+
new() { EntityId = "light.light_11" },
68+
};
69+
70+
var generatedCode = CodeGenTestHelper.GenerateCompilationUnit(_settings, entityStates, Array.Empty<HassServiceDomain>());
71+
var appCode = """
72+
using NetDaemon.HassModel.Entities;
73+
using NetDaemon.HassModel;
74+
using RootNameSpace;
75+
76+
public class Root
77+
{
78+
public void Run(Entities entities)
79+
{
80+
LightEntity l1_1 = entities.Light.light_1_1;
81+
LightEntity l11 = entities.Light.light_11;
82+
}
83+
}
84+
""";
85+
CodeGenTestHelper.AssertCodeCompiles(generatedCode.ToString(), appCode);
86+
}
87+
88+
[Fact]
89+
public void TestEntityInvalidCSharpName()
90+
{
91+
var entityStates = new HassState[]
92+
{
93+
new() { EntityId = "light.1light" },
94+
new() { EntityId = "light.li@#ght" },
95+
};
96+
97+
var generatedCode = CodeGenTestHelper.GenerateCompilationUnit(_settings, entityStates, Array.Empty<HassServiceDomain>());
98+
var appCode = """
99+
using NetDaemon.HassModel.Entities;
100+
using NetDaemon.HassModel;
101+
using RootNameSpace;
102+
103+
public class Root
104+
{
105+
public void Run(Entities entities)
106+
{
107+
LightEntity l1 = entities.Light._1light;
108+
LightEntity l2 = entities.Light.Light;
109+
}
110+
}
111+
""";
112+
CodeGenTestHelper.AssertCodeCompiles(generatedCode.ToString(), appCode);
113+
}
114+
61115
[Fact]
62116
public void TestNumericSensorEntityGeneration()
63117
{

src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/MetaDataMergerTest.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ public void MergeSimple()
1010
{
1111
var previous = new []{new EntityDomainMetadata("light", false, new []
1212
{
13-
new EntityMetaData("light.living", "Livingroom spots"),
14-
new EntityMetaData("light.kitchen", "Kitchen light")
13+
new EntityMetaData("light.living", "Livingroom spots", "Living"),
14+
new EntityMetaData("light.kitchen", "Kitchen light", "Kitchen")
1515
},
1616
new []
1717
{
@@ -20,20 +20,20 @@ public void MergeSimple()
2020

2121
var current = new []{new EntityDomainMetadata("light", false, new []
2222
{
23-
new EntityMetaData("light.bedroom", "nightlight"),
24-
new EntityMetaData("light.kitchen", "Kitchen light new name")
23+
new EntityMetaData("light.bedroom", "nightlight", "Bedroom"),
24+
new EntityMetaData("light.kitchen", "Kitchen light new name", "Kitchen")
2525
},
2626
new []
2727
{
2828
new EntityAttributeMetaData("off_brightness", "OffBrightness", typeof(double))
2929
})};
3030

31-
var result= EntityMetaDataMerger.Merge(new(), new EntitiesMetaData(){Domains = previous}, new EntitiesMetaData{Domains = current}).Domains;
31+
var result = EntityMetaDataMerger.Merge(new(), new EntitiesMetaData { Domains = previous }, new EntitiesMetaData { Domains = current }).Domains;
3232

3333
var expected = new []{new EntityDomainMetadata("light", false, new []
3434
{
35-
new EntityMetaData("light.bedroom", "nightlight"),
36-
new EntityMetaData("light.kitchen", "Kitchen light new name")
35+
new EntityMetaData("light.bedroom", "nightlight", "Bedroom"),
36+
new EntityMetaData("light.kitchen", "Kitchen light new name", "Kitchen")
3737
},
3838
new []
3939
{

0 commit comments

Comments
 (0)