Skip to content

Commit

Permalink
Fix #830 Prevent duplicate C# names after normalizing EntitiyIds (#832)
Browse files Browse the repository at this point in the history
  • Loading branch information
FrankBakkerNl authored Feb 2, 2023
1 parent e30122f commit 94d6465
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand Down Expand Up @@ -46,15 +45,15 @@ private ServiceArguments(string domain, string serviceName, IReadOnlyCollection<

public IEnumerable<ServiceArgument> Arguments { get; }

public string TypeName => $"{_domain.ToNormalizedPascalCase()}{GetServiceMethodName(_serviceName)}Parameters";
public string TypeName => $"{_domain.ToValidCSharpPascalCase()}{GetServiceMethodName(_serviceName)}Parameters";

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

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

return $"{string.Join(", ", anonymousVariableStr)}";
return string.Join(", ", anonymousVariableStr);
}

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

return match ? "@" + name : name;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static IEnumerable<EntityAttributeMetaData> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,18 @@ IReadOnlyList<EntityAttributeMetaData> 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);
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using NetDaemon.Client.HomeAssistant.Model;
using System.Diagnostics;
using NetDaemon.Client.HomeAssistant.Model;

namespace NetDaemon.HassModel.CodeGenerator;

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

private static List<EntityMetaData> MapToEntityMetaData(IEnumerable<HassState> g) =>
g.Select(state => new EntityMetaData(state.EntityId, GetFriendlyName(state)))
.OrderBy(s=>s.id).ToList();
private static List<EntityMetaData> MapToEntityMetaData(IEnumerable<HassState> 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<EntityMetaData> DeDuplicateCSharpNames(IEnumerable<EntityMetaData> 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) }));
}

/// <summary>
/// We prefer the Property names for Entities to be the id in PascalCase
/// </summary>
private static string GetPreferredCSharpName(string id) => EntityIdHelper.GetEntity(id).ToValidCSharpPascalCase();

/// <summary>
/// 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 _
/// </summary>
private static string GetUniqueCSharpName(string id) => EntityIdHelper.GetEntity(id).ToValidCSharpIdentifier();

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HassServiceDomain>());
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<HassServiceDomain>());
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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
{
Expand All @@ -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 []
{
Expand Down

0 comments on commit 94d6465

Please sign in to comment.