diff --git a/src/Excelsior.Tests/CamelCaseTests.cs b/src/Excelsior.Tests/CamelCaseTests.cs new file mode 100644 index 00000000..a4c9e7ee --- /dev/null +++ b/src/Excelsior.Tests/CamelCaseTests.cs @@ -0,0 +1,16 @@ +[TestFixture] +public class CamelCaseTests +{ + [TestCase("", "")] + [TestCase("A", "A")] + [TestCase("Name", "Name")] + [TestCase("FirstName", "First Name")] + [TestCase("NoAttributes", "No Attributes")] + [TestCase("ID", "ID")] + [TestCase("OrderID", "Order ID")] + [TestCase("HTTPStatus", "HTTP Status")] + [TestCase("IOError", "IO Error")] + [TestCase("XMLParser", "XML Parser")] + public void Split(string input, string expected) => + Assert.That(CamelCase.Split(input), Is.EqualTo(expected)); +} diff --git a/src/Excelsior/CamelCase.cs b/src/Excelsior/CamelCase.cs index a7988788..b97c6769 100644 --- a/src/Excelsior/CamelCase.cs +++ b/src/Excelsior/CamelCase.cs @@ -5,7 +5,7 @@ public static string Split(string text) var spaceCount = 0; for (var i = 1; i < text.Length; i++) { - if (char.IsUpper(text[i])) + if (IsWordStart(text, i)) { spaceCount++; } @@ -17,18 +17,38 @@ public static string Split(string text) } var result = new char[text.Length + spaceCount]; - var pos = 0; - result[pos++] = text[0]; + var position = 0; + result[position++] = text[0]; for (var i = 1; i < text.Length; i++) { - if (char.IsUpper(text[i])) + if (IsWordStart(text, i)) { - result[pos++] = ' '; + result[position++] = ' '; } - result[pos++] = text[i]; + result[position++] = text[i]; } return new(result); } + + // An uppercase letter starts a new word when it follows a lowercase letter ("OrderId" -> + // "Order Id") or ends a run of uppercase letters that begins the next word ("HTTPStatus" -> + // "HTTP Status"). Consecutive uppercase letters of an acronym stay together, so "OrderID" + // stays "Order ID" and "ID" stays "ID" rather than being split into "Order I D" / "I D". + static bool IsWordStart(string text, int index) + { + if (!char.IsUpper(text[index])) + { + return false; + } + + if (char.IsLower(text[index - 1])) + { + return true; + } + + return index + 1 < text.Length && + char.IsLower(text[index + 1]); + } } diff --git a/src/Excelsior/EnumExtensions.cs b/src/Excelsior/EnumExtensions.cs index d8db9975..1b23f247 100644 --- a/src/Excelsior/EnumExtensions.cs +++ b/src/Excelsior/EnumExtensions.cs @@ -1,38 +1,52 @@ public static class EnumExtensions { - static ConcurrentDictionary cache = new(); + static readonly ConcurrentDictionary boxedCache = new(); + // Boxed entry point — for callers that already hold an Enum reference (the global + // Func render default and the boxed dispatcher). The boxing is the caller's. public static string Humanize(this Enum value) => - cache.GetOrAdd( - value, - static value => + boxedCache.GetOrAdd(value, static boxed => Compute(boxed.GetType(), boxed.ToString())); + + // Non-boxing entry point for the per-cell hot path (EnumRender). The cache is keyed by + // the value type, and Enum.GetName avoids the boxing that value.ToString() would incur. + internal static string Humanize(TEnum value) + where TEnum : struct, Enum => + TypedCache.Get(value); + + static class TypedCache + where TEnum : struct, Enum + { + static readonly ConcurrentDictionary cache = new(); + + public static string Get(TEnum value) => + cache.GetOrAdd(value, static keyed => Compute(typeof(TEnum), Enum.GetName(keyed) ?? keyed.ToString())); + } + + static string Compute(Type type, string name) + { + var field = type.GetField(name); + if (field is null) { - var type = value.GetType(); - var memberInfo = type.GetField(value.ToString()); + return name; + } - if (memberInfo is null) + // DisplayAttribute Description takes priority over Name. + var displayAttribute = field.GetCustomAttribute(); + if (displayAttribute is not null) + { + if (displayAttribute.Description is not null) { - return value.ToString(); + return displayAttribute.Description; } - // Check for DisplayAttribute - Description takes priority over Name - var displayAttribute = memberInfo.GetCustomAttribute(); - if (displayAttribute is not null) + if (displayAttribute.Name is not null) { - if (displayAttribute.Description is not null) - { - return displayAttribute.Description; - } - - if (displayAttribute.Name is not null) - { - return displayAttribute.Name; - } + return displayAttribute.Name; } + } - // Humanize the enum name - return HumanizeName(value.ToString()); - }); + return HumanizeName(name); + } static string HumanizeName(string name) { diff --git a/src/Excelsior/EnumRender.cs b/src/Excelsior/EnumRender.cs index 637c3761..f5516114 100644 --- a/src/Excelsior/EnumRender.cs +++ b/src/Excelsior/EnumRender.cs @@ -39,7 +39,7 @@ internal static string Render(TEnum value) return c(value); } - return value.Humanize(); + return EnumExtensions.Humanize(value); } } diff --git a/src/Excelsior/Properties.cs b/src/Excelsior/Properties.cs index 7046ac29..01f592bf 100644 --- a/src/Excelsior/Properties.cs +++ b/src/Excelsior/Properties.cs @@ -1,7 +1,12 @@ static class Properties { - static Properties() => - Items = GetPropertiesRecursive(typeof(T), [], false).ToList(); + // Built lazily rather than in a static constructor: a validation or expression-compile failure + // then surfaces as the original exception on first access, instead of a TypeInitializationException + // that buries the real message and is cached on the type for the process lifetime. + static readonly Lazy>> items = + new(() => GetPropertiesRecursive(typeof(T), [], false).ToList()); + + public static IReadOnlyList> Items => items.Value; static IEnumerable> GetPropertiesRecursive(Type type, List<(MemberInfo member, ParameterInfo? parameter)> path, bool useHierachyForName) { @@ -110,6 +115,4 @@ static Expression BuildAccessExpression(IEnumerable path) return current; } - - public static IReadOnlyList> Items { get; } } diff --git a/src/Excelsior/SheetContext.cs b/src/Excelsior/SheetContext.cs index 5b6eb258..c44f8624 100644 --- a/src/Excelsior/SheetContext.cs +++ b/src/Excelsior/SheetContext.cs @@ -3,6 +3,7 @@ namespace Excelsior; public class SheetContext { Dictionary rows = []; + Dictionary<(int Row, int Column), Cell> cells = []; internal WorksheetPart WorksheetPart { get; } internal Worksheet Worksheet => WorksheetPart.Worksheet!; @@ -17,6 +18,14 @@ internal SheetContext(WorksheetPart worksheetPart) internal Cell GetCell(int rowIndex, int columnIndex) { + // Return the existing cell for a coordinate rather than appending a second one: two cells + // sharing a CellReference is a malformed worksheet that Excel rejects. The renderer writes + // each coordinate once today, so this is a guard against an accidental repeat call. + if (cells.TryGetValue((rowIndex, columnIndex), out var existing)) + { + return existing; + } + if (!rows.TryGetValue(rowIndex, out var row)) { row = new() @@ -37,6 +46,7 @@ internal Cell GetCell(int rowIndex, int columnIndex) CellReference = cellRef }; row.Append(cell); + cells[(rowIndex, columnIndex)] = cell; return cell; }