.NET Workflows - Code Generation for Declarative Workflow (#655)

* Notes

* Readme typo

* Update readme

* Checkpoint

* Namespace fix

* Fix ID and namespace

* Checkpoint

* Verified

* Comments

* Isolate "Kit"

* Address note: static

* Checkpoint

* Checkpoint "Executor<>"

* Prefix and internal executors

* Test passing

* Cleanup

* Rename "session" concept

* Revert workflow debug

* Fix template base / pragma

* Tune system scope

* Update dotnet/src/Microsoft.Agents.Workflows.Declarative/CodeGen/ResetVariableTemplate.tt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix empty template

* Add validation for codegen ut

* Fix test

* Codegen baselines

* Constant

* Prep

* Mark TODO

* Fix

* Namespace

* One more

* Update baselines

* Checkpoint

* Checkpoint

* Checkpoint

* fme

* Checkpoint

* Another step

* Fixed up

* Roslyn

* Fix

* More cleaning

* Async

* Fix

* Enum checkpoint

* Refine enum

* Checkpoint

* Sync templates

* Checkpoint

* Streamline

* Pre-merge analyzer updates

* Foreach

* Placeholders

* Checkpoint

* Clean-up

* Sample path resolution

* Checkpoint

* Checkpoint - Workflow Code Building

* Validation

* Test cleanup

* Update test basline

* Update test baseline

* Fix DefaultTemplate usage

* Validation checkpoint

* Fix break/continue edges

* Verify generated code builds

* Fix merge

* Fix build validation

* Update template handling of literal string values.

* Test for metadata case

* Update baselines

* Fix merge

* Checkpoint

* Checkpoint: Conditions

* Invoke Agent Checkpoint

* Namespace

* Address code-analysis issues

* Cross platform test support

* Invoke agent checkpoint

* Clean sample

* Checkpoint: Agent Invoke Input Messages

* Checkpoint - Passing

* Checkpoint

* Regenerate all template + port conversation fix

* Checkpoint: Tests good

* Fix test for unbuntu

* Fix build command

* Checkpoint - E2E

* Test fix

* Update integration tests

* Fix merge

* Update

* Checkpoint !!!

* Baby steps

* Checkpoint

* Checkpoint E2E !!!

* So close...

* Integrate test validation

* Fix merge

* Rebase tests

* Namespace

* Namespace

* Test cleanup

* Sample comment cleanup

* Checkpoint: List conversion

* Include these

* CheckPoint: ParseValue

* Namespace

* Fix sampel

* More namspace

* Comments

* Test updates

* Test fix

* Better build

* Shared code

* Sort solution

* Fix build

* Prune solution

* One more

* Conversion matrix

* Final table conversion

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Chris
2025-09-30 21:56:14 +00:00
committed by GitHub
co-authored by Copilot
parent 40f5b6d8fe
commit 77404d165c
181 changed files with 56682 additions and 230 deletions
@@ -3,7 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
@@ -1,14 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Dynamic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class DataValueExtensions
{
public static DataValue ToDataValue(this object? value) =>
value switch
{
null => DataValue.Blank(),
UnassignedValue => DataValue.Blank(),
FormulaValue formulaValue => formulaValue.ToDataValue(),
DataValue dataValue => dataValue,
bool booleanValue => BooleanDataValue.Create(booleanValue),
int decimalValue => NumberDataValue.Create(decimalValue),
long decimalValue => NumberDataValue.Create(decimalValue),
float decimalValue => FloatDataValue.Create(decimalValue),
decimal decimalValue => NumberDataValue.Create(decimalValue),
double numberValue => FloatDataValue.Create(numberValue),
string stringValue => StringDataValue.Create(stringValue),
DateTime dateonlyValue when dateonlyValue.TimeOfDay == TimeSpan.Zero => DateDataValue.Create(dateonlyValue),
DateTime datetimeValue => DateTimeDataValue.Create(datetimeValue),
TimeSpan timeValue => TimeDataValue.Create(timeValue),
object when value is IDictionary dictionaryValue => dictionaryValue.ToRecordValue(),
object when value is IEnumerable tableValue => tableValue.ToTableValue(),
_ => throw new DeclarativeModelException($"Unsupported variable type: {value.GetType().Name}"),
};
public static FormulaValue ToFormula(this DataValue? value) =>
value switch
{
@@ -65,12 +94,37 @@ internal static class DataValueExtensions
DateTimeDataValue dateTimeValue => dateTimeValue.Value.DateTime,
DateDataValue dateValue => dateValue.Value,
TimeDataValue timeValue => timeValue.Value,
TableDataValue tableValue => tableValue.Values.Select(value => value.ToDictionary()).ToArray(),
RecordDataValue recordValue => recordValue.ToDictionary(),
TableDataValue tableValue => tableValue.ToObject(),
RecordDataValue recordValue => recordValue.ToObject(),
OptionDataValue optionValue => optionValue.Value.Value,
_ => throw new DeclarativeModelException($"Unsupported {nameof(DataValue)} type: {value.GetType().Name}"),
};
public static Type ToClrType(this DataType type) =>
type switch
{
BooleanDataType => typeof(bool),
NumberDataType => typeof(decimal),
FloatDataType => typeof(double),
StringDataType => typeof(string),
DateTimeDataType => typeof(DateTime),
DateDataType => typeof(DateTime),
TimeDataType => typeof(TimeSpan),
TableDataType tableType => VariableType.ListType,
RecordDataType recordValue => VariableType.RecordType,
_ => throw new DeclarativeModelException($"Unsupported {nameof(DataValue)} type: {type.GetType().Name}"),
};
public static IList<TElement>? AsList<TElement>(this DataValue? value)
{
if (value is null || value is BlankDataValue)
{
return null;
}
return value.ToObject().AsList<TElement>();
}
public static FormulaValue NewBlank(this DataType? type) => FormulaValue.NewBlank(type?.ToFormulaType() ?? FormulaType.Blank);
public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) =>
@@ -88,6 +142,53 @@ internal static class DataValueExtensions
return recordType;
}
public static RecordDataValue ToRecordValue(this IDictionary value)
{
return DataValue.RecordFromFields(GetFields());
IEnumerable<KeyValuePair<string, DataValue>> GetFields()
{
yield return new KeyValuePair<string, DataValue>(TypeSchema.Discriminator, nameof(ExpandoObject).ToDataValue());
foreach (string key in value.Keys)
{
yield return new KeyValuePair<string, DataValue>(key, value[key].ToDataValue());
}
}
}
public static TableDataValue ToTableValue(this IEnumerable values)
{
IEnumerator enumerator = values.GetEnumerator();
if (!enumerator.MoveNext())
{
return DataValue.EmptyTable;
}
if (enumerator.Current is IDictionary)
{
DataValue.TableFromRecords(GetFields().ToImmutableArray());
}
return DataValue.TableFromValues(GetValues().ToImmutableArray());
IEnumerable<RecordDataValue> GetFields()
{
foreach (IDictionary value in values)
{
yield return value.ToRecordValue();
}
}
IEnumerable<DataValue> GetValues()
{
foreach (object value in values)
{
yield return value.ToDataValue();
}
}
}
private static RecordType ParseRecordType(this RecordDataValue record)
{
RecordType recordType = RecordType.Empty();
@@ -98,9 +199,60 @@ internal static class DataValueExtensions
return recordType;
}
private static object ToObject(this TableDataValue table)
{
DataValue? firstElement = table.Values.FirstOrDefault();
if (firstElement is null)
{
return Array.Empty<object>();
}
if (firstElement is RecordDataValue record)
{
if (record.Properties.Count == 1 && record.Properties.TryGetValue("Value", out DataValue? singleColumn))
{
record = singleColumn as RecordDataValue ?? record;
}
if (record.Properties.TryGetValue(TypeSchema.Discriminator, out DataValue? value) && value is StringDataValue typeValue)
{
if (string.Equals(nameof(ChatMessage), typeValue.Value, StringComparison.Ordinal))
{
return table.ToChatMessages().ToArray();
}
if (string.Equals(nameof(ExpandoObject), typeValue.Value, StringComparison.Ordinal))
{
return table.Values.Select(dataValue => dataValue.ToDictionary()).ToArray();
}
}
}
return table.Values.Select(value => value.ToObject()).ToArray();
}
private static object ToObject(this RecordDataValue record)
{
if (record.Properties.TryGetValue(TypeSchema.Discriminator, out DataValue? value) && value is StringDataValue typeValue)
{
if (string.Equals(nameof(ChatMessage), typeValue.Value, StringComparison.Ordinal))
{
return record.ToChatMessage();
}
if (string.Equals(nameof(ExpandoObject), typeValue.Value, StringComparison.Ordinal))
{
return record.ToDictionary();
}
}
return record.ToDictionary();
}
private static Dictionary<string, object?> ToDictionary(this RecordDataValue record)
{
Dictionary<string, object?> result = [];
result[TypeSchema.Discriminator] = nameof(ExpandoObject);
foreach (KeyValuePair<string, DataValue> property in record.Properties)
{
result[property.Key] = property.Value.ToObject();
@@ -8,8 +8,8 @@ using System.Dynamic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
@@ -4,6 +4,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.PowerFx.Types;
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Bot.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class JsonDocumentExtensions
{
public static FrozenDictionary<string, object?> ParseRecord(this JsonDocument jsonDocument, VariableType recordType) => jsonDocument.RootElement.ParseRecord(recordType);
public static RecordValue ParseRecord(this JsonDocument jsonDocument, RecordDataType recordType) => jsonDocument.RootElement.ParseRecord(recordType);
private static FrozenDictionary<string, object?> ParseRecord(this JsonElement currentElement, VariableType recordType)
{
if (!recordType.IsRecord || recordType.Schema is null)
{
throw new DeclarativeActionException($"Unable to parse JSON element as {recordType.Type.Name}.");
}
return ParseValues().ToFrozenDictionary(kvp => kvp.Key, kvp => kvp.Value);
IEnumerable<KeyValuePair<string, object?>> ParseValues()
{
foreach (KeyValuePair<string, VariableType?> property in recordType.Schema)
{
JsonElement propertyElement = currentElement.GetProperty(property.Key);
object? parsedValue =
property.Value?.Type switch
{
null => null,
_ when property.Value.Type == typeof(string) => propertyElement.GetString(),
_ when property.Value.Type == typeof(int) => propertyElement.GetInt32(),
_ when property.Value.Type == typeof(long) => propertyElement.GetInt64(),
_ when property.Value.Type == typeof(decimal) => propertyElement.GetDecimal(),
_ when property.Value.Type == typeof(double) => propertyElement.GetDouble(),
_ when property.Value.Type == typeof(bool) => propertyElement.GetBoolean(),
_ when property.Value.Type == typeof(DateTime) => propertyElement.GetDateTime(),
_ when property.Value.Type == typeof(TimeSpan) => propertyElement.GetDateTimeOffset().TimeOfDay,
_ when property.Value.IsRecord => propertyElement.ParseRecord(property.Value),
//TableDataType tableType => ParseTable(tableType, propertyElement),
_ => throw new InvalidOperationException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"),
};
yield return new KeyValuePair<string, object?>(property.Key, parsedValue);
}
//static TableValue ParseTable(TableDataType tableType, JsonElement propertyElement)
//{
// RecordDataType recordType = tableType.ToRecord();
// return
// FormulaValue.NewTable(
// recordType.ToRecordType(),
// propertyElement.EnumerateArray().Select(tableElement => tableElement.ParseRecord(recordType)));
//}
}
}
private static RecordValue ParseRecord(this JsonElement currentElement, RecordDataType recordType)
{
return FormulaValue.NewRecordFromFields(ParseValues());
IEnumerable<NamedValue> ParseValues()
{
foreach (KeyValuePair<string, PropertyInfo> property in recordType.Properties)
{
JsonElement propertyElement = currentElement.GetProperty(property.Key);
FormulaValue? parsedValue =
property.Value.Type switch
{
StringDataType => FormulaValue.New(propertyElement.GetString()),
NumberDataType => FormulaValue.New(propertyElement.GetDecimal()),
BooleanDataType => FormulaValue.New(propertyElement.GetBoolean()),
DateTimeDataType => FormulaValue.New(propertyElement.GetDateTime()),
DateDataType => FormulaValue.New(propertyElement.GetDateTime()),
TimeDataType => FormulaValue.New(propertyElement.GetDateTimeOffset().TimeOfDay),
RecordDataType recordType => propertyElement.ParseRecord(recordType),
TableDataType tableType => ParseTable(tableType, propertyElement),
_ => throw new InvalidOperationException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"),
};
yield return new NamedValue(property.Key, parsedValue);
}
static TableValue ParseTable(TableDataType tableType, JsonElement propertyElement)
{
RecordDataType recordType = tableType.ToRecord();
return
FormulaValue.NewTable(
recordType.ToRecordType(),
propertyElement.EnumerateArray().Select(tableElement => tableElement.ParseRecord(recordType)));
}
}
}
}
@@ -0,0 +1,200 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Bot.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class ObjectExtensions
{
public static IList<TElement>? AsList<TElement>(this object? value)
{
return value switch
{
null => null,
UnassignedValue => null,
BlankValue => null,
BlankDataValue => null,
IList<TElement> list => list,
IEnumerable<TElement> enumerable => enumerable.ToList(),
TElement element => [element],
_ => TypedElements().ToList(),
};
IEnumerable<TElement> TypedElements()
{
if (value is not IEnumerable enumerable)
{
throw new DeclarativeActionException($"Value '{value.GetType().Name}' is not '{nameof(IEnumerable)}'.");
}
foreach (var item in enumerable)
{
if (item is not TElement element)
{
throw new DeclarativeActionException($"Item '{item.GetType().Name}' is not of type '{typeof(TElement).Name}'");
}
yield return element;
}
}
}
public static object? ConvertType(this object? sourceValue, VariableType targetType)
{
if (!targetType.IsValid())
{
throw new DeclarativeActionException($"Unsupported type: '{targetType.Type.Name}'.");
}
if (sourceValue != null && targetType.Type.IsAssignableFrom(sourceValue.GetType()))
{
return sourceValue;
}
return targetType switch
{
_ when typeof(string).IsAssignableFrom(targetType.Type) => ConvertToString(),
_ when typeof(bool).IsAssignableFrom(targetType.Type) => ConvertToBool(),
_ when targetType.IsRecord => ConvertToRecord(),
_ when targetType.IsList => ConvertToList(),
_ when typeof(int).IsAssignableFrom(targetType.Type) => ConvertToInt(),
_ when typeof(long).IsAssignableFrom(targetType.Type) => ConvertToLong(),
_ when typeof(decimal).IsAssignableFrom(targetType.Type) => ConvertToDecimal(),
_ when typeof(double).IsAssignableFrom(targetType.Type) => ConvertToDouble(),
_ when typeof(DateTime).IsAssignableFrom(targetType.Type) => ConvertToDateTime(),
_ when typeof(TimeSpan).IsAssignableFrom(targetType.Type) => ConvertToTimeSpan(),
_ => throw new DeclarativeActionException($"Unsupported type: '{targetType.Type.Name}'."),
};
bool? ConvertToBool() =>
sourceValue switch
{
null => null,
string s => bool.Parse(s),
int i => i != 0,
long l => l != 0,
decimal c => c != 0,
double d => d != 0,
DateTime dt => dt > DateTime.MinValue,
TimeSpan ts => ts > TimeSpan.MinValue,
_ => sourceValue != null,
};
int? ConvertToInt() =>
sourceValue switch
{
null => null,
string s => int.Parse(s),
int i => i,
long l => Convert.ToInt32(l),
decimal c => Convert.ToInt32(c),
double d => Convert.ToInt32(d),
DateTime dt => Convert.ToInt32(dt),
TimeSpan ts => Convert.ToInt32(ts),
_ => throw new DeclarativeActionException($"Unsupported target type for '{sourceValue.GetType().Name}': '{targetType.Type.Name}'."),
};
long? ConvertToLong() =>
sourceValue switch
{
null => null,
string s => long.Parse(s),
int i => i,
long l => l,
decimal c => Convert.ToInt64(c),
double d => Convert.ToInt64(d),
DateTime dt => Convert.ToInt64(dt),
TimeSpan ts => Convert.ToInt64(ts),
_ => throw new DeclarativeActionException($"Unsupported target type for '{sourceValue.GetType().Name}': '{targetType.Type.Name}'."),
};
decimal? ConvertToDecimal() =>
sourceValue switch
{
null => null,
string s => decimal.Parse(s),
int i => i,
long l => l,
decimal c => c,
double d => Convert.ToDecimal(d),
DateTime dt => Convert.ToDecimal(dt),
TimeSpan ts => Convert.ToDecimal(ts),
_ => throw new DeclarativeActionException($"Unsupported target type for '{sourceValue.GetType().Name}': '{targetType.Type.Name}'."),
};
double? ConvertToDouble() =>
sourceValue switch
{
null => null,
string s => double.Parse(s),
int i => i,
long l => l,
decimal c => Convert.ToDouble(c),
double d => d,
DateTime dt => dt.Ticks,
TimeSpan ts => ts.Ticks,
_ => throw new DeclarativeActionException($"Unsupported target type for '{sourceValue.GetType().Name}': '{targetType.Type.Name}'."),
};
DateTime? ConvertToDateTime() =>
sourceValue switch
{
null => null,
string s => DateTime.Parse(s),
int i => new DateTime(i),
long l => new DateTime(l),
decimal c => new DateTime(Convert.ToInt64(c)),
double d => new DateTime(Convert.ToInt64(d)),
DateTime dt => dt,
TimeSpan ts => DateTime.Now.Date.AddTicks(ts.Ticks),
_ => throw new DeclarativeActionException($"Unsupported target type for '{sourceValue.GetType().Name}': '{targetType.Type.Name}'."),
};
TimeSpan? ConvertToTimeSpan() =>
sourceValue switch
{
null => null,
string s => TimeSpan.Parse(s),
int i => TimeSpan.FromTicks(i),
long l => TimeSpan.FromTicks(l),
decimal c => TimeSpan.FromTicks(Convert.ToInt64(c)),
double d => TimeSpan.FromTicks(Convert.ToInt64(d)),
DateTime dt => dt.TimeOfDay,
TimeSpan ts => ts,
_ => throw new DeclarativeActionException($"Unsupported target type for '{sourceValue.GetType().Name}': '{targetType.Type.Name}'."),
};
object? ConvertToList() =>
sourceValue switch
{
null => null,
//string jsonText => JsonDocument.Parse(jsonText.TrimJsonDelimiter()).ParseRecord(targetType),
_ => throw new DeclarativeActionException($"Cannot convert '{sourceValue?.GetType().Name}' to 'Record' (expected JSON string)."),
};
object? ConvertToRecord() =>
sourceValue switch
{
null => null,
string jsonText => JsonDocument.Parse(jsonText.TrimJsonDelimiter()).ParseRecord(targetType),
_ => throw new DeclarativeActionException($"Cannot convert '{sourceValue?.GetType().Name}' to 'Record' (expected JSON string)."),
};
string? ConvertToString() =>
sourceValue switch
{
null => null,
string sourceText => sourceText,
DateTime dateTime => dateTime.ToString("o"), // ISO 8601
TimeSpan timeSpan => timeSpan.ToString("c"), // Constant ("c") format
_ => $"{sourceValue}",
};
}
}
@@ -1,49 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Bot.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class RecordDataTypeExtensions
{
public static RecordValue ParseRecord(this RecordDataType recordType, JsonElement currentElement)
{
return FormulaValue.NewRecordFromFields(ParseValues());
IEnumerable<NamedValue> ParseValues()
{
foreach (KeyValuePair<string, PropertyInfo> property in recordType.Properties)
{
JsonElement propertyElement = currentElement.GetProperty(property.Key);
FormulaValue? parsedValue =
property.Value.Type switch
{
StringDataType => FormulaValue.New(propertyElement.GetString()),
NumberDataType => FormulaValue.New(propertyElement.GetDecimal()),
BooleanDataType => FormulaValue.New(propertyElement.GetBoolean()),
DateTimeDataType => FormulaValue.New(propertyElement.GetDateTime()),
DateDataType => FormulaValue.New(propertyElement.GetDateTime()),
TimeDataType => FormulaValue.New(propertyElement.GetDateTimeOffset().TimeOfDay),
RecordDataType recordType => recordType.ParseRecord(propertyElement),
TableDataType tableType => ParseTable(tableType, propertyElement),
_ => throw new InvalidOperationException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"),
};
yield return new NamedValue(property.Key, parsedValue);
}
static TableValue ParseTable(TableDataType tableType, JsonElement propertyElement)
{
RecordDataType recordType = tableType.ToRecord();
return
FormulaValue.NewTable(
recordType.ToRecordType(),
propertyElement.EnumerateArray().Select(tableElement => ParseRecord(recordType, tableElement)));
}
}
}
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.PowerFx.Types;
@@ -27,4 +29,30 @@ internal static partial class StringExtensions
public static FormulaValue ToFormula(this string? value) =>
string.IsNullOrWhiteSpace(value) ? FormulaValue.NewBlank() : FormulaValue.New(value);
public static string FormatType(this string identifier) => FormatIdentifier(identifier);
public static string FormatName(this string identifier) => FormatIdentifier(identifier, skipFirst: true);
private static string FormatIdentifier(string identifier, bool skipFirst = false)
{
string[] words = identifier.Split('_');
// Capitalize each word
for (int index = skipFirst ? 1 : 0; index < words.Length; ++index)
{
words[index] = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(words[index]);
}
// Combine the words and return
return string.Concat(words);
}
public static IEnumerable<string> ByLine(this string source)
{
foreach (string line in source.Trim().Split('\n'))
{
yield return line.TrimEnd();
}
}
}