#nullable enable using System; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using Swan.Formatters; namespace Swan { /// <summary> /// String related extension methods. /// </summary> public static class StringExtensions { #region Private Declarations private const RegexOptions StandardRegexOptions = RegexOptions.Multiline | RegexOptions.Compiled | RegexOptions.CultureInvariant; private static readonly String[] ByteSuffixes = { "B", "KB", "MB", "GB", "TB" }; private static readonly Lazy<Regex> SplitLinesRegex = new Lazy<Regex>(() => new Regex("\r\n|\r|\n", StandardRegexOptions)); private static readonly Lazy<Regex> UnderscoreRegex = new Lazy<Regex>(() => new Regex(@"_", StandardRegexOptions)); private static readonly Lazy<Regex> CamelCaseRegEx = new Lazy<Regex>(() => new Regex(@"[a-z][A-Z]", StandardRegexOptions)); private static readonly Lazy<MatchEvaluator> SplitCamelCaseString = new Lazy<MatchEvaluator>(() => m => { String x = m.ToString(); return x[0] + " " + x[1..]; }); private static readonly Lazy<String[]> InvalidFilenameChars = new Lazy<String[]>(() => Path.GetInvalidFileNameChars().Select(c => c.ToString()).ToArray()); #endregion /// <summary> /// Returns a string that represents the given item /// It tries to use InvariantCulture if the ToString(IFormatProvider) /// overload exists. /// </summary> /// <param name="this">The item.</param> /// <returns>A <see cref="String" /> that represents the current object.</returns> public static String ToStringInvariant(this Object? @this) { if(@this == null) { return String.Empty; } Type itemType = @this.GetType(); return itemType == typeof(String) ? @this as String ?? String.Empty : Definitions.BasicTypesInfo.Value.ContainsKey(itemType) ? Definitions.BasicTypesInfo.Value[itemType].ToStringInvariant(@this) : @this.ToString()!; } /// <summary> /// Returns a string that represents the given item /// It tries to use InvariantCulture if the ToString(IFormatProvider) /// overload exists. /// </summary> /// <typeparam name="T">The type to get the string.</typeparam> /// <param name="item">The item.</param> /// <returns>A <see cref="String" /> that represents the current object.</returns> public static String ToStringInvariant<T>(this T item) => typeof(String) == typeof(T) ? item as String ?? String.Empty : ToStringInvariant(item as Object); /// <summary> /// Removes the control characters from a string except for those specified. /// </summary> /// <param name="value">The input.</param> /// <param name="excludeChars">When specified, these characters will not be removed.</param> /// <returns> /// A string that represents the current object. /// </returns> /// <exception cref="ArgumentNullException">input.</exception> public static String RemoveControlCharsExcept(this String value, params Char[]? excludeChars) { if(value == null) { throw new ArgumentNullException(nameof(value)); } if(excludeChars == null) { excludeChars = Array.Empty<Char>(); } return new String(value.Where(c => Char.IsControl(c) == false || excludeChars.Contains(c)).ToArray()); } /// <summary> /// Removes all control characters from a string, including new line sequences. /// </summary> /// <param name="value">The input.</param> /// <returns>A <see cref="String" /> that represents the current object.</returns> /// <exception cref="ArgumentNullException">input.</exception> public static String RemoveControlChars(this String value) => value.RemoveControlCharsExcept(null); /// <summary> /// Outputs JSON string representing this object. /// </summary> /// <param name="this">The object.</param> /// <param name="format">if set to <c>true</c> format the output.</param> /// <returns>A <see cref="String" /> that represents the current object.</returns> public static String ToJson(this Object @this, Boolean format = true) => @this == null ? String.Empty : Json.Serialize(@this, format); /// <summary> /// Returns text representing the properties of the specified object in a human-readable format. /// While this method is fairly expensive computationally speaking, it provides an easy way to /// examine objects. /// </summary> /// <param name="this">The object.</param> /// <returns>A <see cref="String" /> that represents the current object.</returns> public static String Stringify(this Object @this) { if(@this == null) { return "(null)"; } try { String jsonText = Json.Serialize(@this, false, "$type"); Object? jsonData = Json.Deserialize(jsonText); return new HumanizeJson(jsonData, 0).GetResult(); } catch { return @this.ToStringInvariant(); } } /// <summary> /// Retrieves a section of the string, inclusive of both, the start and end indexes. /// This behavior is unlike JavaScript's Slice behavior where the end index is non-inclusive /// If the string is null it returns an empty string. /// </summary> /// <param name="this">The string.</param> /// <param name="startIndex">The start index.</param> /// <param name="endIndex">The end index.</param> /// <returns>Retrieves a substring from this instance.</returns> public static String Slice(this String @this, Int32 startIndex, Int32 endIndex) { if(@this == null) { return String.Empty; } Int32 end = endIndex.Clamp(startIndex, @this.Length - 1); return startIndex >= end ? String.Empty : @this.Substring(startIndex, end - startIndex + 1); } /// <summary> /// Gets a part of the string clamping the length and startIndex parameters to safe values. /// If the string is null it returns an empty string. This is basically just a safe version /// of string.Substring. /// </summary> /// <param name="this">The string.</param> /// <param name="startIndex">The start index.</param> /// <param name="length">The length.</param> /// <returns>Retrieves a substring from this instance.</returns> public static String SliceLength(this String @this, Int32 startIndex, Int32 length) { if(@this == null) { return String.Empty; } Int32 start = startIndex.Clamp(0, @this.Length - 1); Int32 len = length.Clamp(0, @this.Length - start); return len == 0 ? String.Empty : @this.Substring(start, len); } /// <summary> /// Splits the specified text into r, n or rn separated lines. /// </summary> /// <param name="this">The text.</param> /// <returns> /// An array whose elements contain the substrings from this instance /// that are delimited by one or more characters in separator. /// </returns> public static String[] ToLines(this String @this) => @this == null ? Array.Empty<String>() : SplitLinesRegex.Value.Split(@this); /// <summary> /// Humanizes (make more human-readable) an identifier-style string /// in either camel case or snake case. For example, CamelCase will be converted to /// Camel Case and Snake_Case will be converted to Snake Case. /// </summary> /// <param name="value">The identifier-style string.</param> /// <returns>A <see cref="String" /> humanized.</returns> public static String Humanize(this String value) { if(value == null) { return String.Empty; } String returnValue = UnderscoreRegex.Value.Replace(value, " "); returnValue = CamelCaseRegEx.Value.Replace(returnValue, SplitCamelCaseString.Value); return returnValue; } /// <summary> /// Humanizes (make more human-readable) an boolean. /// </summary> /// <param name="value">if set to <c>true</c> [value].</param> /// <returns>A <see cref="String" /> that represents the current boolean.</returns> public static String Humanize(this Boolean value) => value ? "Yes" : "No"; /// <summary> /// Humanizes (make more human-readable) the specified value. /// </summary> /// <param name="value">The value.</param> /// <returns>A <see cref="String" /> that represents the current object.</returns> public static String Humanize(this Object value) => value switch { String stringValue => stringValue.Humanize(), Boolean boolValue => boolValue.Humanize(), _ => value.Stringify() }; /// <summary> /// Indents the specified multi-line text with the given amount of leading spaces /// per line. /// </summary> /// <param name="value">The text.</param> /// <param name="spaces">The spaces.</param> /// <returns>A <see cref="String" /> that represents the current object.</returns> public static String Indent(this String value, Int32 spaces = 4) { if(value == null) { value = String.Empty; } if(spaces <= 0) { return value; } String[] lines = value.ToLines(); StringBuilder builder = new StringBuilder(); String indentStr = new String(' ', spaces); foreach(String line in lines) { _ = builder.AppendLine($"{indentStr}{line}"); } return builder.ToString().TrimEnd(); } /// <summary> /// Gets the line and column number (i.e. not index) of the /// specified character index. Useful to locate text in a multi-line /// string the same way a text editor does. /// Please not that the tuple contains first the line number and then the /// column number. /// </summary> /// <param name="value">The string.</param> /// <param name="charIndex">Index of the character.</param> /// <returns>A 2-tuple whose value is (item1, item2).</returns> public static Tuple<Int32, Int32> TextPositionAt(this String value, Int32 charIndex) { if(value == null) { return Tuple.Create(0, 0); } Int32 index = charIndex.Clamp(0, value.Length - 1); Int32 lineIndex = 0; Int32 colNumber = 0; for(Int32 i = 0; i <= index; i++) { if(value[i] == '\n') { lineIndex++; colNumber = 0; continue; } if(value[i] != '\r') { colNumber++; } } return Tuple.Create(lineIndex + 1, colNumber); } /// <summary> /// Makes the file name system safe. /// </summary> /// <param name="value">The s.</param> /// <returns> /// A string with a safe file name. /// </returns> /// <exception cref="ArgumentNullException">s.</exception> public static String ToSafeFilename(this String value) => value == null ? throw new ArgumentNullException(nameof(value)) : InvalidFilenameChars.Value.Aggregate(value, (current, c) => current.Replace(c, String.Empty)).Slice(0, 220); /// <summary> /// Formats a long into the closest bytes string. /// </summary> /// <param name="bytes">The bytes length.</param> /// <returns> /// The string representation of the current Byte object, formatted as specified by the format parameter. /// </returns> public static String FormatBytes(this Int64 bytes) => ((UInt64)bytes).FormatBytes(); /// <summary> /// Formats a long into the closest bytes string. /// </summary> /// <param name="bytes">The bytes length.</param> /// <returns> /// A copy of format in which the format items have been replaced by the string /// representations of the corresponding arguments. /// </returns> public static String FormatBytes(this UInt64 bytes) { Int32 i; Double dblSByte = bytes; for(i = 0; i < ByteSuffixes.Length && bytes >= 1024; i++, bytes /= 1024) { dblSByte = bytes / 1024.0; } return $"{dblSByte:0.##} {ByteSuffixes[i]}"; } /// <summary> /// Truncates the specified value. /// </summary> /// <param name="value">The value.</param> /// <param name="maximumLength">The maximum length.</param> /// <returns> /// Retrieves a substring from this instance. /// The substring starts at a specified character position and has a specified length. /// </returns> public static String? Truncate(this String value, Int32 maximumLength) => Truncate(value, maximumLength, String.Empty); /// <summary> /// Truncates the specified value and append the omission last. /// </summary> /// <param name="value">The value.</param> /// <param name="maximumLength">The maximum length.</param> /// <param name="omission">The omission.</param> /// <returns> /// Retrieves a substring from this instance. /// The substring starts at a specified character position and has a specified length. /// </returns> public static String? Truncate(this String value, Int32 maximumLength, String omission) => value == null ? null : value.Length > maximumLength ? value.Substring(0, maximumLength) + (omission ?? String.Empty) : value; /// <summary> /// Determines whether the specified <see cref="String"/> contains any of characters in /// the specified array of <see cref="Char"/>. /// </summary> /// <returns> /// <c>true</c> if <paramref name="value"/> contains any of <paramref name="chars"/>; /// otherwise, <c>false</c>. /// </returns> /// <param name="value"> /// A <see cref="String"/> to test. /// </param> /// <param name="chars"> /// An array of <see cref="Char"/> that contains characters to find. /// </param> public static Boolean Contains(this String value, params Char[] chars) => chars?.Length == 0 || !String.IsNullOrEmpty(value) && chars != null && value.IndexOfAny(chars) > -1; /// <summary> /// Replaces all chars in a string. /// </summary> /// <param name="value">The value.</param> /// <param name="replaceValue">The replace value.</param> /// <param name="chars">The chars.</param> /// <returns>The string with the characters replaced.</returns> public static String ReplaceAll(this String value, String replaceValue, params Char[] chars) => chars.Aggregate(value, (current, c) => current.Replace(new String(new[] { c }), replaceValue)); /// <summary> /// Convert hex character to an integer. Return -1 if char is something /// other than a hex char. /// </summary> /// <param name="value">The c.</param> /// <returns>Converted integer.</returns> public static Int32 Hex2Int(this Char value) => value >= '0' && value <= '9' ? value - '0' : value >= 'A' && value <= 'F' ? value - 'A' + 10 : value >= 'a' && value <= 'f' ? value - 'a' + 10 : -1; } }