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 @@
+