diff --git a/src/Core/Core-Tests.csproj b/src/Core/Core-Tests.csproj index be6bbbe..93ae665 100644 --- a/src/Core/Core-Tests.csproj +++ b/src/Core/Core-Tests.csproj @@ -7,4 +7,15 @@ true $(NoWarn);CA1311 + + + + TextTemplatingFileGenerator + SpanExtensions.t.cs + + + SpanExtensions.t.tt + + + diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index a56c69c..39cbc23 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -17,24 +17,18 @@ $(_IsPublishing) - Some OK core utility functions + Some OK core utility functions. https://github.com/scottbilas/OkTools - 1.0.18 + 1.0.19 README.md LICENSE.md - - - - - TextTemplatingFileGenerator - WideTypes.cs - + all @@ -47,10 +41,22 @@ + + + TextTemplatingFileGenerator + WideTypes.cs + WideTypes.tt + + TextTemplatingFileGenerator + SpanExtensions.cs + + + SpanExtensions.tt + diff --git a/src/Core/DebugDump.cs b/src/Core/DebugDump.cs new file mode 100644 index 0000000..67c716e --- /dev/null +++ b/src/Core/DebugDump.cs @@ -0,0 +1,164 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text; + +public static class DebugDump +{ + public static string ToEscapedString(this string @this) + { + var count = 0; + for (var i = 0; i != @this.Length; ++i) + { + switch (@this[i]) + { + case '\n': + case '\r': + case '\t': + ++count; + break; + } + } + + var buf = new char[@this.Length + count]; + for (var (i, o) = (0, 0); i != @this.Length; ++i, ++o) + { + switch (@this[i]) + { + case '\n': + buf[o++] = '\\'; + buf[o] = 'n'; + break; + case '\r': + buf[o++] = '\\'; + buf[o] = 'r'; + break; + case '\t': + buf[o++] = '\\'; + buf[o] = 't'; + break; + default: + buf[o] = @this[i]; + break; + } + } + + return new string(buf); + } + + public static string ToArgString(this string? @this) => + @this != null ? $"\"{@this.ToEscapedString()}\"" : "null"; + + const int k_indentMultiple = 2; + + // TODO(TRIM): consider something like https://github.com/byme8/Apparatus.AOT.Reflection + + public static string ToDumpString(this object @this, string? name = null, bool quiet = true, Func? filter = null, int? wrapWidth = null, int maxWrapLines = 0) + { + // i hate this function impl, but i'm not going to let that stop me from making it worse! + + var sb = new StringBuilder(); + var indent = 0; + + if (name != null) + { + sb.AppendLine($"['{name}' {@this.GetType().FullName}]"); + ++indent; + } + + var properties = @this + .GetType() + .GetProperties() + .Where(pi => filter?.Invoke(pi) ?? true) + .OrderBy(pi => pi.Name) + .Select(pi => + { + string? text = null; + + var value = pi.GetValue(@this); + switch (value) + { + case null: + if (!quiet) + text = "null"; + break; + case bool b: + if (b || !quiet) + text = b.ToString().ToLower(); + break; + case IntPtr p: + if (p != default || !quiet) + text = $"0x{p:x}"; + break; + case string str: + text = str.ToArgString(); + break; + case IEnumerable enumerable: + text = '[' + enumerable.Select(s => s.ToArgString()).StringJoin(", ") + ']'; + break; + case IEnumerable> stringDict: + if (@this is ProcessStartInfo && pi.Name == "Environment") + { + stringDict = stringDict + .OrderBy(kv => Environment.GetEnvironmentVariable(kv.Key) != kv.Value ? -1 : 1) // inherited env vars go at the bottom + .ThenBy(kv => kv.Key); + } + else + stringDict = stringDict.OrderBy(kv => kv.Key); + text = '[' + stringDict.Select(kv => $"{kv.Key}={kv.Value.ToArgString()}").StringJoin(", ") + ']'; + break; + case NPath npath: + text = npath.ToString(SlashMode.Native); + break; + default: + text = value.ToString(); + break; + } + + return (name: pi.Name, text); + }) + .Where(p => p.text != null) + .ToArray(); + + var maxNameLen = properties.Max(p => p.name.Length); + + foreach (var p in properties) + { + var text = p.text!; + + var wrapIndent = sb.Length; + sb.Append(' ', indent * k_indentMultiple); + sb.Append(p.name); + sb.Append(' ', maxNameLen - p.name.Length); + sb.Append(" = "); + wrapIndent = sb.Length - wrapIndent; + + if (wrapWidth != null && wrapWidth > wrapIndent + 20) // 20 just a reasonable "any less than this and wrapping looks way worse than not" + { + var written = 0; + var wrappedLines = 0; + + while (written < text.Length) + { + if (written > 0) + sb.Append(' ', wrapIndent); + + var end = Math.Min(text.Length, written + wrapWidth.Value - wrapIndent); + sb.AppendLine(text[written..end]); + written += end - written; + ++wrappedLines; + + if (maxWrapLines > 0 && wrappedLines >= maxWrapLines) + { + sb.Append(' ', wrapIndent); + sb.AppendLine("(...remainder truncated...)"); + break; + } + } + } + else + sb.AppendLine(text); + } + + return sb.ToString(); + } +} diff --git a/src/Core/Extensions/DictionaryExtensions.cs b/src/Core/Extensions/DictionaryExtensions.cs index 5864063..713339c 100644 --- a/src/Core/Extensions/DictionaryExtensions.cs +++ b/src/Core/Extensions/DictionaryExtensions.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace OkTools.Core.Extensions; [PublicAPI] @@ -56,4 +58,13 @@ public static IDictionary AddOrUpdateRange(this IDic return @this; } + + public static bool TryGetValue(this Dictionary @this, ReadOnlySpan key, [MaybeNullWhen(false)] out TValue value) + { +# if NET9_0_OR_GREATER + return @this.GetAlternateLookup>().TryGetValue(key, out value); +# else + return @this.TryGetValue(key.ToString(), out value); // have to alloc :/ +# endif + } } diff --git a/src/Core/Extensions/EnumerableExtensions.cs b/src/Core/Extensions/EnumerableExtensions.cs index 73bec46..8583002 100644 --- a/src/Core/Extensions/EnumerableExtensions.cs +++ b/src/Core/Extensions/EnumerableExtensions.cs @@ -49,6 +49,44 @@ public static T SingleOr(this IEnumerable @this, Func defaultValueGener return value; } + public static IEnumerable<(T item, int index, bool isLast)> SelectWithPositions(this IEnumerable @this) + { + using var enumerator = @this.GetEnumerator(); + if (!enumerator.MoveNext()) + yield break; + + for (var index = 0;;++index) + { + var current = enumerator.Current; + var isLast = !enumerator.MoveNext(); + yield return (current, index, isLast); + if (isLast) + break; + } + } + + public static IEnumerable<(TResult item, int index, bool isLast)> SelectWithPositions(this IEnumerable @this, Func selector) + { + using var enumerator = @this.GetEnumerator(); + if (!enumerator.MoveNext()) + yield break; + + for (var index = 0;;++index) + { + var current = selector(enumerator.Current); + var isLast = !enumerator.MoveNext(); + yield return (current, index, isLast); + if (isLast) + break; + } + } + + public static IEnumerable SelectItem(this IEnumerable<(T item, int index, bool isLast)> @this) + { + foreach (var item in @this) + yield return item.item; + } + public static T First(this IReadOnlyList @this) => @this[0]; public static T Last(this IReadOnlyList @this) => @@ -140,17 +178,21 @@ public static bool TryLast(this IReadOnlyList @this, out T? found) public static IEnumerable WhereNotNull(this IEnumerable @this) where T: class => @this.Where(item => item is not null)!; - public static IEnumerable WhereNotNull(this IEnumerable @this) where T: struct => @this.Where(item => item.HasValue).Select(item => item!.Value); + public static ParallelQuery WhereNotNull(this ParallelQuery @this) where T: class => + @this.Where(item => item is not null)!; + public static ParallelQuery WhereNotNull(this ParallelQuery @this) where T: struct => + @this.Where(item => item.HasValue).Select(item => item!.Value); + public static IEnumerable SelectWhere( this IEnumerable @this, - Func selectWhere) + Func selectWhere) { foreach (var item in @this) { - var (selected, shouldSelect) = selectWhere(item); + var (shouldSelect, selected) = selectWhere(item); if (shouldSelect) yield return selected; } diff --git a/src/Core/Extensions/EnumerableExtensions.t.cs b/src/Core/Extensions/EnumerableExtensions.t.cs index fb11fc2..469b9c8 100644 --- a/src/Core/Extensions/EnumerableExtensions.t.cs +++ b/src/Core/Extensions/EnumerableExtensions.t.cs @@ -1,4 +1,5 @@ using System.Collections; +// ReSharper disable UseArrayEmptyMethod class EnumerableExtensionsTests { @@ -51,6 +52,34 @@ class DirectIndexTest(int count, int index, T value) : IReadOnlyList public T this[int i] => i == index ? value : throw new InvalidOperationException(); } + static IEnumerable SelectWithPositionsCases() => new TestCaseData[] { + new(new char[0], new (char, int, bool)[0]), + new(new[] { 'a' }, + new[] { ('a', 0, true) }), + new(new[] { 'a','b','c' }, + new[] { ('a', 0, false), ('b', 1, false), ('c', 2, true) }) + }; + + [TestCaseSource(nameof(SelectWithPositionsCases))] + public void SelectWithPositions(char[] items, (char c, int i, bool l)[] expected) + { + items.SelectWithPositions().ShouldBe(expected); + } + + static IEnumerable SelectWithPositionsSelectorCases() => new TestCaseData[] { + new(new char[0], new (string, int, bool)[0]), + new(new[] { 'a' }, + new[] { ("a", 0, true) }), + new(new[] { 'a','b','c' }, + new[] { ("a", 0, false), ("b", 1, false), ("c", 2, true) }) + }; + + [TestCaseSource(nameof(SelectWithPositionsSelectorCases))] + public void SelectWithPositions_WithSelector(char[] items, (string c, int i, bool l)[] expected) + { + items.SelectWithPositions(c => c.ToString()).ShouldBe(expected); + } + [Test] public void First_WithReadOnlyList_ShouldOnlyCallIndexer() { diff --git a/src/Core/Extensions/SmallExtensions.cs b/src/Core/Extensions/SmallExtensions.cs index 91fe6f1..04f4b53 100644 --- a/src/Core/Extensions/SmallExtensions.cs +++ b/src/Core/Extensions/SmallExtensions.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text; namespace OkTools.Core.Extensions; @@ -107,3 +108,26 @@ public static class TupleExtensions yield return @this[i]; } } + +[PublicAPI] +public static class StringBuilderExtensions +{ + public static StringBuilder AppendLf(this StringBuilder @this) => + @this.Append('\n'); + public static StringBuilder AppendLf(this StringBuilder @this, string? value) + { @this.Append(value); return @this.Append('\n'); } + public static bool IsEmpty(this StringBuilder @this) => + @this.Length == 0; + public static bool Any(this StringBuilder @this) => + @this.Length != 0; +} + +[PublicAPI] +public static class ExceptionExtensions +{ + public static T WithData(this T @this, object key, object value) where T : Exception + { + @this.Data[key] = value; + return @this; + } +} diff --git a/src/Core/Extensions/SpanExtensions.cs b/src/Core/Extensions/SpanExtensions.cs index 6123110..d33a6be 100644 --- a/src/Core/Extensions/SpanExtensions.cs +++ b/src/Core/Extensions/SpanExtensions.cs @@ -1,20 +1,25 @@ +// DO NOT MODIFY, THIS FILE IS GENERATED + namespace OkTools.Core.Extensions; -public static class ReadOnlySpanExtensions +[PublicAPI] +public static partial class ReadOnlySpanExtensions { + public static ReadOnlySpan Slice(this ReadOnlySpan @this, int start) => + @this.Slice(start, @this.Length - start); + public static ReadOnlySpan SliceSafe(this ReadOnlySpan @this, int start, int length) { if (start < 0) { - length += start; + length -= -start; start = 0; } - if (start + length > @this.Length) - length = @this.Length - start; - - if (length <= 0) - return default; + if (start >= @this.Length || length <= 0) + return new(); + if (start + length >= @this.Length) + return @this.Slice(start); return @this.Slice(start, length); } @@ -22,29 +27,32 @@ public static ReadOnlySpan SliceSafe(this ReadOnlySpan @this, int start public static ReadOnlySpan SliceSafe(this ReadOnlySpan @this, int start) { if (start < 0) - start = 0; - else if (start >= @this.Length) - return default; + return @this; + if (start >= @this.Length) + return new(); - return @this[start..]; + return @this.Slice(start); } } -public static class SpanExtensions +[PublicAPI] +public static partial class SpanExtensions { + public static Span Slice(this Span @this, int start) => + @this.Slice(start, @this.Length - start); + public static Span SliceSafe(this Span @this, int start, int length) { if (start < 0) { - length += start; + length -= -start; start = 0; } - if (start + length > @this.Length) - length = @this.Length - start; - - if (length <= 0) - return default; + if (start >= @this.Length || length <= 0) + return new(); + if (start + length >= @this.Length) + return @this.Slice(start); return @this.Slice(start, length); } @@ -52,11 +60,44 @@ public static Span SliceSafe(this Span @this, int start, int length) public static Span SliceSafe(this Span @this, int start) { if (start < 0) + return @this; + if (start >= @this.Length) + return new(); + + return @this.Slice(start); + } +} + +[PublicAPI] +public static partial class StringSegmentExtensions +{ + public static StringSegment Slice(this StringSegment @this, int start) => + @this.Slice(start, @this.Length - start); + + public static StringSegment SliceSafe(this StringSegment @this, int start, int length) + { + if (start < 0) + { + length -= -start; start = 0; - else if (start >= @this.Length) - return default; + } + + if (start >= @this.Length || length <= 0) + return new(); + if (start + length >= @this.Length) + return @this.Slice(start); + + return @this.Slice(start, length); + } + + public static StringSegment SliceSafe(this StringSegment @this, int start) + { + if (start < 0) + return @this; + if (start >= @this.Length) + return new(); - return @this[start..]; + return @this.Slice(start); } } diff --git a/src/Core/Extensions/SpanExtensions.tt b/src/Core/Extensions/SpanExtensions.tt index d9366ea..c1d37a7 100644 --- a/src/Core/Extensions/SpanExtensions.tt +++ b/src/Core/Extensions/SpanExtensions.tt @@ -1,35 +1,40 @@ <#@ output extension=".cs" #> <# // https://github.com/jgiannuzzi/T4.Build/issues/7 #@ output extension=".gen.cs" #> +// DO NOT MODIFY, THIS FILE IS GENERATED + namespace OkTools.Core.Extensions; -<# foreach (var type in new[] { "ReadOnlySpan", "Span" }) { #> -public static class <#=type#>Extensions +<# foreach (var (type, ptype) in new[] {("ReadOnlySpan", ""), ("Span", ""), ("StringSegment", "")}) { #> +[PublicAPI] +public static partial class <#=type#>Extensions { - public static <#=type#> SliceSafe(this <#=type#> @this, int start, int length) + public static <#=type#><#=ptype#> Slice<#=ptype#>(this <#=type#><#=ptype#> @this, int start) => + @this.Slice(start, @this.Length - start); + + public static <#=type#><#=ptype#> SliceSafe<#=ptype#>(this <#=type#><#=ptype#> @this, int start, int length) { if (start < 0) { - length += start; + length -= -start; start = 0; } - if (start + length > @this.Length) - length = @this.Length - start; - - if (length <= 0) - return default; + if (start >= @this.Length || length <= 0) + return new(); + if (start + length >= @this.Length) + return @this.Slice(start); return @this.Slice(start, length); } - public static <#=type#> SliceSafe(this <#=type#> @this, int start) + public static <#=type#><#=ptype#> SliceSafe<#=ptype#>(this <#=type#><#=ptype#> @this, int start) { if (start < 0) - start = 0; - else if (start >= @this.Length) - return default; + return @this; + if (start >= @this.Length) + return new(); - return @this[start..]; + return @this.Slice(start); } } diff --git a/src/Core/Extensions/StringExtensions.cs b/src/Core/Extensions/StringExtensions.cs index 82c07e3..fcc6c01 100644 --- a/src/Core/Extensions/StringExtensions.cs +++ b/src/Core/Extensions/StringExtensions.cs @@ -26,7 +26,7 @@ public static int IndexOfNot(this string @this, char value, int startIndex, int if (count < 0 || count > @this.Length - startIndex) throw new ArgumentOutOfRangeException(nameof(count), $"Out of range 0 <= {count} <= {@this.Length - startIndex}"); - for (var (i, iend) = (startIndex, startIndex + count); i != iend; ++i) + for (var (i, ie) = (startIndex, startIndex + count); i != ie; ++i) { if (@this[i] != value) return i; @@ -105,6 +105,9 @@ public static ReadOnlySpan AsSpanSafe(this string @this, int start, int ma public static ReadOnlySpan AsSpanSafe(this string @this, int start) => @this.AsSpanSafe(start, @this.Length); + public static StringSegment AsStringSegment(this string @this) => + new(@this); + public static IEnumerable SelectToStrings(this IEnumerable @this) => @this.Select(v => v?.ToString()).WhereNotNull(); @@ -146,18 +149,65 @@ public static string StringJoin(this ITuple @this, char separator) => public static string StringJoin(this ITuple @this) => string.Join("", @this.SelectObjects()); - public static string RegexReplace(this string @this, string pattern, string replacement) => - Regex.Replace(@this, pattern, replacement); - public static string RegexReplace(this string @this, string pattern, string replacement, RegexOptions options) => - Regex.Replace(@this, pattern, replacement, options); - public static string RegexReplace(this string @this, string pattern, string replacement, RegexOptions options, TimeSpan matchTimeout) => - Regex.Replace(@this, pattern, replacement, options, matchTimeout); - public static string RegexReplace(this string @this, string pattern, MatchEvaluator evaluator) => - Regex.Replace(@this, pattern, evaluator); - public static string RegexReplace(this string @this, string pattern, MatchEvaluator evaluator, RegexOptions options) => - Regex.Replace(@this, pattern, evaluator, options); - public static string RegexReplace(this string @this, string pattern, MatchEvaluator evaluator, RegexOptions options, TimeSpan matchTimeout) => - Regex.Replace(@this, pattern, evaluator, options, matchTimeout); + public static IEnumerable SelectLines(this string @this) + { + var reader = new StringReader(@this); + while (reader.ReadLine() is { } line) + yield return line; + } + + public static IEnumerable SelectLinesAsSegments(this StringSegment @this) + { + for (var iter = @this;;) + { + var end = iter.IndexOf('\n'); + if (end < 0) + { + if (iter.Any) + yield return iter; + yield break; + } + + var trim = 0; + if (end > 0 && iter[end-1] == '\r') + trim = 1; + yield return iter[..(end-trim)]; + + iter = iter[(end+1)..]; + } + } + + public static IEnumerable SelectLinesAsSegments(this string @this) => + SelectLinesAsSegments(@this.AsStringSegment()); + + public static bool WildcardMatch(this string @this, string wildcardPattern, RegexOptions rxOptions = RegexOptions.IgnoreCase) => + TextUtility.WildcardToRegex(wildcardPattern, rxOptions).IsMatch(@this); + + public static Match RegexMatch(this string @this, string rxPattern) => + Regex.Match(@this, rxPattern); + public static MatchCollection RegexMatches(this string @this, string rxPattern) => + Regex.Matches(@this, rxPattern); + public static Match RegexMatch(this string @this, Regex rx) => + rx.Match(@this); + public static MatchCollection RegexMatches(this string @this, Regex rx) => + rx.Matches(@this); + + public static string RegexReplace(this string @this, string rxPattern, string replacement) => + Regex.Replace(@this, rxPattern, replacement); + public static string RegexReplace(this string @this, string rxPattern, string replacement, RegexOptions options) => + Regex.Replace(@this, rxPattern, replacement, options); + public static string RegexReplace(this string @this, string rxPattern, string replacement, RegexOptions options, TimeSpan matchTimeout) => + Regex.Replace(@this, rxPattern, replacement, options, matchTimeout); + public static string RegexReplace(this string @this, string rxPattern, MatchEvaluator evaluator) => + Regex.Replace(@this, rxPattern, evaluator); + public static string RegexReplace(this string @this, string rxPattern, MatchEvaluator evaluator, RegexOptions options) => + Regex.Replace(@this, rxPattern, evaluator, options); + public static string RegexReplace(this string @this, string rxPattern, MatchEvaluator evaluator, RegexOptions options, TimeSpan matchTimeout) => + Regex.Replace(@this, rxPattern, evaluator, options, matchTimeout); + public static string RegexReplace(this string @this, Regex rx, string replacement) => + rx.Replace(@this, replacement); + public static string RegexReplace(this string @this, Regex rx, MatchEvaluator evaluator) => + rx.Replace(@this, evaluator); public static string ToLowerFirstChar(this string @this) { @@ -196,7 +246,7 @@ public static string ExpandTabs(this string @this, int tabWidth, StringBuilder? if (tabCount == 0) return @this; - // more early-out and a bit silly scenarios, but why not.. + // more early-out and a bit silly scenarios, but why not... if (tabWidth == 0) return @this.Replace("\t", ""); if (tabWidth == 1) diff --git a/src/Core/Extensions/StringExtensions.t.cs b/src/Core/Extensions/StringExtensions.t.cs index 8f31691..203884a 100644 --- a/src/Core/Extensions/StringExtensions.t.cs +++ b/src/Core/Extensions/StringExtensions.t.cs @@ -1,5 +1,6 @@ // ReSharper disable StringLiteralTypo +[Parallelizable] class StringExtensionsTests { [Test] @@ -211,6 +212,38 @@ public void StringJoin_WithMultiple_ReturnsJoined() enumerable.StringJoin('\n').ShouldBe("abc\n57\n-14\nz"); } + static (string, string[])[] SelectLinesCases() => + [ + ("", []), + ("\n", [ "" ]), + ("\r\n", [ "" ]), + ("\n\r\n", [ "", "" ]), + ("\r\n\r\n", [ "", "" ]), + ("abc", [ "abc" ]), + ("abc\ndef\n\nghi", [ "abc", "def", "", "ghi" ]), + ("abc\ndefgh\n\n \r\njklm\n", [ "abc", "defgh", "", " ", "jklm" ]), + ("abc\ndefghi\n\nnopr\r\n", [ "abc", "defghi", "", "nopr" ]), + (" help Print\n diff Build\n manifest Build project manifests for Unity to generate dll's for diff reports.\n codedom Dump a C# file annotated with node/token kinds for codedom debugging.", + [" help Print", + " diff Build", + " manifest Build project manifests for Unity to generate dll's for diff reports.", + " codedom Dump a C# file annotated with node/token kinds for codedom debugging."]), + ]; + + [TestCaseSource(nameof(SelectLinesCases))] + public void SelectLines((string str, string[] expected) data) + { + var strs = data.str.SelectLines().ToArray(); + strs.ShouldBe(data.expected); + } + + [TestCaseSource(nameof(SelectLinesCases))] + public void SelectLinesAsSegments((string str, string[] expected) data) + { + var segs = data.str.SelectLinesAsSegments().Select(s => s.ToString()).ToArray(); + segs.ShouldBe(data.expected); + } + [TestCase("", "")] [TestCase("abc", "abc")] [TestCase("Abc", "abc")] diff --git a/src/Core/NiceIO_Ext.cs b/src/Core/NiceIO_Ext.cs index d18ef4a..ff60ebe 100644 --- a/src/Core/NiceIO_Ext.cs +++ b/src/Core/NiceIO_Ext.cs @@ -234,4 +234,28 @@ public Stream OpenReadWriteShared() => File.Open(ToString(SlashMode.Native), FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); public StreamReader OpenReaderShared() => new(OpenReadShared()); + + public Match RegexContentsMatch(string rxPattern) => + ReadAllText().RegexMatch(rxPattern); + public Match RegexContentsMatch(Regex rx) => + ReadAllText().RegexMatch(rx); + public MatchCollection RegexContentsMatches(string rxPattern) => + ReadAllText().RegexMatches(rxPattern); + public MatchCollection RegexContentsMatches(Regex rx) => + ReadAllText().RegexMatches(rx); + + public string ToDisplayString(NPath? tryRelativeTo) + { + var relPath = tryRelativeTo != null ? MakeRelative(tryRelativeTo) : this; + if (relPath.IsRelative) + { + var str = relPath.TildeCollapse().ToString(SlashMode.Forward); + + // starts to get really unreadable with more than this many + if (!str.StartsWith("../../../../", StringComparison.Ordinal)) + return str; + } + + return TildeCollapse().ToString(SlashMode.Forward); + } } diff --git a/src/Core/SpanExtensions.t.cs b/src/Core/SpanExtensions.t.cs new file mode 100644 index 0000000..ea8e793 --- /dev/null +++ b/src/Core/SpanExtensions.t.cs @@ -0,0 +1,79 @@ +// DO NOT MODIFY, THIS FILE IS GENERATED + +class ReadOnlySpanExtensionTests +{ + [Test] + public void SliceSafe() + { + ReadOnlySpan span = new[] { 0, 1, 2, 3 }.AsSpan(); + + span.SliceSafe(-5, 3).ToArray().ShouldBeEmpty(); + span.SliceSafe(-5, 8).ToArray().ShouldBe([0, 1, 2]); + span.SliceSafe(-5).ToArray().ShouldBe([0, 1, 2, 3]); + + span.SliceSafe(2, -1).ToArray().ShouldBeEmpty(); + span.SliceSafe(2, 5).ToArray().ShouldBe([2, 3]); + span.SliceSafe(2).ToArray().ShouldBe([2, 3]); + + span.SliceSafe(7, 2).ToArray().ShouldBeEmpty(); + span.SliceSafe(7, -5).ToArray().ShouldBeEmpty(); + span.SliceSafe(7).ToArray().ShouldBeEmpty(); + } + + [Test] + public void SliceSafe_WithEmpty() + { + ReadOnlySpan span = new int[0].AsSpan(); + + span.SliceSafe(-5, 3).ToArray().ShouldBeEmpty(); + span.SliceSafe(-5, 8).ToArray().ShouldBeEmpty(); + span.SliceSafe(-5).ToArray().ShouldBeEmpty(); + + span.SliceSafe(2, -1).ToArray().ShouldBeEmpty(); + span.SliceSafe(2, 5).ToArray().ShouldBeEmpty(); + span.SliceSafe(2).ToArray().ShouldBeEmpty(); + + span.SliceSafe(7, 2).ToArray().ShouldBeEmpty(); + span.SliceSafe(7, -5).ToArray().ShouldBeEmpty(); + span.SliceSafe(7).ToArray().ShouldBeEmpty(); + } +} + +class SpanExtensionTests +{ + [Test] + public void SliceSafe() + { + Span span = new[] { 0, 1, 2, 3 }.AsSpan(); + + span.SliceSafe(-5, 3).ToArray().ShouldBeEmpty(); + span.SliceSafe(-5, 8).ToArray().ShouldBe([0, 1, 2]); + span.SliceSafe(-5).ToArray().ShouldBe([0, 1, 2, 3]); + + span.SliceSafe(2, -1).ToArray().ShouldBeEmpty(); + span.SliceSafe(2, 5).ToArray().ShouldBe([2, 3]); + span.SliceSafe(2).ToArray().ShouldBe([2, 3]); + + span.SliceSafe(7, 2).ToArray().ShouldBeEmpty(); + span.SliceSafe(7, -5).ToArray().ShouldBeEmpty(); + span.SliceSafe(7).ToArray().ShouldBeEmpty(); + } + + [Test] + public void SliceSafe_WithEmpty() + { + Span span = new int[0].AsSpan(); + + span.SliceSafe(-5, 3).ToArray().ShouldBeEmpty(); + span.SliceSafe(-5, 8).ToArray().ShouldBeEmpty(); + span.SliceSafe(-5).ToArray().ShouldBeEmpty(); + + span.SliceSafe(2, -1).ToArray().ShouldBeEmpty(); + span.SliceSafe(2, 5).ToArray().ShouldBeEmpty(); + span.SliceSafe(2).ToArray().ShouldBeEmpty(); + + span.SliceSafe(7, 2).ToArray().ShouldBeEmpty(); + span.SliceSafe(7, -5).ToArray().ShouldBeEmpty(); + span.SliceSafe(7).ToArray().ShouldBeEmpty(); + } +} diff --git a/src/Core/Extensions/SpanExtensions.t.cs b/src/Core/SpanExtensions.t.tt similarity index 58% rename from src/Core/Extensions/SpanExtensions.t.cs rename to src/Core/SpanExtensions.t.tt index 41bd03b..02d8854 100644 --- a/src/Core/Extensions/SpanExtensions.t.cs +++ b/src/Core/SpanExtensions.t.tt @@ -1,9 +1,14 @@ -class ReadOnlySpanExtensionsTests +<#@ output extension=".cs" #> +<# // https://github.com/jgiannuzzi/T4.Build/issues/7 #@ output extension=".gen.cs" #> +// DO NOT MODIFY, THIS FILE IS GENERATED +<# foreach (var type in new[] {"ReadOnlySpan", "Span"}) { #> + +class <#=type#>ExtensionTests { [Test] public void SliceSafe() { - ReadOnlySpan span = new[] { 0, 1, 2, 3 }.AsSpan(); + <#=type#> span = new[] { 0, 1, 2, 3 }.AsSpan(); span.SliceSafe(-5, 3).ToArray().ShouldBeEmpty(); span.SliceSafe(-5, 8).ToArray().ShouldBe([0, 1, 2]); @@ -17,25 +22,23 @@ public void SliceSafe() span.SliceSafe(7, -5).ToArray().ShouldBeEmpty(); span.SliceSafe(7).ToArray().ShouldBeEmpty(); } -} -class SpanExtensionsTests -{ [Test] - public void SliceSafe() + public void SliceSafe_WithEmpty() { - Span span = new[] { 0, 1, 2, 3 }.AsSpan(); + <#=type#> span = new int[0].AsSpan(); span.SliceSafe(-5, 3).ToArray().ShouldBeEmpty(); - span.SliceSafe(-5, 8).ToArray().ShouldBe([0, 1, 2]); - span.SliceSafe(-5).ToArray().ShouldBe([0, 1, 2, 3]); + span.SliceSafe(-5, 8).ToArray().ShouldBeEmpty(); + span.SliceSafe(-5).ToArray().ShouldBeEmpty(); span.SliceSafe(2, -1).ToArray().ShouldBeEmpty(); - span.SliceSafe(2, 5).ToArray().ShouldBe([2, 3]); - span.SliceSafe(2).ToArray().ShouldBe([2, 3]); + span.SliceSafe(2, 5).ToArray().ShouldBeEmpty(); + span.SliceSafe(2).ToArray().ShouldBeEmpty(); span.SliceSafe(7, 2).ToArray().ShouldBeEmpty(); span.SliceSafe(7, -5).ToArray().ShouldBeEmpty(); span.SliceSafe(7).ToArray().ShouldBeEmpty(); } } +<# } #> diff --git a/src/Core/StaticUtility.cs b/src/Core/StaticUtility.cs index cb2a4c7..ab47c65 100644 --- a/src/Core/StaticUtility.cs +++ b/src/Core/StaticUtility.cs @@ -7,4 +7,7 @@ public static partial class StaticUtility { [DebuggerStepThrough] public static T[] Arr(params T[] items) => items; + + public static int MinimizeWith(this ref int @this, int other) => @this = Math.Min(@this, other); + public static int MaximizeWith(this ref int @this, int other) => @this = Math.Max(@this, other); } diff --git a/src/Core/StringSegment.cs b/src/Core/StringSegment.cs index 1cc47ee..24e8b55 100644 --- a/src/Core/StringSegment.cs +++ b/src/Core/StringSegment.cs @@ -12,13 +12,20 @@ public static void Append(this StringBuilder @this, StringSegment segment) => @this.Append(segment.String, segment.SegmentStart, segment.Length); } +// TODO: consider getting rid of this in favor of ReadOnlyMemory extensions [PublicAPI] [DebuggerDisplay("{ToDebugString()}")] -public readonly struct StringSegment : IEquatable +public readonly struct StringSegment : + IEquatable, +# if NET9_0_OR_GREATER + IEquatable>, +# endif + IEquatable>, + IEquatable { // TODO: TESTS - readonly string _string; + readonly string? _string; // nullable because we're a struct readonly int _offset, _length; public StringSegment(string str) @@ -50,14 +57,14 @@ public StringSegment(in StringSegment src, int start, int length) public string ToDebugString() { - var str = _string + var str = String .Substring(_offset, _length) .Replace("\r", "\\r") .Replace("\n", "\\n"); return $"'{str}' (start={_offset}, end={SegmentEnd}, len={_length})"; } - public override string ToString() => _string[SegmentStart..SegmentEnd]; + public override string ToString() => _length > 0 ? _string!.Substring(_offset, _length) : ""; // no alloc if full string public ReadOnlySpan Span => _string.AsSpan(_offset, _length); @@ -79,7 +86,7 @@ public ReadOnlySpan AsSpan(int start, int length) public int SegmentStart => _offset; public int SegmentEnd => _offset + _length; - public string String => _string; + public string String => _string ?? ""; public int Length => _length; public bool IsEmpty => _length == 0; public bool Any => _length != 0; @@ -92,12 +99,25 @@ public char this[int index] { if (index < 0 || index >= _length) throw new ArgumentOutOfRangeException(nameof(index), $"Out of range 0 <= {index} < {_length}"); - return _string[_offset + index]; + return _string![_offset + index]; } } public StringSegment Slice(int start, int length) => new(this, start, length); + public bool StartsWith(char value) => + _length != 0 && _string![_offset] == value; + public bool StartsWith(ReadOnlySpan value) => + Span.StartsWith(value); + public bool StartsWith(ReadOnlySpan value, StringComparison comparison) => + Span.StartsWith(value, comparison); + public bool EndsWith(char value) => + _length != 0 && _string![_offset + _length - 1] == value; + public bool EndsWith(ReadOnlySpan value) => + Span.EndsWith(value); + public bool EndsWith(ReadOnlySpan value, StringComparison comparison) => + Span.EndsWith(value, comparison); + public int IndexOf(char value) => Span.IndexOf(value); public int IndexOf(char value, int startIndex) => @@ -105,12 +125,19 @@ public int IndexOf(char value, int startIndex) => public int IndexOf(char value, int startIndex, int count) => AsSpan(startIndex, count).IndexOf(value) + startIndex; + public int IndexOf(string value) => + Span.IndexOf(value); + public int IndexOf(string value, int startIndex) => + AsSpan(startIndex).IndexOf(value) + startIndex; + public int IndexOf(string value, int startIndex, int count) => + AsSpan(startIndex, count).IndexOf(value) + startIndex; + public int GetTrimStart() { var i = 0; for (; i != _length; ++i) { - if (!char.IsWhiteSpace(_string[_offset + i])) + if (!char.IsWhiteSpace(_string![_offset + i])) break; } return i; @@ -121,39 +148,59 @@ public int GetTrimEnd() var i = _length; for (; i > 0; --i) { - if (!char.IsWhiteSpace(_string[_offset + i-1])) + if (!char.IsWhiteSpace(_string![_offset + i-1])) break; } return i; } + public static implicit operator StringSegment(string value) => new(value); + public static implicit operator ReadOnlySpan(StringSegment value) => value.Span; + public StringSegment TrimStart() => this[GetTrimStart()..]; public StringSegment TrimEnd() => this[..GetTrimEnd()]; public StringSegment Trim() => this[GetTrimStart()..GetTrimEnd()]; - public Match Match(Regex regex) => regex.Match(_string, _offset, _length); - - public bool StringEquals(StringSegment other, StringComparison comparison = StringComparison.Ordinal) => - _length == other._length && StringCompare(other) == 0; - - public int StringCompare(StringSegment other, StringComparison comparison = StringComparison.Ordinal) => - string.Compare(_string, _offset, other._string, other._offset, _length, comparison); + public Match Match(Regex regex) => regex.Match(String, _offset, _length); + + public int Compare(StringSegment other, StringComparison comparison = StringComparison.Ordinal) => + Span.CompareTo(other.Span, comparison); + public int Compare(ReadOnlyMemory other, StringComparison comparison = StringComparison.Ordinal) => + Span.CompareTo(other.Span, comparison); + public int Compare(ReadOnlySpan other, StringComparison comparison = StringComparison.Ordinal) => + Span.CompareTo(other, comparison); + public int Compare(string other, StringComparison comparison = StringComparison.Ordinal) => + Span.CompareTo(other.AsSpan(), comparison); + + public bool Equals(StringSegment other, StringComparison comparison) => + Span.Equals(other.Span, comparison); + public bool Equals(ReadOnlyMemory other, StringComparison comparison) => + Span.Equals(other.Span, comparison); + public bool Equals(ReadOnlySpan other, StringComparison comparison) => + Span.Equals(other, comparison); + public bool Equals(string other, StringComparison comparison) => + Span.Equals(other.AsSpan(), comparison); public bool Equals(StringSegment other) => - ReferenceEquals(_string, other._string) && _offset == other._offset && _length == other._length; - public bool Equals(string other, StringComparison comparison = StringComparison.Ordinal) => - string.Compare(_string, _offset, other, 0, Math.Max(_length, other.Length), comparison) == 0; + Span.Equals(other.Span, StringComparison.Ordinal); + public bool Equals(ReadOnlyMemory other) => + Span.Equals(other.Span, StringComparison.Ordinal); + public bool Equals(ReadOnlySpan other) => + Span.Equals(other, StringComparison.Ordinal); + public bool Equals(string? other) => + other != null && Span.Equals(other.AsSpan(), StringComparison.Ordinal); public override bool Equals(object? obj) => obj switch { - StringSegment other when Equals(other) => true, - string other when Equals(other) => true, + StringSegment other => Equals(other), + ReadOnlyMemory other => Equals(other), + string other => Equals(other), _ => false, }; - public override int GetHashCode() => HashCode.Combine(_string, _offset, _length); + public override int GetHashCode() => HashCode.Combine(String, _offset, _length); public static bool operator ==(StringSegment left, StringSegment right) => left.Equals(right); public static bool operator !=(StringSegment left, StringSegment right) => !left.Equals(right); diff --git a/src/Core/StringSegment.t.cs b/src/Core/StringSegment.t.cs new file mode 100644 index 0000000..1dece4d --- /dev/null +++ b/src/Core/StringSegment.t.cs @@ -0,0 +1,49 @@ +class StringSegmentTests +{ + [TestCase("abc", "abc", 0, true)] + [TestCase("abcd", "abc", 1, false)] + [TestCase("", "", 0, true)] + public void Equals(string a, string b, int compare, bool equal) + { + var s = a.AsStringSegment(); + + s.Compare(b).ShouldBe(compare); + s.Equals(b).ShouldBe(equal); + + s.Compare(b.AsStringSegment()).ShouldBe(compare); + s.Equals(b.AsStringSegment()).ShouldBe(equal); + + s.Compare(b.AsSpan()).ShouldBe(compare); + s.Equals(b.AsSpan()).ShouldBe(equal); + + s.Compare(b.AsMemory()).ShouldBe(compare); + s.Equals(b.AsMemory()).ShouldBe(equal); + } + + [TestCase("", -1, 5, "")] + [TestCase("abc", -1, 5, "abc")] + [TestCase("abcdefgh", -1, 5, "abcd")] + [TestCase("", 0, 15, "")] + [TestCase("abc", 1, 15, "bc")] + [TestCase("abcdefgh", 2, 15, "cdefgh")] + [TestCase("", 0, 0, "")] + [TestCase("abc", 1, 1, "b")] + [TestCase("abcdefgh", 2, 5, "cdefg")] + public void SliceSafe_WithStartAndLength(string str, int start, int length, string expected) + { + var result = str.AsStringSegment().SliceSafe(start, length); + result.ToString().ShouldBe(expected); + } + + [TestCase("", -1, "")] + [TestCase("abc", -1, "abc")] + [TestCase("abcdefgh", -1, "abcdefgh")] + [TestCase("", 0, "")] + [TestCase("abc", 1, "bc")] + [TestCase("abcdefgh", 2, "cdefgh")] + public void SliceSafe_WithStart(string str, int start, string expected) + { + var result = str.AsStringSegment().SliceSafe(start); + result.ToString().ShouldBe(expected); + } +} diff --git a/src/Core/TextUtility.cs b/src/Core/TextUtility.cs index 9894bf6..ee27097 100644 --- a/src/Core/TextUtility.cs +++ b/src/Core/TextUtility.cs @@ -4,7 +4,7 @@ namespace OkTools.Core; [PublicAPI] -public static class TextUtility +public static partial class TextUtility { /// /// Scans `text` for EOL sequences (\n, \r\n) and returns the most common seen. Old style mac sequences (plain \r) are not supported and instead treated as unix (plain \n). @@ -64,103 +64,4 @@ public static Regex WildcardToRegex(string wildcard, RegexOptions rxOptions = Re public static bool IsWildcardPattern(string patternToTest) => patternToTest.Any(c => c is '*' or '?'); - - public delegate bool Replacer(ReadOnlySpan macroName, TextWriter writer); - - public static int ReplaceMacros(ReadOnlySpan source, TextWriter writer, Replacer replacer) - { - var found = 0; - - var offset = 0; - for (;;) - { - // find start of next macro, writing remainder if no more macros - var begin = source.IndexOf("{{"); - if (begin < 0) - { - writer.Write(source); - break; - } - - // write what was before the macro and advance - var oldSpan = source; - writer.Write(source[..begin]); - source = source[(begin+2)..]; - offset += begin+2; - - // find end of this macro - var end = source.IndexOf("}}"); - if (end < 0) - throw new FormatException($"Macro starting at offset {offset} and beginning with '{oldSpan.SliceSafe(0, 20).ToString()}' was not closed"); - - // collect the macro name and advance - var macro = source[..end]; - source = source[(end+2)..]; - offset += end+2; - - // find replacement matching the macro - if (!replacer(macro, writer)) - throw new FormatException($"Unrecognized macro '{macro.ToString()}'"); - - ++found; - } - - return found; - } - - public static bool ContainsMacros(ReadOnlySpan source) => - source.IndexOf("{{") >= 0; - - public static string ReplaceMacros(string source, Replacer replacer) - { - if (!ContainsMacros(source)) - return source; - - var sb = new StringBuilder(); - return ReplaceMacros(source, new StringWriter(sb), replacer) == 0 - ? source - : sb.ToString(); - } - - public static string ReplaceMacros(string source, params (string name, Action replacer)[] replacements) => - ReplaceMacros(source, CreateMacroReplacer(replacements)); - - public static Replacer CreateMacroReplacer(params (string name, Action replacer)[] replacements) => - CreateMacroReplacer(10, replacements); - - public static Replacer CreateMacroReplacer(int useDictIfLengthAtLeast, params (string name, Action replacer)[] replacements) - { - // $$$ TODO: validate that the macro names are valid, and that there are no duplicates - // also if there is no end marker when move to support $macro type names, check for ambiguous/overlapping names - // (for example they cannot contain '{{' or '}}' or be empty, or have "macro_a" and "macro_ab" as separate macros) - - // if it's small, do a linear search - if (replacements.Length < useDictIfLengthAtLeast) - { - return (macroName, writer) => - { - foreach (var (name, action) in replacements) - { - if (!macroName.SequenceEqual(name)) - continue; - - action(writer); - return true; - } - - return false; - }; - } - - // bigger gets a dict, more setup time and alloc, but faster lookup - var dict = replacements.ToDictionary(); - return (macroName, writer) => - { - if (!dict.TryGetValue(macroName.ToString(), out var action)) - return false; - - action(writer); - return true; - }; - } } diff --git a/src/Core/TextUtility_ReplaceMacros.cs b/src/Core/TextUtility_ReplaceMacros.cs new file mode 100644 index 0000000..464f58f --- /dev/null +++ b/src/Core/TextUtility_ReplaceMacros.cs @@ -0,0 +1,103 @@ +using System.Buffers; +using System.Text; + +namespace OkTools.Core; + +using SimpleReplacer = (string Macro, Action WriteAction); +using SimpleReplacement = (string Macro, string Replacement); + +public static partial class TextUtility +{ + public delegate bool MacroReplacer(StringSegment macroName, TextWriter writer); + + public static StringSegment ReplaceMacros(StringSegment source, SimpleReplacer[] replacers) => + ReplaceMacros(source, CreateMacroReplacer(replacers)); + public static StringSegment ReplaceMacros(StringSegment source, SimpleReplacement[] replacements) => + ReplaceMacros(source, CreateMacroReplacer(replacements)); + public static StringSegment ReplaceMacros(StringSegment source, MacroReplacer replacer) + { + if (source.IndexOf("{{") >= 0) + { + var sb = new StringBuilder(); + if (ReplaceMacros(source, new StringWriter(sb), replacer) != 0) + return sb.ToString(); + } + + return source; + } + + public static int ReplaceMacros(StringSegment source, TextWriter writer, MacroReplacer replacer) + { + var found = 0; + + var offset = 0; + for (;;) + { + // find start of next macro, writing remainder if no more macros + var begin = source.IndexOf("{{"); + if (begin < 0) + { + writer.Write(source.Span); + break; + } + + // write what was before the macro and advance + var oldSpan = source; + writer.Write(source[..begin].Span); + source = source[(begin+2)..]; + offset += begin+2; + + // find end of this macro + var end = source.IndexOf("}}"); + if (end < 0) + throw new FormatException($"Macro starting at offset {offset} and beginning with '{oldSpan.SliceSafe(0, 20)}' was not closed"); + + // collect the macro name and advance + var macro = source[..end]; + source = source[(end+2)..]; + offset += end+2; + + // find replacement matching the macro + if (!replacer(macro, writer)) + throw new FormatException($"Unrecognized macro '{macro}'"); + + ++found; + } + + return found; + } + +# if NETSTANDARD + static readonly char[] k_invalidMacroChars = "{}\t\r\n".AsSpan().ToArray(); + public static bool IsValidMacroName(StringSegment macroName) => + !macroName.IsEmpty && macroName.String.IndexOfAny(k_invalidMacroChars, macroName.SegmentStart, macroName.Length) < 0; +# else + static readonly SearchValues k_invalidMacroChars = SearchValues.Create("{}\t\r\n"); + public static bool IsValidMacroName(StringSegment macroName) => + !macroName.IsEmpty && !macroName.Span.ContainsAny(k_invalidMacroChars); +# endif + + public static MacroReplacer CreateMacroReplacer(IEnumerable replacements) => CreateMacroReplacer( + replacements.Select(r => (r.Macro, r.Replacement)), (writer, replacement) => writer.Write(replacement)); + public static MacroReplacer CreateMacroReplacer(IEnumerable replacers) => CreateMacroReplacer( + replacers.Select(r => (r.Macro, r.WriteAction)), (writer, action) => action(writer)); + + public static MacroReplacer CreateMacroReplacer( + IEnumerable<(string name, TUserData userData)> macros, + Action applyAction) + { + var dict = macros.ToDictionary(); + + if (dict.Keys.Any(k => !IsValidMacroName(k))) + throw new ArgumentException("Invalid macro name"); + + return (macro, writer) => + { + if (!dict.TryGetValue(macro, out var userData)) + return false; + + applyAction(writer, userData); + return true; + }; + } +} diff --git a/src/Core/TextUtility_ReplaceMacros.t.cs b/src/Core/TextUtility_ReplaceMacros.t.cs index 5c1cb65..00a20b8 100644 --- a/src/Core/TextUtility_ReplaceMacros.t.cs +++ b/src/Core/TextUtility_ReplaceMacros.t.cs @@ -1,27 +1,15 @@ partial class TextUtilityTests { - static string Replace(string source, params (string name, Action replacer)[] replacements) - { - var dictReplacer = TextUtility.CreateMacroReplacer(0, replacements); - var arrayReplacer = TextUtility.CreateMacroReplacer(1000, replacements); - - // check the two types of replacers work the same - var dictResult = TextUtility.ReplaceMacros(source, dictReplacer); - var arrayResult = TextUtility.ReplaceMacros(source, arrayReplacer); - arrayResult.ShouldBe(dictResult); - - return dictResult; - } - [TestCase("{{macro}}", "value")] [TestCase("{{spaces are ok}}{{macro}}{{another}}", "yes they arevalue**result**")] [TestCase("xyzzy\n{{macro}}{{macro}}**more", "xyzzy\nvaluevalue**more")] public void ReplaceMacros_Basics(string text, string expected) { - Replace(text, + var result = TextUtility.ReplaceMacros(text, [ ("macro", w => w.Write("value")), ("another", w => w.Write("**result**")), - ("spaces are ok", w => w.Write("yes they are"))) + ("spaces are ok", w => w.Write("yes they are"))]); + result .ShouldBe(expected); } @@ -33,7 +21,7 @@ public void ReplaceMacros_WithIncompleteMacro_Throws(string text) { Should .Throw(() => - Replace(text, ("valid", _ => {}))) + TextUtility.ReplaceMacros(text, [("valid", _ => { })])) .Message.ShouldContain("was not closed"); } @@ -45,7 +33,7 @@ public void ReplaceMacros_WithInvalidMacro_Throws(string text) { Should .Throw(() => - Replace(text, ("valid", _ => {}))) + TextUtility.ReplaceMacros(text, [("valid", _ => { })])) .Message.ShouldContain("Unrecognized macro"); } @@ -55,10 +43,10 @@ public void ReplaceMacros_WithInvalidMacro_Throws(string text) [TestCase(" abc \n foobar ")] public void ReplaceMacros_WithNoMacro_ReturnsSame(string text) { - var replaced = Replace(text); + var replaced = TextUtility.ReplaceMacros(text, [("xyzzy", "jooky")]); replaced.ShouldBe(text); // we should get the exact same string back if no work was done on it - ReferenceEquals(replaced, text).ShouldBeTrue(); + ReferenceEquals(replaced.ToString(), text).ShouldBeTrue(); } } diff --git a/src/Core/WideTypes.cs b/src/Core/WideTypes.cs index f3e2ea5..4c7e8db 100644 --- a/src/Core/WideTypes.cs +++ b/src/Core/WideTypes.cs @@ -1,3 +1,5 @@ +// DO NOT MODIFY, THIS FILE IS GENERATED + namespace OkTools.Core { // the purpose of these types is programmer convenience, not SIMD. if you want SIMD types, diff --git a/src/Core/WideTypes.tt b/src/Core/WideTypes.tt index f3625bd..a7403e2 100644 --- a/src/Core/WideTypes.tt +++ b/src/Core/WideTypes.tt @@ -4,6 +4,8 @@ <#@ import namespace="System" #> <#@ import namespace="System.Collections.Generic" #> <#@ import namespace="System.Linq" #> +// DO NOT MODIFY, THIS FILE IS GENERATED + namespace OkTools.Core { // the purpose of these types is programmer convenience, not SIMD. if you want SIMD types, diff --git a/src/Core/_Global.cs b/src/Core/_Global.cs new file mode 100644 index 0000000..93cac38 --- /dev/null +++ b/src/Core/_Global.cs @@ -0,0 +1,2 @@ +global using OkTools.Core; +global using OkTools.Core.Extensions; diff --git a/src/Terminal/AnsiInput.cs b/src/Terminal/AnsiInput.cs index 33936ca..84c9df8 100644 --- a/src/Terminal/AnsiInput.cs +++ b/src/Terminal/AnsiInput.cs @@ -1,12 +1,12 @@ -using System.Buffers; +namespace OkTools.Terminal; + +using System.Buffers; using System.IO.Pipelines; using System.Runtime.CompilerServices; using System.Threading.Channels; using Vezel.Cathode.IO; using static Vezel.Cathode.Text.Control.ControlConstants; -namespace OkTools.Terminal; - // i can get rid of most of this class once this is implemented: // https://github.com/vezel-dev/cathode/issues/59 "Implement a control sequence parser" @@ -370,13 +370,4 @@ static KeyEvent ParseControlChar(char ch) => _ => default }; } - - static int CountSame(ReadOnlySpan span1, ReadOnlySpan span2) where T : IEquatable - { - var count = 0; - while (count < span1.Length && count < span2.Length && span1[count].Equals(span2[count])) - ++count; - - return count; - } } diff --git a/src/Terminal/CliAppRunner.cs b/src/Terminal/CliAppRunner.cs new file mode 100644 index 0000000..9345b66 --- /dev/null +++ b/src/Terminal/CliAppRunner.cs @@ -0,0 +1,157 @@ +namespace OkTools.Terminal; + +using System.Globalization; +using System.Text; +using DocoptNet; + +using Terminal = Vezel.Cathode.Terminal; +using SimpleMacro = (string macroName, string replacement); +using MacroWriter = (string macroName, Action writeAction); + +public class CliAppConfig +{ + public required string ProgramVersion { get; init; } + public required IReadOnlyList Commands { get; init; } + + public string ProgramName { get; init; } = CliUtility.ProgramName; + public IReadOnlyList? SimpleMacros { get; init; } + public IReadOnlyList? MacroWriters { get; init; } + + public CliCommandSpec? TryGetCommand(string name) => + Commands.FirstOrDefault(c => c.Name == name); + public CliCommandSpec GetCommand(string name) => + TryGetCommand(name) ?? throw new DocoptInputErrorException($"Unknown command: {name}"); +} + +public static class CliAppRunner +{ + // do not want docopt doing any extra special stuff here for help/version, we do it fully ourselves + public static Task Run(CliAppConfig config, IHelpFeaturingParser parser, IReadOnlyList cliArgs) where TArgs : ICliDocoptArgs => + Run(config, parser.DisableHelp(), cliArgs); + public static Task Run(CliAppConfig config, IVersionFeaturingParser parser, IReadOnlyList cliArgs) where TArgs : ICliDocoptArgs => + Run(config, parser.DisableVersion(), cliArgs); + + public static async Task Run(CliAppConfig config, IBaselineParser parser, IReadOnlyList cliArgs) where TArgs : ICliDocoptArgs + { + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; + + var ctx = new CliContext(config); + + PauseLevel pauseLevel = default; + + try + { + // catch this likely-common one + if (cliArgs is ["--help" or "-?"]) + cliArgs = ["help"]; + + // try any non-command variants first + if (parser.Parse(cliArgs) is IArgumentsResult { Arguments.OptVersion: true }) + { + await ctx.Status.WriteLineAsync($"{ctx.Config.ProgramName} {ctx.Config.ProgramVersion}"); + return (int)CliExitCode.Help; + } + + // now do it as if it has a command with preceding options + var args = parser + .WithOptions(ArgsParseOptions.Default.WithOptionsFirst(true)) + .ParseToArguments(cliArgs); + + // do this as early as we can + DocoptUtils.ParseEnumOpt(args.OptPauseLevel, out pauseLevel); + + // now we have enough info to get the context set up + DocoptUtils.ParseEnumOpt(args.OptDebugLevel, out var debugLevel); + DocoptUtils.ParseEnumOpt(args.OptOutputFormat, out var outputFormat); + ctx.InitLogging(debugLevel, outputFormat); + if (ctx.IsVerbose) + { + await ctx.Verbose.WriteLineAsync("Command line: " + Environment.CommandLine); + await ctx.Verbose.WriteObjectAsync(ctx, args, nameof(args)); + await ctx.Verbose.WriteObjectAsync(ctx, ctx, nameof(ctx)); + } + + // get command, defaulting to 'help' if user didn't give one + var commandArgs = args.ArgCommandArgs + .Prepend(args.ArgCommand ?? "help") + .ToList(); + + // rearrange this alternate way of asking for help into standard command form + if (commandArgs.Count > 1 && commandArgs[1] is "--help" or "-?") + { + commandArgs[1] = commandArgs[0]; + commandArgs[0] = "help"; + } + + return (int)await ctx.Config.GetCommand(commandArgs[0]).Exec(ctx, commandArgs); + } + catch (DocoptInputErrorException x) + { + if (cliArgs.Count != 0) + await ctx.Error.WriteLineAsync("bad command line: " + cliArgs.StringJoin(' ')); + + var sb = new StringBuilder(); + if (x.Message.Length != 0) + sb.AppendLf(x.Message); + + var usage = x.TryGetUsage(); + if (usage != null) + sb.AppendLf().AppendLf(usage); + + var message = DocoptUtils.Reflow(ctx.ReplaceMacros(sb.ToString()).ToString(), ctx.WrapWidth ?? 0); + await ctx.Status.WriteAsync(message); + + return (int)CliExitCode.ErrorUsage; + } + catch (Exception x) + { + if (ctx.IsVerbose) + throw; + + switch (x) + { + case DirectoryNotFoundException or FileNotFoundException: + await ctx.Error.WriteLineAsync(x.Message); + return (int)CliExitCode.ErrorNoInput; + + case CliErrorException xc: + await ctx.Error.WriteLineAsync(xc.Message); + await ctx.Error.WriteLineAsync($"exiting with error code {(int)xc.Code}"); + return (int)xc.Code; + } + + ctx.Error.WriteException(x); + + return (int)CliExitCode.ErrorSoftware; + } + finally + { + if (pauseLevel != PauseLevel.Off) + { + // vezel doesn't support this yet + if (!TerminalUtils.IsFullyInteractive) + { + await ctx.Warning.WriteLineAsync("stdin/out is redirected; ignoring `--pause`"); + } + else if (pauseLevel switch + { + PauseLevel.Always => true, + PauseLevel.Errors when ctx.Error.Any => true, + PauseLevel.Warnings when ctx.Error.Any || ctx.Warning.Any => true, + _ => false, + }) + { + if (ctx.Error.Any) + await ctx.Error.WriteAsync("Errors occurred; press any key to exit..."); + else if (ctx.Warning.Any) + await ctx.Error.WriteAsync("Warnings occurred; press any key to exit..."); + else + await ctx.Status.WriteAsync("Press any key to exit..."); + + Terminal.StandardIn.TextReader.Read(); + } + } + } + } +} diff --git a/src/Terminal/CliContext.cs b/src/Terminal/CliContext.cs new file mode 100644 index 0000000..071726c --- /dev/null +++ b/src/Terminal/CliContext.cs @@ -0,0 +1,173 @@ +namespace OkTools.Terminal; + +using Spectre.Console; + +using Terminal = Vezel.Cathode.Terminal; +using SimpleMacro = (string macroName, string replacement); +using MacroWriter = (string macroName, Action writeAction); + +public enum DebugLevel +{ + Off, + Normal, + Extreme, +} + +public enum PauseLevel +{ + Off, + Errors, + Warnings, + Always, +} + +public enum OutputFormat +{ + Humane, + Plain, + Jsonl, +} + +public readonly record struct CliCommandSpec( + string Name, + string ShortHelp, // empty means hidden + string Help, + Func, Task> Exec); + +public class CliContext +{ + // logging + DebugLevel _debugLevel; + OutputFormat _outputFormat; + int? _wrapWidth = 0; + int? _maxWrapWidth; + ILogger? _verboseLogger; + TrackedLogger _warningLogger = null!, _errorLogger = null!; + IStatusLogger _statusLogger = null!, _coloredLogger = null!; + + // config + readonly CliAppConfig _config; + readonly TextUtility.MacroReplacer _macroReplacer; + NPath? _toolDir; + + public CliContext(CliAppConfig config) + { + _config = config; + + try + { + _maxWrapWidth = Terminal.Size.Width; + } + catch (IOException) + { + // this can happen if the console isn't attached (like during early stages of a unit test) + } + + IEnumerable simpleMacros = + [ + ("cliapp.name", _config.ProgramName), + ("cliapp.version", _config.ProgramVersion), + ]; + + IEnumerable macroWriters = + [ + ("commands.help", (_, w) => + { + var minWidth = _config.Commands.Select(s => s.Name.Length).Max(); + w.Write(_config.Commands + .Where(c => c.ShortHelp != "") + .Select(s => $" {s.Name.PadRight(minWidth)} {ReplaceMacros(s.ShortHelp)}") + .StringJoin('\n')); + }) + ]; + + if (_config.SimpleMacros != null) + simpleMacros = simpleMacros.Concat(_config.SimpleMacros); + if (_config.MacroWriters != null) + macroWriters = macroWriters.Concat(_config.MacroWriters); + + // TODO: can i fix up the api so it can infer the types? + _macroReplacer = TextUtility.CreateMacroReplacer(Enumerable.Concat( + simpleMacros.Select)>( + item => (item.macroName, writer => writer.Write(item.replacement))), + macroWriters.Select)>( + item => (item.macroName, writer => item.writeAction(this, writer))))); + + InitLogging(default, default); + } + + public CliAppConfig Config => _config; + public ITrackedLogger Error => _errorLogger; + public ITrackedLogger Warning => _warningLogger; + public IStatusLogger Status => _statusLogger; + public IStatusLogger Colored => _coloredLogger; // use when we want color output even if redirected. this is meant for help commands, where redirection probably means it's being run through a pager like `less`. + public ILogger Verbose => _verboseLogger ?? throw new InvalidOperationException($"Check {nameof(IsVerbose)} before using {nameof(Verbose)}"); + + public DebugLevel DebugLevel => _debugLevel; + public OutputFormat OutputFormat => _outputFormat; + public int? WrapWidth => _wrapWidth; // null means "no wrap" (0 means "default wrap") + public int? MaxWrapWidth => _maxWrapWidth; // maximum possible wrap, null means "unable to detect" (like if noninteractive or no console) + public bool IsVerbose => DebugLevel > DebugLevel.Off; + + public NPath ToolDir => _toolDir ??= AppContext.BaseDirectory.ToNPath(); + + // TODO: this is temporary. implement proper color detection according to https://clig.dev/#output rules on color + // (also see Spectre.Console.AnsiDetector for some windows-specific detection code, consider reusing) + // TODO: if error redirected, modify loggers to prefix every line (\n not just WriteLine) with "Error" or "Warning" + static (bool status, bool warningError) DetectColorEnabled() + { + // override detection for color support because we're deciding this for ourselves + AnsiConsole.Profile.Capabilities.Ansi = true; + + return (Terminal.StandardOut.IsInteractive, Terminal.StandardError.IsInteractive); + } + + public void InitLogging(DebugLevel debugLevel, OutputFormat outputFormat) + { + _debugLevel = debugLevel; + _outputFormat = outputFormat; + + if (_outputFormat != OutputFormat.Humane) + _wrapWidth = null; + + var stdout = Terminal.StandardOut.TextWriter; + var stderr = Terminal.StandardError.TextWriter; + + if (_outputFormat == OutputFormat.Jsonl) + { + _verboseLogger = IsVerbose ? new JsonLogger(stdout, "verbose") : null; + _statusLogger = new JsonLogger(stdout, "status"); + _coloredLogger = new JsonLogger(stdout, "status"); + _warningLogger = new TrackedLogger(new JsonLogger(stderr, "warning")); + _errorLogger = new TrackedLogger(new JsonLogger(stderr, "error")); + } + else if (_outputFormat == OutputFormat.Plain) + { + _verboseLogger = IsVerbose ? new TextWriterLogger(stdout) : null; + _statusLogger = new StatusWriterLogger(stdout); + _coloredLogger = new StatusWriterLogger(stdout); + _warningLogger = new TrackedLogger(new TextWriterLogger(stderr)); + _errorLogger = new TrackedLogger(new TextWriterLogger(stderr)); + } + else + { + var (status, warningError) = DetectColorEnabled(); + + _verboseLogger = IsVerbose + ? new AnsiConsoleLogger(Color.Grey) + : null; + _statusLogger = status + ? new AnsiStatusWriterLogger(stdout) + : new StatusWriterLogger(stdout); + _coloredLogger = new AnsiStatusWriterLogger(stdout); + _warningLogger = new TrackedLogger(warningError + ? new AnsiConsoleLogger(Color.Yellow) + : new TextWriterLogger(stderr)); + _errorLogger = new TrackedLogger(warningError + ? new AnsiConsoleLogger(Color.Red) + : new TextWriterLogger(stderr)); + } + } + + public StringSegment ReplaceMacros(StringSegment text) => TextUtility.ReplaceMacros(text, _macroReplacer); +} diff --git a/src/Terminal/Docopt/DocoptUtils.cs b/src/Terminal/Docopt/DocoptUtils.cs new file mode 100644 index 0000000..3cb1616 --- /dev/null +++ b/src/Terminal/Docopt/DocoptUtils.cs @@ -0,0 +1,93 @@ +namespace OkTools.Terminal; + +using DocoptNet; + +public readonly record struct NamedOpt(string Name, T? Value); + +public static class NamedValue +{ + public static NamedOpt Create(string name, T? value) => new(name, value); +} + +// stay with IBaselineParser; i want to handle versioning and help myself (don't like how docopt.net does it) +public interface ICliDocoptArgs +{ + NamedOpt? OptPauseLevel => null; + NamedOpt? OptDebugLevel => null; + NamedOpt? OptOutputFormat => null; + + bool OptVersion { get; } + string? ArgCommand { get; } + IReadOnlyList ArgCommandArgs { get; } +} + +public static partial class DocoptUtils +{ + // TODO: update docopt codegen to do this as generated attrs or something + public static string OptNameToText(string optPropertyName) + { + if (!optPropertyName.StartsWith("Opt", StringComparison.Ordinal)) + throw new ArgumentException("Expecting option arguments only"); + + if (optPropertyName.Length == 4) + return "-" + char.ToLower(optPropertyName[3]); + + var len = + optPropertyName.Length // string + - 3 // skip Opt + + 2 // room for leading '--' + + optPropertyName.Count(char.IsUpper) - 2; // every capital is preceded by -, except the first and the 'O' in Opt + + Span chars = stackalloc char[len]; + chars[0] = '-'; + chars[1] = '-'; + chars[2] = char.ToLower(optPropertyName[3]); + + for (var (i, o) = (4, 3); i != optPropertyName.Length; ++i) + { + if (char.IsUpper(optPropertyName[i])) + chars[o++] = '-'; + chars[o++] = char.ToLower(optPropertyName[i]); + } + + return new string(chars); + } + + public static bool ParseEnumOpt(string optionName, string? optionValue, out T result, T defaultValue = default) where T : struct, Enum + { + if (optionValue == null) + { + result = defaultValue; + return false; + } + + if (Enum.TryParse(optionValue, true, out result)) + return true; + + throw new DocoptInputErrorException( + $"Illegal `{OptNameToText(optionName)}` type '{optionValue}' "+ + $"(must be one of [{Enum.GetNames().Select(s => s.ToLower()).StringJoin(' ')}])"); + } + + public static T ParseEnumOpt(string optionName, string? optionValue, T defaultValue = default) where T : struct, Enum + { + ParseEnumOpt(optionName, optionValue, out var result, defaultValue); + return result; + } + + public static bool ParseEnumOpt(NamedOpt? option, out T result, T defaultValue = default) where T : struct, Enum + { + if (option == null) + { + result = defaultValue; + return false; + } + return ParseEnumOpt(option.Value.Name, option.Value.Value, out result, defaultValue); + } + + public static T ParseEnumOpt(NamedOpt? option, T defaultValue = default) where T : struct, Enum + { + ParseEnumOpt(option, out var result, defaultValue); + return result; + } +} diff --git a/src/Terminal/Docopt/DocoptUtils_ProcessHelp.cs b/src/Terminal/Docopt/DocoptUtils_ProcessHelp.cs new file mode 100644 index 0000000..4a4c876 --- /dev/null +++ b/src/Terminal/Docopt/DocoptUtils_ProcessHelp.cs @@ -0,0 +1,155 @@ +namespace OkTools.Terminal; + +using Spectre.Console; + +public static partial class DocoptUtils +{ + public class FullHelpOptions + { + public required bool ShowFullHelp; + public Func? FullHelpAvailableMessage; + /* + * if (available) + var extra = helpArgs.ArgCommand != null ? $" {helpArgs.ArgCommand}": ""; + return $"Full help is available via `{{cliapp.name}} help -f{extra}`" + else + var extra = nameOfFullHelpOption != null ? $"; {nameOfFullHelpOption} flag ignored" : null; + return $"\n\n(This command does not have extra help{extra}.)"; + */ + } + + public static void DisplayHelp(CliContext cliContext, string helpText, FullHelpOptions? fullHelpOptions = null) + { + var helpLines = cliContext + .ReplaceMacros(helpText) + .SelectLinesAsSegments(); + + if (fullHelpOptions != null) + helpLines = FilterHelp(helpLines, fullHelpOptions); + + if (cliContext.WrapWidth != null) + { + var wrapWidth = cliContext.WrapWidth == 0 ? DocoptReflowOptions.DefaultWrapWidth : cliContext.WrapWidth.Value; + if (cliContext.MaxWrapWidth != null) + wrapWidth.MinimizeWith(cliContext.MaxWrapWidth.Value); + + helpLines = Reflow(helpLines, wrapWidth); + } + + if (cliContext.Colored.SupportsColor) + { + var helpArray = helpLines.ToArray(); + foreach (var line in Colorize(helpArray, helpArray.Length > 15)) + cliContext.Colored.MarkupLine(line); + } + else + { + foreach (var line in helpLines) + cliContext.Status.WriteLine(line.ToString()); + } + } + + static IEnumerable FilterHelp(IEnumerable help, FullHelpOptions options) + { + var insideFullHelpSection = false; + var includedFullHelp = false; + + var filtered = help + .Select(line => line.TrimEnd()) // catch accidental whitespace at end of docopt help text lines + .SelectWithPositions() + .Where(line => + { + // full-help section begin marker + if (line.item == " <") + { + if (insideFullHelpSection) + throw new InvalidOperationException("Full help section already open"); + insideFullHelpSection = true; + includedFullHelp = true; + return false; + } + + // full-help section end marker + if (line.item == " >") + { + if (!insideFullHelpSection) + throw new InvalidOperationException("Full help section not open"); + insideFullHelpSection = false; + return false; + } + + if (line.isLast && insideFullHelpSection) + throw new InvalidOperationException("Full help section was never closed"); + + return !insideFullHelpSection || options.ShowFullHelp; + }) + .SelectItem(); + + return (options.FullHelpAvailableMessage != null, hadFull: includedFullHelp, options.ShowFullHelp) switch + { + (true, true, false) => filtered.Concat([default, new(options.FullHelpAvailableMessage!(true))]), + (true, false, true) => filtered.Concat([default, new(options.FullHelpAvailableMessage!(false))]), + _ => filtered + }; + } + + static IEnumerable Colorize(IEnumerable helpLines, bool isBigHelp) + { + var lastLine = new StringSegment(); + var codeOpen = false; + + foreach (var helpLine in helpLines) + { + // heading + if (!helpLine.StartsWith(' ') && !helpLine.StartsWith("Full help", StringComparison.Ordinal)) + { + yield return helpLine + .ToString().EscapeMarkup() + .RegexReplace("(.*):(.*)", "[underline blue]$1[/]: $2"); + } + // ordinary text + else + { + // docopt parser needs no blank line after these, but i like it better more spread out. + // but only if it's "big help"! + if (!lastLine.StartsWith(' ') && !lastLine.IsEmpty && isBigHelp) + yield return ""; + + var parts = helpLine.ToString().Split('`'); + var startCodeOpen = codeOpen; + for (var i = 0; i < parts.Length; ++i) + { + if (i % 2 != 0 == startCodeOpen) + { + parts[i] = parts[i] + .EscapeMarkup() + .RegexReplace(// -x and --xyz style options + //@"([^a-z]|^)(--[a-z][a-z*-]*|-[a-z])([^a-z]|$)", + @"(? Reflow(text, new DocoptReflowOptions { DesiredWrapWidth = wrapWidth}); - public static string Reflow(string text) => Reflow(text, new DocoptReflowOptions()); + public static string Reflow(string text, int wrapWidth = 0) => + Reflow(text, new DocoptReflowOptions { DesiredWrapWidth = wrapWidth }); + public static string Reflow(string text, DocoptReflowOptions options) => + Reflow(text.SelectLinesAsSegments(), options).StringJoin('\n'); + + public static IEnumerable Reflow(IEnumerable lines, int wrapWidth = 0) => + Reflow(lines, new DocoptReflowOptions { DesiredWrapWidth = wrapWidth }); - public static string Reflow(string text, DocoptReflowOptions options) + public static IEnumerable Reflow(IEnumerable lines, DocoptReflowOptions options) { // TODO: support a line break marker, such as a backslash at the end of a line. This would tell reflow not to join // that line with the next. @@ -47,26 +46,38 @@ public static string Reflow(string text, DocoptReflowOptions options) if (options.MinWrapWidth < 0 || options.MinWrapWidth >= wrapWidth) throw new ArgumentOutOfRangeException($"{nameof(options.MinWrapWidth)} out of range 0 <= {options.MinWrapWidth} < {wrapWidth}"); - var result = new StringBuilder(); + // TODO: avoid building into stringbuilder if possible and try to just return the segment if unmodified (will be true most of the time for typical case) + var line = new StringBuilder(); + StringSegment Eol(int newIndent = 0) + { + var text = line.ToString(); + + line.Clear(); + + if (newIndent != 0) + line.Append(' ', newIndent); + + return new(text); + } var needEol = false; - foreach (var section in SelectSections(text).ToArray()) // ToArray is to force _extraIndent to work (needs to modify the prev) + foreach (var section in SelectSections(lines).ToArray()) // ToArray is to force _extraIndent to work (needs to modify the prev) { if (needEol) { - result.Append(options.Eol); needEol = false; + yield return Eol(); } if (!section.Text.Any) { - result.Append(options.Eol); + yield return Eol(); continue; } // do the indent here so its whitespace doesn't get caught up in the calculations below // (TODO: this breaks very narrow wrapping..) - result.Append(section.Text[..section.TotalIndent].Span); + line.Append(section.Text[..section.TotalIndent].Span); var sectionText = section.Text[section.TotalIndent..]; // special: if we have a really wide column indent, let's fall back to non aligned @@ -75,8 +86,7 @@ public static string Reflow(string text, DocoptReflowOptions options) if (options.IndentFallback != 0 && wrapWidth - indent < options.IndentFallback) { indent = section.Text.GetTrimStart() + 1; - result.Append(options.Eol); - result.Append(' ', indent); + yield return Eol(indent); } for (;;) @@ -96,33 +106,59 @@ public static string Reflow(string text, DocoptReflowOptions options) } // write what will fit and advance - result.Append(sectionText[..write].TrimEnd()); + line.Append(sectionText[..write].TrimEnd().Span); sectionText = sectionText[write..].TrimStart(); if (!sectionText.Any) break; - result.Append(options.Eol); - result.Append(' ', indent); + yield return Eol(indent); } needEol = true; } - return result.ToString(); + yield return Eol(); + } + + static IEnumerable
SelectSections(IEnumerable lines) + { + Section? lastSection = null; + + foreach (var line in lines) + { + var newSection = new Section(line); + + if (lastSection != null) + { + var merged = lastSection.MergeWith(newSection); + if (merged != null) + lastSection = merged; + else + { + yield return lastSection; + lastSection = newSection; + } + } + else + lastSection = newSection; + } + + if (lastSection != null) + yield return lastSection; } - record Section(StringSegment Text, int Indent) + partial record Section(StringSegment Text, int Indent) { int _extraIndent; public Section(StringSegment text) : this(text.TrimEnd(), 0) { - var indentMatch = Text.Match(k_indentRx0); + var indentMatch = Text.Match(IndentRx0()); if (indentMatch.Success) Indent = indentMatch.Index - Text.SegmentStart + indentMatch.Length; else { - indentMatch = Text.Match(k_indentRx1); + indentMatch = Text.Match(IndentRx1()); if (indentMatch.Success) Indent = indentMatch.Index - Text.SegmentStart + indentMatch.Length; else @@ -130,6 +166,15 @@ public Section(StringSegment text) : this(text.TrimEnd(), 0) } } + // these regexes find where we should indent to upon wrapping, in priority order. + // + // - align to the right side of a "docopt divider" (>= 2 spaces). higher pri to catch bulleted option lists. + [GeneratedRegex(@"\S {2,}")] + private static partial Regex IndentRx0(); + // - align to the text part of a bullet point, number, or comment: * - // # 1. + [GeneratedRegex(@"^ *([-*#]|//|\d+\.) ")] + private static partial Regex IndentRx1(); + public int TotalIndent => Indent + _extraIndent; bool HasPrefix => Text.GetTrimStart() < Indent; // TODO: "extra indent" and "prefix" concepts do the same thing; join them @@ -147,8 +192,8 @@ public Section(StringSegment text) : this(text.TrimEnd(), 0) { var wordLen = end + 1; // include the space var text0 = Text[Indent..(Indent+wordLen)]; - var text1 = other.Text[Indent..(Indent+wordLen)]; - if (text0.StringEquals(text1)) + var text1 = other.Text.SliceSafe(Indent, wordLen); + if (text0.Equals(text1)) { _extraIndent = other._extraIndent = wordLen; return null; @@ -163,55 +208,4 @@ public Section(StringSegment text) : this(text.TrimEnd(), 0) public override string ToString() => $"{Text.ToDebugString()}; indent={Indent}, prefix={HasPrefix}"; } - - // these regexes find where we should indent to upon wrapping, in priority order. - // - // - align to the right side of a "docopt divider" (>= 2 spaces). higher pri to catch bulleted option lists. - static readonly Regex k_indentRx0 = new(@"\S {2,}"); - // - align to the text part of a bullet point, number, or comment: * - // # 1. - static readonly Regex k_indentRx1 = new(@"^ *([-*#]|//|\d+\.) "); - - static IEnumerable SelectLines(string text) - { - for (var start = 0; start != text.Length;) - { - var end = text.IndexOf('\n', start); - - var next = end; - if (end < 0) - end = next = text.Length; - else - ++next; - - yield return new StringSegment(text, start, end - start); - start = next; - } - } - - static IEnumerable
SelectSections(string text) - { - Section? lastSection = null; - - foreach (var line in SelectLines(text)) - { - var newSection = new Section(line); - - if (lastSection != null) - { - var merged = lastSection.MergeWith(newSection); - if (merged != null) - lastSection = merged; - else - { - yield return lastSection; - lastSection = newSection; - } - } - else - lastSection = newSection; - } - - if (lastSection != null) - yield return lastSection; - } } diff --git a/src/Core/DocoptUtility.t.cs b/src/Terminal/DocoptUtils_Reflow.t.cs similarity index 97% rename from src/Core/DocoptUtility.t.cs rename to src/Terminal/DocoptUtils_Reflow.t.cs index 49757ab..8348479 100644 --- a/src/Core/DocoptUtility.t.cs +++ b/src/Terminal/DocoptUtils_Reflow.t.cs @@ -1,13 +1,14 @@ -class DocoptUtilityTests +using OkTools.Terminal; + +class DocoptUtilsReflowTests { - static string Reflow(string text, int width, int minWrapWidth = 0, string eol = "\n") => - DocoptUtility.Reflow(text, + static string Reflow(string text, int width, int minWrapWidth = 0) => + DocoptUtils.Reflow(text, new DocoptReflowOptions { DesiredWrapWidth = width, MinWrapWidth = minWrapWidth, IndentFallback = 0, - Eol = eol, }); [Test] @@ -280,9 +281,6 @@ public void Reflow_Eols() Reflow( "some text\nother text\r\nstill more", 10).ShouldBe( "some text\nother text\nstill more"); - Reflow( - "some text\nother text\r\nstill more", 10, eol: "\r\n").ShouldBe( - "some text\r\nother text\r\nstill more"); } [Test] diff --git a/src/Terminal/Extensions/ControlBuilderExtensions.cs b/src/Terminal/Extensions/CathodeExtensions.cs similarity index 91% rename from src/Terminal/Extensions/ControlBuilderExtensions.cs rename to src/Terminal/Extensions/CathodeExtensions.cs index 849e53b..83a7a1d 100644 --- a/src/Terminal/Extensions/ControlBuilderExtensions.cs +++ b/src/Terminal/Extensions/CathodeExtensions.cs @@ -1,8 +1,18 @@ -using System.Text; +namespace OkTools.Terminal.Extensions; + +using System.Text; using Vezel.Cathode.Text.Control; using static Vezel.Cathode.Text.Control.ControlConstants; +using Terminal = Vezel.Cathode.Terminal; -namespace OkTools.Terminal.Extensions; +[PublicAPI] +public static class TerminalUtils +{ + public static bool IsFullyInteractive => + Terminal.StandardIn.IsInteractive && + Terminal.StandardOut.IsInteractive && + Terminal.StandardError.IsInteractive; +} [PublicAPI] public static class ControlBuilderExtensions diff --git a/src/Terminal/Extensions/DocoptExtensions.cs b/src/Terminal/Extensions/DocoptExtensions.cs index 014552f..b181389 100644 --- a/src/Terminal/Extensions/DocoptExtensions.cs +++ b/src/Terminal/Extensions/DocoptExtensions.cs @@ -2,11 +2,30 @@ namespace OkTools.Terminal.Extensions; -// ReSharper disable MethodHasAsyncOverload - [PublicAPI] public static class DocoptExtensions { + public static string? TryGetUsage(this DocoptInputErrorException @this) + { + if (@this.Data.Contains("Usage") && @this.Data["Usage"] is string usage && usage != "") + return usage; + + return null; + } + + public static TArgs ParseToArguments(this IBaselineParser @this, IReadOnlyList args) => + @this.Parse(args) switch + { + IArgumentsResult argsResult => + argsResult.Arguments, + IInputErrorResult errorResult => + throw new DocoptInputErrorException(errorResult.Error).WithData("Usage", errorResult.Usage), + var unknownResult => + throw new InvalidOperationException("Unknown result from parser: " + unknownResult.GetType().FullName) + }; + + public static bool Any(this StringList @this) => @this.Count != 0; + class InputErrorResult : IInputErrorResult { public InputErrorResult(string error) { Error = error; } @@ -19,15 +38,16 @@ public static (CliExitCode? code, T parsed) Parse(this IHelpFeaturingParser args, string programVersion, string help, string usage, Func? postParse = null, - TextUtility.Replacer? macroReplacer = null, + TextUtility.MacroReplacer? macroReplacer = null, TextWriter? outWriter = null, TextWriter? errWriter = null, int? wrapWidth = null) { - // vezel warns about this, but if using vezel we should set outWriter/errWriter + outWriter ??= Vezel.Cathode.Terminal.StandardOut.TextWriter; + errWriter ??= Vezel.Cathode.Terminal.StandardError.TextWriter; + + // figure out how to do this with cathode # pragma warning disable RS0030 - outWriter ??= Console.Out; - errWriter ??= Console.Error; wrapWidth ??= !Console.IsOutputRedirected ? Console.WindowWidth : 0; # pragma warning restore RS0030 @@ -63,13 +83,13 @@ public static (CliExitCode? code, T parsed) Parse(this IHelpFeaturingParser(this IHelpFeaturingParser(this IHelpFeaturingParser + StringSegment FormatHelp(string helpText, string version) => macroReplacer != null ? TextUtility.ReplaceMacros(helpText, macroReplacer) : string.Format(helpText, CliUtility.ProgramName, version); @@ -116,4 +136,5 @@ string FormatHelp(string helpText, string version) => return rc; } + } diff --git a/src/Terminal/Logging/Loggers.cs b/src/Terminal/Logging/Loggers.cs new file mode 100644 index 0000000..a78938a --- /dev/null +++ b/src/Terminal/Logging/Loggers.cs @@ -0,0 +1,129 @@ +using System.Reflection; +using Spectre.Console; + +namespace OkTools.Terminal; + +// general note on newline: always use \n rather than Environment.NewLine, because \r\n +// sucks. library funcs often use Environment.NewLine so avoid their "WriteLine" calls too. + +public interface ILogger +{ + void Write(string text); + void WriteLine(string text = ""); + + Task WriteAsync(string text); + Task WriteLineAsync(string text = ""); + + void WriteException(Exception x) => + WriteLine(x.ToString()); + + string ObjectToString(CliContext ctx, object item, string name, Func? filter = null) => + item.ToDumpString( + name: name, + quiet: ctx.DebugLevel <= DebugLevel.Normal, + filter: filter, + wrapWidth: ctx.WrapWidth, + maxWrapLines: ctx.DebugLevel > DebugLevel.Normal ? 0 : 5); + + void WriteObject(CliContext ctx, object item, string name, Func? filter = null) => + WriteLine(ObjectToString(ctx, item, name, filter)); + + Task WriteObjectAsync(CliContext ctx, object item, string name, Func? filter = null) => + WriteLineAsync(ObjectToString(ctx, item, name, filter)); +} + +public interface ITrackedLogger : ILogger +{ + int LinesWritten { get; } + bool Any => LinesWritten > 0; +} + +public class TrackedLogger(ILogger logger) : ITrackedLogger +{ + public int LinesWritten { get; private set; } + + public void Write(string text) + { + LinesWritten += text.Count(c => c == '\n'); + logger.Write(text); + } + + public void WriteLine(string text = "") + { + LinesWritten += text.Count(c => c == '\n') + 1; + logger.WriteLine(text); + } + + public Task WriteAsync(string text) + { + LinesWritten += text.Count(c => c == '\n'); + return logger.WriteAsync(text); + } + + public Task WriteLineAsync(string text = "") + { + LinesWritten += text.Count(c => c == '\n') + 1; + return logger.WriteLineAsync(text); + } +} + +public class TextWriterLogger : ILogger +{ + protected readonly TextWriter Writer; + + public TextWriterLogger(TextWriter writer) => Writer = writer; + + public void Write(string text) => Writer.Write(text); + public Task WriteAsync(string text) => Writer.WriteAsync(text); + + // even though TextWriter's own internal impl does two writes (one for text, one for \n), we do it here as a single + // string to reduce the chance multithreaded writes will interleave newlines. don't like the alloc but /shrug need + // to upgrade the interface to support spans and begin/end synchronization or whatever etc. + public void WriteLine(string text = "") => Write(text + '\n'); + public Task WriteLineAsync(string text = "") => WriteAsync(text + '\n'); +} + +public sealed class AnsiConsoleLogger(Color color) : ILogger +{ + public void Write(string text) + { + if (text == "") + return; + + using var _ = new SkipSpectreWordWrap(); + + AnsiConsole.Foreground = color; + //AnsiConsole.Write(text); + // bug workaround: https://github.com/spectreconsole/spectre.console/issues/1387 + AnsiConsole.Write("{0}", text); + AnsiConsole.ResetColors(); + } + + public void WriteLine(string text) + { + using var _ = new SkipSpectreWordWrap(); + + if (text == "") + { + AnsiConsole.Write("\n"); + return; + } + + AnsiConsole.Foreground = color; + //AnsiConsole.Write(text + "\n"); // string concat matches internal WriteLine impl + // bug workaround: https://github.com/spectreconsole/spectre.console/issues/1387 + AnsiConsole.Write("{0}\n", text); + AnsiConsole.ResetColors(); + } + + // AnsiConsole doesn't support async (yet?) + public Task WriteAsync(string text) + { Write(text); return Task.CompletedTask; } + public Task WriteLineAsync(string text = "") + { WriteLine(text); return Task.CompletedTask; } + + public void WriteException(Exception x) => + AnsiConsole.WriteException(x, ExceptionFormats.ShortenEverything | ExceptionFormats.ShowLinks); + + // TODO: nice formatted WriteObject (table etc. but single color to match ctor color) +} diff --git a/src/Terminal/Logging/Loggers_Status.cs b/src/Terminal/Logging/Loggers_Status.cs new file mode 100644 index 0000000..c4cd161 --- /dev/null +++ b/src/Terminal/Logging/Loggers_Status.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using Spectre.Console; + +namespace OkTools.Terminal; + +public interface IStatusLogger : ILogger +{ + // TODO: would be nice to have DryRunLine break lines at the wrap margin and put a [dry-run] on each. (requires context access.) + + bool SupportsColor => false; + void DryRunLine(string text); + void Markup(string text); + void MarkupLine(string text); + + Task DryRunLineAsync(string text); + Task MarkupAsync(string text); + Task MarkupLineAsync(string text); + + void WriteOrDryRunLine(bool dowit, string text) + { + if (dowit) + WriteLine(text); + else + DryRunLine(text); + } + + Task WriteOrDryRunLineAsync(bool dowit, string text) => + dowit ? WriteLineAsync(text) : DryRunLineAsync(text); +} + +public sealed class AnsiStatusWriterLogger(TextWriter writer) : TextWriterLogger(writer), IStatusLogger +{ + public bool SupportsColor => true; + + public void DryRunLine(string text) + { + using var _ = new SkipSpectreWordWrap(); + AnsiConsole.Markup("[grey italic][[dry-run]][/] " + text.EscapeMarkup() + '\n'); // TODO: MarkupInterpolated? + } + + public void Markup(string text) + { + using var _ = new SkipSpectreWordWrap(); + AnsiConsole.Markup(text); + } + + public void MarkupLine(string text) + { + using var _ = new SkipSpectreWordWrap(); + AnsiConsole.Markup(text + '\n'); // string concat matches internal MarkupLine impl + } + + // AnsiConsole doesn't support async (yet?) + public Task DryRunLineAsync(string text) + { DryRunLine(text); return Task.CompletedTask; } + public Task MarkupAsync(string text) + { Markup(text); return Task.CompletedTask; } + public Task MarkupLineAsync(string text) + { MarkupLine(text); return Task.CompletedTask; } +} + +public sealed class StatusWriterLogger : TextWriterLogger, IStatusLogger +{ + public StatusWriterLogger(TextWriter writer) : base(writer) {} + + public void DryRunLine(string text) => + Writer.WriteLine($"[dry-run] {text}"); + public void Markup(string text) => + Writer.Write(text.RemoveMarkup()); + public void MarkupLine(string text) => + Writer.WriteLine(text.RemoveMarkup()); + + public Task DryRunLineAsync(string text) => + Writer.WriteLineAsync($"[dry-run] {text}"); + public Task MarkupAsync(string text) => + Writer.WriteAsync(text.RemoveMarkup()); + public Task MarkupLineAsync(string text) => + Writer.WriteLineAsync(text.RemoveMarkup()); +} + +public sealed class JsonLogger(TextWriter writer, string stream) : IStatusLogger +{ + // TODO: this is copy pasta, switch it to nicer """ single template thing + string Begin() => + $"{{\"timestamp\":\"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}\", \"stream\":\"{stream}\","; + string Middle(string text) => + $"\"text\":\"{JsonEncodedText.Encode(text)}\""; + string End() => + "}\n"; + + public void Write(string text) + { + writer.Write(Begin()); + writer.Write(Middle(text)); + writer.Write(End()); + } + + public void WriteLine(string text) => + Write(text + "\n"); + + public async Task WriteAsync(string text) + { + await writer.WriteAsync(Begin()); + await writer.WriteAsync(Middle(text)); + await writer.WriteAsync(End()); + } + + public Task WriteLineAsync(string text) => + WriteAsync(text + "\n"); + + // TODO: write exception as json object + //public void WriteException(Exception x) + // TODO: serialize object to json + //public void WriteObject(CliContextCliApp ctx, object item, string name, Func? filter = null) + + // TODO: nicer to use a json property for dry-run-ness + static string WrapDryRun(string text) => "[dry-run] " + text + '\n'; + + public void DryRunLine(string text) => + Write(WrapDryRun(text)); + public void Markup(string text) => + Write(text.RemoveMarkup()); + public void MarkupLine(string text) => + WriteLine(text.RemoveMarkup()); + + public Task DryRunLineAsync(string text) => + WriteAsync(WrapDryRun(text)); + public Task MarkupAsync(string text) => + WriteAsync(text.RemoveMarkup()); + public Task MarkupLineAsync(string text) => + WriteLineAsync(text.RemoveMarkup()); +} diff --git a/src/Terminal/Logging/SkipSpectreWordWrap.cs b/src/Terminal/Logging/SkipSpectreWordWrap.cs new file mode 100644 index 0000000..a041a82 --- /dev/null +++ b/src/Terminal/Logging/SkipSpectreWordWrap.cs @@ -0,0 +1,34 @@ +using Spectre.Console; + +namespace OkTools.Terminal; + +// this works around Spectre apparent lack of ability to pass through "single line" option for rendering. word wrapping +// will break the ability to copy-paste long lines from console. specific example is when i print out a full cli that +// is intended to copy-paste into a debugger. wrapping will require multiple copy-pastes. +// +// see https://github.com/spectreconsole/spectre.console/discussions/471 +// + +readonly struct SkipSpectreWordWrap : IDisposable +{ + static bool s_inProgress; + readonly int _oldWidth = AnsiConsole.Profile.Width; + + public SkipSpectreWordWrap() + { + if (s_inProgress) + throw new InvalidOperationException("nested SkipSpectreWordWrap"); + s_inProgress = true; + + AnsiConsole.Profile.Width = 100000; + } + + public void Dispose() + { + if (!s_inProgress) + throw new InvalidOperationException("unexpected SkipSpectreWordWrap lifetime"); + + AnsiConsole.Profile.Width = _oldWidth; + s_inProgress = false; + } +} diff --git a/src/Terminal/_Global.cs b/src/Terminal/_Global.cs new file mode 100644 index 0000000..97c356b --- /dev/null +++ b/src/Terminal/_Global.cs @@ -0,0 +1 @@ +global using OkTools.Terminal.Extensions; diff --git a/src/Unity.Cli/Program.cs b/src/Unity.Cli/Program.cs index 9a706be..985a6ec 100644 --- a/src/Unity.Cli/Program.cs +++ b/src/Unity.Cli/Program.cs @@ -1,5 +1,6 @@ using DocoptNet; using DotNetConfig; +using OkTools.Terminal; public static class Program { @@ -108,12 +109,12 @@ IDictionary ParseOpt(string usage) } catch (DocoptExitException x) { - Console.WriteLine(DocoptUtility.Reflow(x.Message, Console.WindowWidth)); + Console.WriteLine(DocoptUtils.Reflow(x.Message, Console.WindowWidth)); return (int)CliExitCode.Help; } catch (CliErrorException x) { - Console.Error.Write(DocoptUtility.Reflow(x.Message, Console.WindowWidth)); + Console.Error.Write(DocoptUtils.Reflow(x.Message, Console.WindowWidth)); if (!x.Message.EndsWith('\n')) Console.Error.WriteLine(); return (int)x.Code; diff --git a/src/_Include/TestUtils.cs b/src/_Include/TestUtils.cs index 33287b9..dcef624 100644 --- a/src/_Include/TestUtils.cs +++ b/src/_Include/TestUtils.cs @@ -7,7 +7,7 @@ { public DirectoryBackup(string folderPath) { - _backupPath = Path.GetTempPath().ToNPath().Combine(Environment.ProcessId.ToString()); + _backupPath = NPath.SystemTempDirectory.Combine(Environment.ProcessId.ToString()); _fullPath = folderPath.ToNPath().MakeAbsolute(); Directory.CreateDirectory(_backupPath.ToString()!); diff --git a/targets/Library.targets b/targets/Library.targets index cc35a84..3fe745f 100644 --- a/targets/Library.targets +++ b/targets/Library.targets @@ -140,6 +140,7 @@ +