Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| namespace TinyString; | ||
| public enum NamingFormat | ||
| { | ||
| CamelCase, | ||
| PascalCase, | ||
| SnakeCase, | ||
| KebabCase, | ||
| HumanCase | ||
| } |
| // <auto-generated/> | ||
| global using System; | ||
| global using System.Collections.Generic; | ||
| global using System.IO; | ||
| global using System.Linq; | ||
| global using System.Net.Http; | ||
| global using System.Threading; | ||
| global using System.Threading.Tasks; |
| // <auto-generated/> | ||
| global using System; | ||
| global using System.Collections.Generic; | ||
| global using System.IO; | ||
| global using System.Linq; | ||
| global using System.Net.Http; | ||
| global using System.Threading; | ||
| global using System.Threading.Tasks; |
| namespace TinyString; | ||
| public enum PrintStyle | ||
| { | ||
| SingleLine, | ||
| MultiLine | ||
| } |
| using System.Linq.Expressions; | ||
| namespace TinyString; | ||
| /// <summary> | ||
| /// Fluent builder for configuring how a single property is rendered. | ||
| /// Obtained via <see cref="StringifyOptions{T}.For{TProp}"/>. | ||
| /// </summary> | ||
| public sealed class PropertyBuilder<T, TProp> | ||
| { | ||
| private readonly StringifyOptions<T> _parent; | ||
| private readonly PropertyConfig _config; | ||
| internal PropertyBuilder(StringifyOptions<T> parent, PropertyConfig config) | ||
| { | ||
| _parent = parent; | ||
| _config = config; | ||
| } | ||
| /// <summary>Exclude this property from the output.</summary> | ||
| public PropertyBuilder<T, TProp> Ignore() { _config.Ignored = true; return this; } | ||
| /// <summary>Override the display name of this property's key.</summary> | ||
| public PropertyBuilder<T, TProp> As(string name) { _config.Label = name; return this; } | ||
| /// <summary>Render only the value — no key prefix.</summary> | ||
| public PropertyBuilder<T, TProp> NoKey() { _config.ShowKey = false; return this; } | ||
| /// <summary>Prepend text immediately before this property's value.</summary> | ||
| public PropertyBuilder<T, TProp> Prefix(string prefix) { _config.Prefix = prefix; return this; } | ||
| /// <summary>Append text immediately after this property's value.</summary> | ||
| public PropertyBuilder<T, TProp> Suffix(string suffix) { _config.Suffix = suffix; return this; } | ||
| /// <summary> | ||
| /// Override the separator used between items when this property is a collection. | ||
| /// If the separator starts with <c>\n</c> it is also prepended before the first item. | ||
| /// </summary> | ||
| public PropertyBuilder<T, TProp> Separator(string separator) { _config.CollectionSeparator = separator; return this; } | ||
| /// <summary>Override the number of decimal places for this property's value.</summary> | ||
| public PropertyBuilder<T, TProp> Decimals(int decimals) { _config.Decimals = decimals; return this; } | ||
| /// <summary> | ||
| /// Continue configuring the next property. Equivalent to calling | ||
| /// <c>.For()</c> on the parent <see cref="StringifyOptions{T}"/>. | ||
| /// </summary> | ||
| public PropertyBuilder<T, TNext> For<TNext>(Expression<Func<T, TNext>> selector) | ||
| => _parent.For(selector); | ||
| } |
| using System.Text.RegularExpressions; | ||
| namespace TinyString; | ||
| using System.Text; | ||
| public static class StringExtensions | ||
| { | ||
| /// <summary> | ||
| /// Naive CamelCase conversion: first letter lowercase, rest unchanged. | ||
| /// (You can enhance with better rules for acronyms, underscores, etc.) | ||
| /// </summary> | ||
| public static string ToCamelCase(this string str) | ||
| { | ||
| if (string.IsNullOrEmpty(str)) return str; | ||
| if (str.Length == 1) return str.ToLower(); | ||
| return char.ToLower(str[0]) + str.Substring(1); | ||
| } | ||
| /// <summary> | ||
| /// Simple SnakeCase conversion: insert underscores before uppercase letters, then lower everything. | ||
| /// (Again, can be improved for various corner cases.) | ||
| /// </summary> | ||
| public static string ToSnakeCase(this string str) | ||
| { | ||
| if (string.IsNullOrEmpty(str)) return str; | ||
| var sb = new StringBuilder(); | ||
| foreach (char c in str) | ||
| { | ||
| if (char.IsUpper(c) && sb.Length > 0) | ||
| { | ||
| sb.Append('_'); | ||
| } | ||
| sb.Append(char.ToLower(c)); | ||
| } | ||
| return sb.ToString(); | ||
| } | ||
| /// <summary> | ||
| /// Converts a string to kebab-case. | ||
| /// </summary> | ||
| public static string ToKebabCase(this string str) | ||
| { | ||
| if (string.IsNullOrEmpty(str)) return str; | ||
| var snakeCase = str.ToSnakeCase(); | ||
| return snakeCase.Replace('_', '-'); | ||
| } | ||
| /// <summary> | ||
| /// Converts a string to Human Case with spaces between words. | ||
| /// </summary> | ||
| public static string ToHumanCase(this string str) | ||
| { | ||
| if (string.IsNullOrEmpty(str)) return str; | ||
| var sb = new StringBuilder(); | ||
| for (int i = 0; i < str.Length; i++) | ||
| { | ||
| char c = str[i]; | ||
| // Add space before uppercase letters if not the first character | ||
| // and the previous character is not already a space | ||
| if (i > 0 && char.IsUpper(c) && !char.IsWhiteSpace(str[i - 1])) | ||
| { | ||
| sb.Append(' '); | ||
| } | ||
| sb.Append(c); | ||
| } | ||
| return sb.ToString(); | ||
| } | ||
| /// <summary> | ||
| /// Simple Slug conversion: remove non-alphanumeric characters and lowercase everything. | ||
| /// </summary> | ||
| /// <param name="str"></param> | ||
| /// <returns></returns> | ||
| public static string ToSlug(this string str) => Regex.Replace(str, "[^a-zA-Z0-9]", "").ToLower(); | ||
| /// <summary> | ||
| /// Remove newline characters from a string. | ||
| /// </summary> | ||
| /// <param name="str"></param> | ||
| /// <returns></returns> | ||
| public static string OneLine(this string? str) => str?.Replace("\n", " ").Replace("\r", " ") ?? ""; | ||
| /// <summary> | ||
| /// Split in the capped part and the residual part. | ||
| /// </summary> | ||
| /// <param name="str"></param> | ||
| /// <param name="length"></param> | ||
| /// <returns></returns> | ||
| public static (string capped, string residual) CapLength(this string str, int length) | ||
| => new | ||
| ( | ||
| str.Length <= length ? str : str[..length], | ||
| str.Length > length ? str[length..] : string.Empty | ||
| ); | ||
| /// <summary> | ||
| /// Check if a string is composed of only digits. | ||
| /// </summary> | ||
| /// <param name="str"></param> | ||
| /// <returns></returns> | ||
| public static bool IsDigitsOnly(this string str) => str.All(char.IsDigit); | ||
| /// <summary> | ||
| /// Remove all non-digit characters from a string. | ||
| /// </summary> | ||
| /// <param name="str"></param> | ||
| /// <returns></returns> | ||
| public static string KeepDigits(this string str) | ||
| => string.IsNullOrEmpty(str) ? string.Empty : new string(str.Where(char.IsDigit).ToArray()); | ||
| /// <summary> | ||
| /// Check if a string is null or empty. | ||
| /// </summary> | ||
| /// <param name="str"></param> | ||
| /// <returns></returns> | ||
| public static bool IsNullOrEmpty(this string? str) => string.IsNullOrEmpty(str); | ||
| /// <summary> | ||
| /// Check if a string is not null or empty. | ||
| /// </summary> | ||
| /// <param name="str"></param> | ||
| /// <returns></returns> | ||
| public static bool IsNotEmpty(this string? str) => !string.IsNullOrEmpty(str); | ||
| /// <summary> | ||
| /// Join a sequence | ||
| /// </summary> | ||
| /// <param name="source"></param> | ||
| /// <param name="separator"></param> | ||
| /// <typeparam name="T"></typeparam> | ||
| /// <returns></returns> | ||
| public static string Join<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source); | ||
| } |
| using System.Collections; | ||
| using System.Globalization; | ||
| using System.Reflection; | ||
| using System.Text; | ||
| namespace TinyString; | ||
| public static class Stringifier | ||
| { | ||
| // ── New fluent API ────────────────────────────────────────────────────── | ||
| /// <summary> | ||
| /// Converts an object to a human-readable string. | ||
| /// Pass an optional <paramref name="configure"/> action to customise the output | ||
| /// with the fluent builder; omit it for sensible defaults. | ||
| /// </summary> | ||
| public static string? Stringify<T>(this T obj, Action<StringifyOptions<T>>? configure = null) | ||
| { | ||
| if (obj is null) return null; | ||
| // When called with no options, honour any legacy attributes so that | ||
| // existing code keeps working without changes. | ||
| if (configure is null && HasLegacyAttributes(typeof(T))) | ||
| return StringifyLegacy(obj); | ||
| var options = new StringifyOptions<T>(); | ||
| configure?.Invoke(options); | ||
| return StringifyWithOptions(obj, options); | ||
| } | ||
| private static string StringifyWithOptions<T>(T obj, StringifyOptions<T> opts) | ||
| { | ||
| var type = typeof(T); | ||
| var sb = new StringBuilder(); | ||
| // Header | ||
| if (opts._showHeader) | ||
| { | ||
| sb.Append(opts._header ?? type.Name); | ||
| if (opts._style == PrintStyle.MultiLine) | ||
| sb.AppendLine(); | ||
| else | ||
| sb.Append(". "); | ||
| } | ||
| var props = type | ||
| .GetProperties(BindingFlags.Public | BindingFlags.Instance) | ||
| .Where(p => p.GetIndexParameters().Length == 0 && p.CanRead); | ||
| var first = true; | ||
| foreach (var prop in props) | ||
| { | ||
| opts._properties.TryGetValue(prop.Name, out var cfg); | ||
| if (cfg?.Ignored == true) continue; | ||
| var keyName = ConvertName(cfg?.Label ?? prop.Name, opts._namingFormat); | ||
| var showKey = cfg?.ShowKey ?? true; | ||
| var prefix = cfg?.Prefix ?? ""; | ||
| var suffix = cfg?.Suffix ?? ""; | ||
| var decimals = cfg?.Decimals ?? opts._decimals; | ||
| var collSep = cfg?.CollectionSeparator | ||
| ?? opts._collectionSeparator | ||
| ?? (opts._style == PrintStyle.MultiLine ? "\n|_ " : ", "); | ||
| var value = prop.GetValue(obj); | ||
| var rendered = prefix + ConvertValue(value, decimals, collSep) + suffix; | ||
| var line = showKey ? $"{keyName}: {rendered}" : rendered; | ||
| if (opts._style == PrintStyle.SingleLine) | ||
| { | ||
| if (!first) sb.Append(opts._separator); | ||
| sb.Append(line); | ||
| first = false; | ||
| } | ||
| else | ||
| { | ||
| sb.AppendLine(line); | ||
| } | ||
| } | ||
| return sb.ToString().TrimEnd(); | ||
| } | ||
| // ── Legacy attribute-based API (deprecated) ───────────────────────────── | ||
| /// <summary> | ||
| /// Converts an object to a string using the legacy attribute-based configuration | ||
| /// (<c>[Stringify]</c>, <c>[StringifyProperty]</c>, <c>[StringifyIgnore]</c>). | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// This overload is kept for backwards compatibility. Migrate to | ||
| /// <see cref="Stringify{T}(T, Action{StringifyOptions{T}})"/> with the fluent API. | ||
| /// Attribute-based configuration will be removed in a future major version. | ||
| /// </remarks> | ||
| [Obsolete( | ||
| "Attribute-based configuration is deprecated. " + | ||
| "Use Stringify<T>(Action<StringifyOptions<T>>) with the fluent API instead. " + | ||
| "Attribute-based configuration will be removed in a future major version.")] | ||
| public static string? Stringify(this object? obj) => | ||
| obj is null ? null : StringifyLegacy(obj); | ||
| // Called internally (no obsolete warning) for legacy-attribute objects and nested objects. | ||
| internal static string StringifyLegacy(object obj) | ||
| { | ||
| var type = obj.GetType(); | ||
| var classAttr = type.GetCustomAttribute<StringifyAttribute>() ?? new StringifyAttribute(); | ||
| var sb = new StringBuilder(); | ||
| if (classAttr.Emoji.IsNotEmpty()) | ||
| sb.Append(classAttr.Emoji); | ||
| if (classAttr.PrintClassName) | ||
| { | ||
| if (sb.Length > 0) sb.Append(' '); | ||
| sb.Append(type.Name); | ||
| } | ||
| if (sb.Length > 0) | ||
| { | ||
| if (classAttr.PrintStyle == PrintStyle.MultiLine) | ||
| sb.AppendLine(); | ||
| else | ||
| sb.Append(classAttr.ClassNameSeparator); | ||
| } | ||
| var props = type | ||
| .GetProperties(BindingFlags.Public | BindingFlags.Instance) | ||
| .Where(p => p.GetIndexParameters().Length == 0 && p.CanRead) | ||
| .Where(p => p.GetCustomAttribute<StringifyIgnoreAttribute>() == null); | ||
| var first = true; | ||
| foreach (var prop in props) | ||
| { | ||
| var propAttr = prop.GetCustomAttribute<StringifyPropertyAttribute>(); | ||
| var propName = ConvertName(prop.Name, classAttr.NamingFormat); | ||
| var format = propAttr?.Format ?? classAttr.PropertyFormat; | ||
| var collSep = propAttr?.CollectionSeparator ?? classAttr.CollectionSeparator; | ||
| var decimals = propAttr?.Decimals ?? classAttr.Decimals; | ||
| var value = prop.GetValue(obj); | ||
| var converted = ConvertValue(value, decimals, collSep); | ||
| var line = format.Replace("{k}", propName).Replace("{v}", converted); | ||
| if (classAttr.PrintStyle == PrintStyle.SingleLine) | ||
| { | ||
| if (!first) sb.Append(classAttr.PropertySeparator); | ||
| sb.Append(line); | ||
| first = false; | ||
| } | ||
| else | ||
| { | ||
| sb.AppendLine(line); | ||
| } | ||
| } | ||
| return sb.ToString().TrimEnd(); | ||
| } | ||
| // ── Shared helpers ────────────────────────────────────────────────────── | ||
| private static bool HasLegacyAttributes(Type type) | ||
| { | ||
| if (type.GetCustomAttribute<StringifyAttribute>() != null) return true; | ||
| return type | ||
| .GetProperties(BindingFlags.Public | BindingFlags.Instance) | ||
| .Any(p => p.GetCustomAttribute<StringifyPropertyAttribute>() != null | ||
| || p.GetCustomAttribute<StringifyIgnoreAttribute>() != null); | ||
| } | ||
| private static string ConvertName(string name, NamingFormat format) => format switch | ||
| { | ||
| NamingFormat.CamelCase => name.ToCamelCase(), | ||
| NamingFormat.SnakeCase => name.ToSnakeCase(), | ||
| NamingFormat.KebabCase => name.ToKebabCase(), | ||
| NamingFormat.HumanCase => name.ToHumanCase(), | ||
| _ => name, | ||
| }; | ||
| internal static string ConvertValue(object? value, int decimals, string collectionSeparator) => | ||
| value switch | ||
| { | ||
| null => "null", | ||
| string s => s, | ||
| bool b => b.ToString(), | ||
| Enum e => e.ToString(), | ||
| int or long or short | ||
| or byte or uint | ||
| or ulong or ushort | ||
| or sbyte => value.ToString()!, | ||
| IEnumerable enumerable => ConvertCollection(enumerable, decimals, collectionSeparator), | ||
| float f => f.ToString($"F{decimals}", CultureInfo.InvariantCulture), | ||
| double d => d.ToString($"F{decimals}", CultureInfo.InvariantCulture), | ||
| decimal m => m.ToString($"F{decimals}", CultureInfo.InvariantCulture), | ||
| _ => TrySmartEnum(value) ?? StringifyLegacy(value), | ||
| }; | ||
| private static string ConvertCollection(IEnumerable enumerable, int decimals, string separator) | ||
| { | ||
| var items = enumerable.Cast<object?>().Select(i => ConvertValue(i, decimals, separator)); | ||
| var joined = string.Join(separator, items); | ||
| // If the separator starts with a newline the caller wants it also before the first item. | ||
| return separator.Contains('\n') ? separator + joined : joined; | ||
| } | ||
| private static string? TrySmartEnum(object value) | ||
| { | ||
| var type = value.GetType(); | ||
| var nameProp = type.GetProperty("Name"); | ||
| if (nameProp?.PropertyType != typeof(string)) return null; | ||
| bool IsSmartEnum(Type t) => | ||
| t.GetInterfaces().Any(i => i.Name.Contains("SmartEnum")); | ||
| var cursor = type; | ||
| while (cursor != null && cursor != typeof(object)) | ||
| { | ||
| if (cursor.Name.Contains("SmartEnum") || IsSmartEnum(cursor)) | ||
| return nameProp.GetValue(value) as string; | ||
| cursor = cursor.BaseType; | ||
| } | ||
| return IsSmartEnum(type) ? nameProp.GetValue(value) as string : null; | ||
| } | ||
| } |
| namespace TinyString; | ||
| /// <summary> | ||
| /// An attribute to control how classes are stringified via the .Stringify() extension method. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Deprecated: use the fluent <c>Stringify(o => o.MultiLine()...)</c> API instead. | ||
| /// This attribute will be removed in a future major version. | ||
| /// </remarks> | ||
| [Obsolete( | ||
| "Attribute-based configuration is deprecated. " + | ||
| "Use the fluent Stringify<T>(Action<StringifyOptions<T>>) API instead. " + | ||
| "This attribute will be removed in a future major version.")] | ||
| [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] | ||
| public class StringifyAttribute : Attribute | ||
| { | ||
| /// <summary> | ||
| /// Determines whether properties are printed on one line | ||
| /// or multiple lines. Default = <see cref="PrintStyle.SingleLine"/>. | ||
| /// </summary> | ||
| public PrintStyle PrintStyle { get; set; } = PrintStyle.SingleLine; | ||
| /// <summary> | ||
| /// If <c>true</c>, prints the class name (or an emoji, if set) at the start | ||
| /// before printing any properties. Default = <c>true</c>. | ||
| /// </summary> | ||
| public bool PrintClassName { get; set; } = true; | ||
| /// <summary> | ||
| /// If set, prints this emoji at the start instead of the class name. | ||
| /// If <see cref="PrintClassName"/> is also <c>true</c> but <c>Emoji</c> is specified, | ||
| /// the emoji takes precedence. Default = <c>""</c>. | ||
| /// </summary> | ||
| public string Emoji { get; set; } = ""; | ||
| /// <summary> | ||
| /// A separator inserted immediately after the class name or emoji when | ||
| /// <see cref="PrintStyle"/> is SingleLine. | ||
| /// Default = <c>". "</c>. | ||
| /// </summary> | ||
| public string ClassNameSeparator { get; set; } = ". "; | ||
| /// <summary> | ||
| /// Used to separate properties when <see cref="PrintStyle"/> is SingleLine. | ||
| /// Default = <c>", "</c>. | ||
| /// </summary> | ||
| public string PropertySeparator { get; set; } = ", "; | ||
| /// <summary> | ||
| /// Used to separate collection items (e.g. elements in a List). | ||
| /// Default = <c>"; "</c>. | ||
| /// </summary> | ||
| public string CollectionSeparator { get; set; } = "; "; | ||
| /// <summary> | ||
| /// Number of decimal places to use when printing floating-point properties. | ||
| /// Default = <c>5</c>. | ||
| /// </summary> | ||
| public int Decimals { get; set; } = 5; | ||
| /// <summary> | ||
| /// Specifies how property names are converted: PascalCase, CamelCase, SnakeCase, etc. | ||
| /// Default = <see cref="NamingFormat.PascalCase"/>. | ||
| /// </summary> | ||
| public NamingFormat NamingFormat { get; set; } = NamingFormat.PascalCase; | ||
| /// <summary> | ||
| /// A format string controlling how each property is displayed. | ||
| /// <c>{k}</c> is replaced by the property name; <c>{v}</c> is replaced by its value. | ||
| /// Default = <c>"{k}: {v}"</c>. | ||
| /// </summary> | ||
| public string PropertyFormat { get; set; } = "{k}: {v}"; | ||
| } |
| namespace TinyString; | ||
| /// <remarks> | ||
| /// Deprecated: use <c>.For(x => x.Prop).Ignore()</c> in the fluent API instead. | ||
| /// This attribute will be removed in a future major version. | ||
| /// </remarks> | ||
| [Obsolete( | ||
| "Attribute-based configuration is deprecated. " + | ||
| "Use .For(x => x.Prop).Ignore() in the fluent Stringify<T>(Action<StringifyOptions<T>>) API instead. " + | ||
| "This attribute will be removed in a future major version.")] | ||
| [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] | ||
| public class StringifyIgnoreAttribute : Attribute | ||
| { | ||
| } |
| using System.Linq.Expressions; | ||
| namespace TinyString; | ||
| /// <summary> | ||
| /// Fluent builder for configuring how an object is stringified. | ||
| /// Obtain one via <see cref="Stringifier.Stringify{T}(T, Action{StringifyOptions{T}})"/>. | ||
| /// </summary> | ||
| public sealed class StringifyOptions<T> | ||
| { | ||
| // ── Global settings (internal so Stringifier can read them directly) ──── | ||
| internal PrintStyle _style = PrintStyle.SingleLine; | ||
| internal string? _header; | ||
| internal bool _showHeader = true; | ||
| internal string _separator = ", "; | ||
| internal string? _collectionSeparator; // null = auto (", " single-line / "\n|_ " multi-line) | ||
| internal int _decimals = 2; | ||
| internal NamingFormat _namingFormat = NamingFormat.PascalCase; | ||
| // ── Per-property settings ────────────────────────────────────────────── | ||
| internal readonly Dictionary<string, PropertyConfig> _properties = new(); | ||
| // ── Global option methods ────────────────────────────────────────────── | ||
| /// <summary>Print each property on its own line.</summary> | ||
| public StringifyOptions<T> MultiLine() { _style = PrintStyle.MultiLine; return this; } | ||
| /// <summary>Print all properties on a single line (default).</summary> | ||
| public StringifyOptions<T> SingleLine() { _style = PrintStyle.SingleLine; return this; } | ||
| /// <summary>Override the header shown before the properties (replaces the class name).</summary> | ||
| public StringifyOptions<T> Label(string label) { _header = label; return this; } | ||
| /// <summary>Hide the header entirely.</summary> | ||
| public StringifyOptions<T> NoLabel() { _showHeader = false; return this; } | ||
| /// <summary>Separator between properties in single-line mode. Default: <c>", "</c>.</summary> | ||
| public StringifyOptions<T> Separator(string separator) { _separator = separator; return this; } | ||
| /// <summary> | ||
| /// Override the default separator between collection items. | ||
| /// By default: <c>", "</c> in single-line mode, <c>"\n|_ "</c> in multi-line mode. | ||
| /// </summary> | ||
| public StringifyOptions<T> CollectionSeparator(string separator) { _collectionSeparator = separator; return this; } | ||
| /// <summary>Default number of decimal places for floating-point values. Default: <c>2</c>.</summary> | ||
| public StringifyOptions<T> Decimals(int decimals) { _decimals = decimals; return this; } | ||
| /// <summary>Naming format applied to all property keys. Default: <see cref="NamingFormat.PascalCase"/>.</summary> | ||
| public StringifyOptions<T> Keys(NamingFormat format) { _namingFormat = format; return this; } | ||
| // ── Property configuration ───────────────────────────────────────────── | ||
| /// <summary> | ||
| /// Begin configuring a specific property. Chain further calls on the returned | ||
| /// <see cref="PropertyBuilder{T, TProp}"/> and continue with the next | ||
| /// <c>.For()</c> when done. | ||
| /// </summary> | ||
| public PropertyBuilder<T, TProp> For<TProp>(Expression<Func<T, TProp>> selector) | ||
| { | ||
| var name = ExtractName(selector); | ||
| if (!_properties.TryGetValue(name, out var config)) | ||
| { | ||
| config = new PropertyConfig(); | ||
| _properties[name] = config; | ||
| } | ||
| return new PropertyBuilder<T, TProp>(this, config); | ||
| } | ||
| internal static string ExtractName<TProp>(Expression<Func<T, TProp>> selector) => | ||
| selector.Body is MemberExpression m | ||
| ? m.Member.Name | ||
| : throw new ArgumentException( | ||
| "Selector must be a direct property access, e.g. x => x.Name.", nameof(selector)); | ||
| } | ||
| /// <summary>Per-property configuration, populated by <see cref="PropertyBuilder{T, TProp}"/>.</summary> | ||
| internal sealed class PropertyConfig | ||
| { | ||
| public bool Ignored { get; set; } | ||
| public string? Label { get; set; } | ||
| public bool ShowKey { get; set; } = true; | ||
| public string? Prefix { get; set; } | ||
| public string? Suffix { get; set; } | ||
| public string? CollectionSeparator { get; set; } | ||
| public int? Decimals { get; set; } | ||
| } |
| namespace TinyString; | ||
| /// <remarks> | ||
| /// Deprecated: use the fluent <c>.For(x => x.Prop).Prefix(...).Suffix(...)</c> API instead. | ||
| /// This attribute will be removed in a future major version. | ||
| /// </remarks> | ||
| [Obsolete( | ||
| "Attribute-based configuration is deprecated. " + | ||
| "Use the fluent Stringify<T>(Action<StringifyOptions<T>>) API instead. " + | ||
| "This attribute will be removed in a future major version.")] | ||
| [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] | ||
| public class StringifyPropertyAttribute : Attribute | ||
| { | ||
| /// <summary> | ||
| /// Creates a new StringifyPropertyAttribute with optional format. | ||
| /// </summary> | ||
| /// <param name="format">Optional format string for this property.</param> | ||
| public StringifyPropertyAttribute(string? format = null) | ||
| { | ||
| Format = format; | ||
| } | ||
| /// <summary> | ||
| /// Format string for this property. | ||
| /// Use `{k}` for the property name and `{v}` for the value. | ||
| /// Default = <c>null</c> (uses class-level format). | ||
| /// </summary> | ||
| public string? Format { get; set; } | ||
| /// <summary> | ||
| /// Used to separate collection items (e.g. elements in a List). | ||
| /// Default = <c>null</c> (uses class-level separator). | ||
| /// </summary> | ||
| public string? CollectionSeparator { get; set; } | ||
| /// <summary> | ||
| /// Number of decimal places to use when printing floating-point properties. | ||
| /// Default = <c>null</c> (uses class-level decimal places). | ||
| /// </summary> | ||
| public int? Decimals { get; set; } | ||
| /// <summary> | ||
| /// A separator inserted immediately after the class name or emoji. | ||
| /// Default = <c>null</c> (uses class-level separator). | ||
| /// </summary> | ||
| public string? ClassNameSeparator { get; set; } | ||
| } |
| <Project Sdk="Microsoft.NET.Sdk"> | ||
| <Import Project="Version.props" Condition="Exists('Version.props')" /> | ||
| <PropertyGroup> | ||
| <TargetFrameworks>net8.0;net9.0</TargetFrameworks> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <Nullable>enable</Nullable> | ||
| <NuGetAudit>false</NuGetAudit> | ||
| <!-- NuGet package metadata --> | ||
| <PackageId>TinyString</PackageId> | ||
| <Version>$(ProjectVersion)</Version> | ||
| <Authors>Gianluca Belvisi</Authors> | ||
| <Company>gianlucabelvisi.com</Company> | ||
| <Description>Lightweight pretty-printer utilities for .NET</Description> | ||
| <PackageTags>string extensions stringify smart tostring pretty-print</PackageTags> | ||
| <PackageIcon>logo_icon.png</PackageIcon> | ||
| <PackageReadmeFile>README.md</PackageReadmeFile> | ||
| <PackageLicenseExpression>MIT</PackageLicenseExpression> | ||
| <PackageProjectUrl>https://github.com/gianlucabelvisi/TinyTools</PackageProjectUrl> | ||
| <RepositoryUrl>https://github.com/gianlucabelvisi/TinyTools</RepositoryUrl> | ||
| <IncludeSymbols>true</IncludeSymbols> | ||
| <IncludeSource>true</IncludeSource> | ||
| <GeneratePackageOnBuild>true</GeneratePackageOnBuild> | ||
| <RootNamespace>TinyString</RootNamespace> | ||
| </PropertyGroup> | ||
| <ItemGroup> | ||
| <None Include="README.md" Pack="true" PackagePath="" /> | ||
| <None Include="logo_icon.png" Pack="true" PackagePath="" /> | ||
| </ItemGroup> | ||
| </Project> |
@@ -5,6 +5,9 @@ <?xml version="1.0" encoding="utf-8"?> | ||
| <Default Extension="psmdcp" ContentType="application/vnd.openxmlformats-package.core-properties+xml" /> | ||
| <Default Extension="cs" ContentType="application/octet" /> | ||
| <Default Extension="csproj" ContentType="application/octet" /> | ||
| <Default Extension="dll" ContentType="application/octet" /> | ||
| <Default Extension="md" ContentType="application/octet" /> | ||
| <Default Extension="nuspec" ContentType="application/octet" /> | ||
| <Default Extension="pdb" ContentType="application/octet" /> | ||
| <Default Extension="png" ContentType="application/octet" /> | ||
| <Default Extension="nuspec" ContentType="application/octet" /> | ||
| </Types> |
+62
-349
@@ -1,12 +0,7 @@ | ||
| # Tiny String: Small but Mighty String Utilities for .NET | ||
| # TinyString | ||
|  | ||
|  | ||
| **TinyString** is a powerful, attribute-driven object pretty-printer for .NET. | ||
| Drop it into any C# project to get beautiful, customizable string representations of your objects. | ||
| Turn any .NET object into a readable string. Zero config for simple cases, a clean fluent API when you want more. | ||
| --- | ||
| ## Installation | ||
| ```bash | ||
@@ -18,385 +13,103 @@ dotnet add package TinyString | ||
| ## Table of Contents | ||
| ## Zero config | ||
| - [Basic Usage](#basic-usage) | ||
| - [Class-Level Customization](#class-level-customization) | ||
| - [Property-Level Customization](#property-level-customization) | ||
| - [Ignoring Properties](#ignoring-properties) | ||
| - [Naming Formats](#naming-formats) | ||
| - [Advanced Features](#advanced-features) | ||
| - [Full Examples](#full-examples) | ||
| - [License](#license) | ||
| Call `.Stringify()` on anything: | ||
| --- | ||
| ## Basic Usage | ||
| Just call `.Stringify()` on any object: | ||
| ```csharp | ||
| using TinyString; | ||
| public class Book | ||
| { | ||
| public string Title { get; set; } = ""; | ||
| public string Author { get; set; } = ""; | ||
| public int Pages { get; set; } | ||
| } | ||
| var book = new Book { Title = "1984", Author = "George Orwell", Pages = 328 }; | ||
| Console.WriteLine(book.Stringify()); | ||
| // Output: Book. Title: 1984, Author: George Orwell, Pages: 328 | ||
| book.Stringify(); | ||
| // → "Book. Title: 1984, Author: George Orwell, Pages: 328" | ||
| ``` | ||
| --- | ||
| All public properties, declaration order, class name as header, floats at 2 decimal places. No setup required. | ||
| ## Class-Level Customization | ||
| Control the output style with `[Stringify(...)]` options: | ||
| ```csharp | ||
| [Stringify( | ||
| PrintStyle = PrintStyle.MultiLine, | ||
| Emoji = "🦁🦓🦍", | ||
| PrintClassName = false, | ||
| PropertySeparator = " | ", | ||
| CollectionSeparator = "\n|_ ", | ||
| Decimals = 0, | ||
| NamingFormat = NamingFormat.KebabCase, | ||
| PropertyFormat = "{k} {v}", | ||
| ClassNameSeparator = " :: " | ||
| )] | ||
| public class Zoo | ||
| { | ||
| public required string Title { get; set; } | ||
| public List<Animal> Animals { get; set; } = []; | ||
| public double EntrancePrice { get; set; } | ||
| } | ||
| [Stringify( | ||
| PrintStyle = PrintStyle.SingleLine, | ||
| PrintClassName = false, | ||
| PropertySeparator = " ", | ||
| PropertyFormat = "{k} {v}", | ||
| Decimals = 2 | ||
| )] | ||
| public class Animal | ||
| { | ||
| [StringifyProperty(format: "{v}")] | ||
| public required string Name { get; set; } | ||
| [StringifyProperty(format: "({v})")] | ||
| public required Species Species { get; set; } | ||
| [StringifyProperty(format: "{v}kg")] | ||
| public required double Weight { get; set; } | ||
| [StringifyProperty(format: "{v}yrs")] | ||
| public int Age { get; set; } | ||
| [StringifyProperty(format: "(rare: {v})")] | ||
| public bool IsRare { get; set; } | ||
| } | ||
| public enum Species { Cat, Tiger, Elephant } | ||
| var zoo = new Zoo | ||
| { | ||
| Title = "Wonderful Zoo", | ||
| EntrancePrice = 15, | ||
| Animals = new() | ||
| { | ||
| new Animal { Name = "Mittens", Species = Species.Cat, Weight = 4.5, Age = 5, IsRare = false }, | ||
| new Animal { Name = "Tony", Species = Species.Tiger, Weight = 120.3, Age = 6, IsRare = true } | ||
| } | ||
| }; | ||
| Console.WriteLine(zoo.Stringify()); | ||
| /* | ||
| 🦁🦓🦍 | ||
| Title Wonderful Zoo | ||
| Animals | ||
| |_ Mittens (Cat) 4.50kg 5yrs (rare: False) | ||
| |_ Tony (Tiger) 120.30kg 6yrs (rare: True) | ||
| EntrancePrice 15 | ||
| */ | ||
| ``` | ||
| --- | ||
| ## Property-Level Customization | ||
| ## Fluent API | ||
| Override formatting for individual properties: | ||
| Pass a configuration action when you need more control. Everything lives at the call site — your classes stay clean: | ||
| ```csharp | ||
| [StringifyProperty(format: "🎭 {v}")] | ||
| public string Name { get; set; } | ||
| [StringifyProperty(format: "💰 {v} gold")] | ||
| public decimal Gold { get; set; } | ||
| [StringifyProperty(format: "HP: {v}/{v}")] | ||
| public Health Health { get; set; } | ||
| zoo.Stringify(o => o | ||
| .MultiLine() | ||
| .Label("🦁 Zoo") | ||
| .For(x => x.EntrancePrice).Prefix("$").Decimals(0)); | ||
| ``` | ||
| --- | ||
| ## Ignoring Properties | ||
| Skip properties with `[StringifyIgnore]`: | ||
| ```csharp | ||
| public class SecretNote | ||
| { | ||
| public string Message { get; set; } = ""; | ||
| [StringifyIgnore] | ||
| public string Password { get; set; } = ""; | ||
| } | ||
| var note = new SecretNote { Message = "Hello", Password = "secret123" }; | ||
| Console.WriteLine(note.Stringify()); | ||
| // Output: SecretNote: Message: Hello | ||
| // (Password is ignored) | ||
| ``` | ||
| --- | ||
| ## Naming Formats | ||
| Choose how property names are displayed: | ||
| ```csharp | ||
| [Stringify(NamingFormat = NamingFormat.SnakeCase)] | ||
| public class SnakeCaseExample | ||
| { | ||
| public string MyProperty { get; set; } = "value"; | ||
| } | ||
| // Output: SnakeCaseExample: my_property: value | ||
| [Stringify(NamingFormat = NamingFormat.HumanCase)] | ||
| public class HumanCaseExample | ||
| { | ||
| public string MyProperty { get; set; } = "value"; | ||
| } | ||
| // Output: HumanCaseExample: My Property: value | ||
| 🦁 Zoo | ||
| Name: Woodland Zoo | ||
| EntrancePrice: $15 | ||
| Animals: | ||
| |_ Animal. Name: Mittens, Species: Cat, Weight: 4.50 | ||
| |_ Animal. Name: Tony, Species: Tiger, Weight: 120.30 | ||
| ``` | ||
| **Available formats:** | ||
| - `PascalCase` (default): `MyProperty` | ||
| - `CamelCase`: `myProperty` | ||
| - `SnakeCase`: `my_property` | ||
| - `KebabCase`: `my-property` | ||
| - `HumanCase`: `My Property` | ||
| In multi-line mode, collections are automatically rendered with `|_ ` per item. In single-line mode they're joined with `", "`. No extra configuration needed. | ||
| --- | ||
| ## Advanced Features | ||
| ## Options | ||
| ### Nested Objects & Collections | ||
| ### Global | ||
| Objects are stringified recursively, and collections are joined with your specified separator: | ||
| | Method | What it does | Default | | ||
| |---|---|---| | ||
| | `.MultiLine()` / `.SingleLine()` | Layout style | single-line | | ||
| | `.Label("…")` | Replace the class name header | class name | | ||
| | `.NoLabel()` | Hide the header entirely | — | | ||
| | `.Separator("…")` | Between properties in single-line | `", "` | | ||
| | `.CollectionSeparator("…")` | Override the auto collection separator | auto | | ||
| | `.Decimals(n)` | Decimal places for floats | `2` | | ||
| | `.Keys(NamingFormat.X)` | Key naming style | `PascalCase` | | ||
| ```csharp | ||
| [Stringify( | ||
| PrintStyle = PrintStyle.MultiLine, | ||
| Emoji = "⚔️", | ||
| PropertySeparator = "\n", | ||
| CollectionSeparator = ", ", | ||
| Decimals = 1 | ||
| )] | ||
| public class Character | ||
| { | ||
| [StringifyProperty(format: "🎭 {v}")] | ||
| public string Name { get; set; } = ""; | ||
| Available naming formats: `PascalCase`, `CamelCase`, `SnakeCase`, `KebabCase`, `HumanCase`. | ||
| [StringifyProperty(format: "Skills: [{v}]")] | ||
| public List<string> Skills { get; set; } = new(); | ||
| ### Per property | ||
| [StringifyProperty(format: "🎒 {v}")] | ||
| public Inventory Inventory { get; set; } = new(); | ||
| } | ||
| Chain `.For(x => x.Prop)` to configure a specific property, then keep chaining `.For()` to move to the next one: | ||
| [Stringify( | ||
| PrintStyle = PrintStyle.SingleLine, | ||
| PropertySeparator = " | ", | ||
| PropertyFormat = "{k}={v}" | ||
| )] | ||
| public class Inventory | ||
| { | ||
| public List<Item> Items { get; set; } = new(); | ||
| public int Weight { get; set; } | ||
| } | ||
| [Stringify(PropertyFormat = "[{k}:{v}]")] | ||
| public class Item | ||
| { | ||
| public string Name { get; set; } = ""; | ||
| public ItemType Type { get; set; } | ||
| } | ||
| public enum ItemType { Weapon, Armor } | ||
| var hero = new Character | ||
| { | ||
| Name = "Gandalf", | ||
| Skills = new() { "Fireball", "Teleport" }, | ||
| Inventory = new Inventory | ||
| { | ||
| Items = new() { new Item { Name = "Staff", Type = ItemType.Weapon } }, | ||
| Weight = 15 | ||
| } | ||
| }; | ||
| Console.WriteLine(hero.Stringify()); | ||
| /* | ||
| ⚔️ Character | ||
| 🎭 Gandalf | ||
| Skills: [Fireball, Teleport] | ||
| 🎒 Items: [Name:Staff] [Type:Weapon] | Weight=15 | ||
| */ | ||
| ```csharp | ||
| animal.Stringify(o => o | ||
| .NoLabel() | ||
| .Separator(" ") | ||
| .For(x => x.Name).NoKey() | ||
| .For(x => x.Species).Prefix("(").Suffix(")").NoKey() | ||
| .For(x => x.Weight).NoKey().Suffix("kg").Decimals(2) | ||
| .For(x => x.Age).NoKey().Suffix("yrs") | ||
| .For(x => x.IsRare).Ignore()); | ||
| // → "Mittens (Cat) 4.50kg 5yrs" | ||
| ``` | ||
| ### Null & Empty Handling | ||
| | Method | What it does | | ||
| |---|---| | ||
| | `.Ignore()` | Exclude this property | | ||
| | `.As("…")` | Rename the key | | ||
| | `.NoKey()` | Show only the value | | ||
| | `.Prefix("…")` / `.Suffix("…")` | Wrap the value | | ||
| | `.Separator("…")` | Collection separator for this property | | ||
| | `.Decimals(n)` | Decimal places for this property | | ||
| ```csharp | ||
| var character = new Character | ||
| { | ||
| Name = "Empty Character", | ||
| Skills = null!, // null collection | ||
| Inventory = new Inventory { Items = new() } // empty collection | ||
| }; | ||
| Console.WriteLine(character.Stringify()); | ||
| // Skills: [null] | ||
| // Items: (empty collection) | ||
| ``` | ||
| --- | ||
| ## Full Examples | ||
| ## Nested objects | ||
| ### RPG Character Sheet | ||
| Nested objects are stringified automatically using their own defaults: | ||
| ```csharp | ||
| [Stringify( | ||
| PrintStyle = PrintStyle.MultiLine, | ||
| Emoji = "⚔️", | ||
| PropertySeparator = "\n", | ||
| CollectionSeparator = ", ", | ||
| Decimals = 1, | ||
| NamingFormat = NamingFormat.HumanCase | ||
| )] | ||
| public class Character | ||
| { | ||
| [StringifyProperty(format: "🎭 {v}")] | ||
| public string Name { get; set; } = ""; | ||
| [StringifyProperty(format: "Level {v}")] | ||
| public int Level { get; set; } | ||
| [StringifyProperty(format: "Class: {v}")] | ||
| public CharacterClass Class { get; set; } | ||
| [StringifyProperty(format: "Skills: [{v}]")] | ||
| public List<string> Skills { get; set; } = new(); | ||
| [StringifyProperty(format: "💰 {v} gold")] | ||
| public decimal Gold { get; set; } | ||
| [StringifyIgnore] | ||
| public string SecretPassword { get; set; } = ""; | ||
| [StringifyProperty(format: "⭐ {v}")] | ||
| public bool IsLegendary { get; set; } | ||
| } | ||
| public enum CharacterClass { Warrior, Mage, Rogue } | ||
| var hero = new Character | ||
| { | ||
| Name = "Gandalf", | ||
| Level = 99, | ||
| Class = CharacterClass.Mage, | ||
| Skills = new() { "Fireball", "Teleport", "Lightning Bolt" }, | ||
| Gold = 1250.75m, | ||
| IsLegendary = true | ||
| }; | ||
| Console.WriteLine(hero.Stringify()); | ||
| /* | ||
| ⚔️ Character | ||
| 🎭 Gandalf | ||
| Level 99 | ||
| Class: Mage | ||
| Skills: [Fireball, Teleport, Lightning Bolt] | ||
| 💰 1250.8 gold | ||
| ⭐ True | ||
| */ | ||
| var order = new Order { Product = new Product { Name = "Book", Price = 12.99 }, Qty = 2 }; | ||
| order.Stringify(); | ||
| // → "Order. Product: Product. Name: Book, Price: 12.99, Qty: 2" | ||
| ``` | ||
| ### Zoo Management | ||
| --- | ||
| ```csharp | ||
| [Stringify( | ||
| Emoji = "🦁🦓🦍", | ||
| PrintStyle = PrintStyle.MultiLine, | ||
| CollectionSeparator = "\n|_ ", | ||
| Decimals = 0 | ||
| )] | ||
| public class Zoo | ||
| { | ||
| public string Title { get; set; } = ""; | ||
| public List<Animal> Animals { get; set; } = new(); | ||
| public double EntrancePrice { get; set; } | ||
| } | ||
| ## Migrating from the attribute API | ||
| [Stringify( | ||
| PrintStyle = PrintStyle.SingleLine, | ||
| PropertySeparator = " ", | ||
| Decimals = 2 | ||
| )] | ||
| public class Animal | ||
| { | ||
| [StringifyProperty(format: "{v}")] | ||
| public string Name { get; set; } = ""; | ||
| Previous versions configured stringification via `[Stringify]`, `[StringifyProperty]`, and `[StringifyIgnore]` attributes. These still work but are deprecated and will be removed in a future major version. Move the configuration to the `.Stringify()` call site instead. | ||
| [StringifyProperty(format: "({v})")] | ||
| public Species Species { get; set; } | ||
| [StringifyProperty(format: "{v}kg")] | ||
| public double Weight { get; set; } | ||
| [StringifyProperty(format: "{v}yrs")] | ||
| public int Age { get; set; } | ||
| } | ||
| public enum Species { Cat, Tiger, Elephant } | ||
| var zoo = new Zoo | ||
| { | ||
| Title = "Wonderful Zoo", | ||
| Animals = new() | ||
| { | ||
| new Animal { Name = "Mittens", Species = Species.Cat, Weight = 4.5, Age = 5 }, | ||
| new Animal { Name = "Tony", Species = Species.Tiger, Weight = 120.3, Age = 6 } | ||
| }, | ||
| EntrancePrice = 15 | ||
| }; | ||
| Console.WriteLine(zoo.Stringify()); | ||
| /* | ||
| 🦁🦓🦍 Zoo | ||
| Title: Wonderful Zoo | ||
| Animals: | ||
| |_ Mittens (Cat) 4.50kg 5yrs | ||
| |_ Tony (Tiger) 120.30kg 6yrs | ||
| EntrancePrice: 15 | ||
| */ | ||
| ``` | ||
| --- | ||
| ## License | ||
| TinyString is licensed under the MIT License. | ||
| MIT License |
@@ -5,3 +5,3 @@ <?xml version="1.0" encoding="utf-8"?> | ||
| <id>TinyString</id> | ||
| <version>0.0.3</version> | ||
| <version>0.1.0</version> | ||
| <authors>Gianluca Belvisi</authors> | ||
@@ -15,3 +15,3 @@ <license type="expression">MIT</license> | ||
| <tags>string extensions stringify smart tostring pretty-print</tags> | ||
| <repository type="git" url="https://github.com/gianlucabelvisi/TinyTools" commit="20a934b554ac85d8f748c8f0a09642f19a8f4868" /> | ||
| <repository type="git" url="https://github.com/gianlucabelvisi/TinyTools" commit="9eec7761022e45ac1993f8442db2ae257324f7d0" /> | ||
| <dependencies> | ||
@@ -18,0 +18,0 @@ <group targetFramework="net8.0" /> |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet