Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Excelsior.Tests/CamelCaseTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
32 changes: 26 additions & 6 deletions src/Excelsior/CamelCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
Expand All @@ -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]);
}
}
60 changes: 37 additions & 23 deletions src/Excelsior/EnumExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,38 +1,52 @@
public static class EnumExtensions
{
static ConcurrentDictionary<Enum, string> cache = new();
static readonly ConcurrentDictionary<Enum, string> boxedCache = new();

// Boxed entry point — for callers that already hold an Enum reference (the global
// Func<Enum,string> 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<TEnum>). The cache is keyed by
// the value type, and Enum.GetName<TEnum> avoids the boxing that value.ToString() would incur.
internal static string Humanize<TEnum>(TEnum value)
where TEnum : struct, Enum =>
TypedCache<TEnum>.Get(value);

static class TypedCache<TEnum>
where TEnum : struct, Enum
{
static readonly ConcurrentDictionary<TEnum, string> 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<DisplayAttribute>();
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<DisplayAttribute>();
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)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Excelsior/EnumRender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ internal static string Render(TEnum value)
return c(value);
}

return value.Humanize();
return EnumExtensions.Humanize(value);
}
}

Expand Down
11 changes: 7 additions & 4 deletions src/Excelsior/Properties.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
static class Properties<T>
{
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<IReadOnlyList<Property<T>>> items =
new(() => GetPropertiesRecursive(typeof(T), [], false).ToList());

public static IReadOnlyList<Property<T>> Items => items.Value;

static IEnumerable<Property<T>> GetPropertiesRecursive(Type type, List<(MemberInfo member, ParameterInfo? parameter)> path, bool useHierachyForName)
{
Expand Down Expand Up @@ -110,6 +115,4 @@ static Expression BuildAccessExpression(IEnumerable<MemberInfo> path)

return current;
}

public static IReadOnlyList<Property<T>> Items { get; }
}
10 changes: 10 additions & 0 deletions src/Excelsior/SheetContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace Excelsior;
public class SheetContext
{
Dictionary<int, Row> rows = [];
Dictionary<(int Row, int Column), Cell> cells = [];

internal WorksheetPart WorksheetPart { get; }
internal Worksheet Worksheet => WorksheetPart.Worksheet!;
Expand All @@ -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()
Expand All @@ -37,6 +46,7 @@ internal Cell GetCell(int rowIndex, int columnIndex)
CellReference = cellRef
};
row.Append(cell);
cells[(rowIndex, columnIndex)] = cell;
return cell;
}

Expand Down
Loading