Other helpers

edit

Deterministic GUIDs

DeterministicGuid.Create computes a name-based GUID (RFC 4122 version 3 or 5): the same namespace and name always give the same GUID, on every machine. Use it to derive a stable identifier from a natural key, such as a URL or a file path:

// Generate a GUID from a DNS name
var guid = DeterministicGuid.Create(DeterministicGuid.DnsNamespace, "example.com", DeterministicGuidVersion.Version5);
Assert.Equal(Guid.Parse("cfbff0d1-9375-5685-968c-48ce8b15ae17"), guid);

// The same input will always generate the same GUID
var guid2 = DeterministicGuid.Create(DeterministicGuid.DnsNamespace, "example.com", DeterministicGuidVersion.Version5);
Assert.Equal(guid, guid2);

Optional values

Optional<T> tells a missing value from a value that is null, for instance for the fields of a partial update:

var notSet = new Optional<string?>();
var setToNull = new Optional<string?>(null);

// Unlike null, Optional tells a missing value from a value that is null
Assert.False(notSet.HasValue);
Assert.True(setToNull.HasValue);
Assert.NotEqual(notSet, setToNull);

var (hasValue, value) = setToNull;
Assert.True(hasValue);
Assert.Null(value);

new Optional<T>() and default have no value; new Optional<T>(value) has one, even when it is null. Two optional values are equal when neither has a value, or when both have equal values. ToString returns an empty string when there is no value.

Placeholder text

LoremIpsumGenerator generates random "lorem ipsum" text, for mock-ups and test data:

var sentence = LoremIpsumGenerator.Sentence(wordCount: 5);
Assert.HasCount(5, sentence.Split(' '));
Assert.EndsWith(".", sentence);

// 3 sentences of 8 words
var paragraph = LoremIpsumGenerator.Paragraph(wordCount: 8, sentenceCount: 3);
Assert.HasCount(24, paragraph.Split(' '));

var paragraphs = LoremIpsumGenerator.Paragraphs(wordsPerSentence: 8, sentencesPerParagraph: 3, paragraphCount: 2);
Assert.HasCount(2, paragraphs);

A sentence starts with an upper-case letter and ends with a period. A paragraph is sentences separated by spaces, and Paragraphs returns a lazy sequence of paragraphs. The words are random, so the text changes on every call.

Safe handles in native calls

CreateHandleScope gives the raw handle of a SafeHandle and keeps the SafeHandle from being released until the scope is disposed, so the handle stays valid while native code uses it:

using var safeHandle = File.OpenHandle(Path.GetTempFileName(), FileMode.Open, FileAccess.Read, FileShare.None, FileOptions.DeleteOnClose);

// The SafeHandle cannot be released while the scope is alive
using var scope = safeHandle.CreateHandleScope();
nint handle = scope.Value;
Assert.NotEqual(0, handle); // Use handle safely, for instance in a P/Invoke call

SafeHandleValue converts implicitly to nint. HasValue is true until the scope is disposed; Value throws an ObjectDisposedException afterwards. Creating a scope for an invalid handle throws an ArgumentException.

Tuples

ToArray copies the elements of a tuple to an object?[]:

var tuple = (1, "hello", true);
object?[] array = tuple.ToArray();
Assert.Equal([1, "hello", true], array);

URIs

Assert.True(new Uri("https://www.example.com/").IsHttpOrHttps());
Assert.False(new Uri("ftp://www.example.com/").IsHttpOrHttps());

// A relative URI has no scheme
Assert.False(new Uri("images/logo.png", UriKind.Relative).IsHttpOrHttps());

IsHttpOrHttps returns true for an absolute URI whose scheme is http or https, in any case.

Certificate dates

X509Certificate2.NotBefore and NotAfter are local times. The NotBeforeUtc and NotAfterUtc extension properties return them as DateTimeOffset values in UTC, which compare the same way on every machine:

using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using var certificate = request.CreateSelfSigned(
    new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
    new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero));

// NotBefore and NotAfter are local times; NotBeforeUtc and NotAfterUtc do not depend on the time zone
Assert.Equal(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), certificate.NotBeforeUtc);
Assert.Equal(new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero), certificate.NotAfterUtc);

UTF-8 without a byte order mark

Encoding.UTF8 writes a byte order mark at the start of files and streams. Encoding.UTF8WithoutPreamble, a static extension property of Encoding, returns a shared UTF-8 encoding that does not:

var path = Path.GetTempFileName();

// Encoding.UTF8 would write the byte order mark (EF BB BF) before the text
File.WriteAllText(path, "hello", Encoding.UTF8WithoutPreamble);
Assert.Equal("hello"u8.ToArray(), File.ReadAllBytes(path));

File.Delete(path);

Environment variables

Environment.GetRequiredEnvironmentVariable and Environment.GetEnvironmentVariableOrDefault are static extension methods of Environment:

// Static extension members of Environment
var logLevel = Environment.GetEnvironmentVariableOrDefault("SAMPLE_APP_LOG_LEVEL", "Information");
Assert.Equal("Information", logLevel);

// Throws an InvalidOperationException that names the missing variable
Assert.Throws<InvalidOperationException>(() => Environment.GetRequiredEnvironmentVariable("SAMPLE_APP_API_KEY"));

Exceptions with their inner exceptions

exception.ToString(includeInnerException: true) returns exception.ToString(), which already includes the inner exceptions (all of them for an AggregateException). With false, it returns only the type, the message and the stack trace of the exception, without its inner exceptions.

Hash codes of collections

AddValues adds every element of an array, a span or an enumerable to a HashCode, in order, optionally with an equality comparer. Use it in the GetHashCode of a type that holds a collection:

var first = new HashCode();
first.Add("order", StringComparer.Ordinal);
first.AddValues(new[] { 1, 2, 3 });

var second = new HashCode();
second.Add("order", StringComparer.Ordinal);
second.AddValues(new List<int> { 1, 2, 3 });

// Each value is added in order, so equal sequences give equal hash codes
Assert.Equal(first.ToHashCode(), second.ToHashCode());

A null array adds 0, so it does not hash like an empty array.

Asynchronous regex replacement

ReplaceAsync replaces the matches of a regular expression with the result of an asynchronous function, for replacements that need I/O:

var regex = new Regex(@"\{(?<name>\w+)\}", RegexOptions.None, TimeSpan.FromSeconds(1));

var result = await regex.ReplaceAsync("Hello {name}, you have {count} messages", async match =>
{
    await Task.Yield(); // For instance, read the value from a database
    return match.Groups["name"].Value switch
    {
        "name" => "Ada",
        "count" => "3",
        _ => match.Value,
    };
});

Assert.Equal("Hello Ada, you have 3 messages", result);

The function is called for each match, one after the other: the next match waits for the previous replacement. The matches are processed in the order the regex finds them, like Regex.Replace: from right to left when the regex uses RegexOptions.RightToLeft.

Like Regex.Replace, overloads limit the number of replacements (count, -1 for all of them) and set the position where the search starts (startat). They also take a CancellationToken, checked before each match. The overloads whose function returns a ValueTask<string> pass the token to the function:

var regex = new Regex(@"[0-9]+", RegexOptions.None, TimeSpan.FromSeconds(1));
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1));

// Replace the first 2 matches, searching from the 2nd character. The callback receives the token.
var result = await regex.ReplaceAsync("1 22 333 4444", async (match, cancellationToken) =>
{
    await Task.Delay(1, cancellationToken); // For instance, call a service
    return $"<{match.Value}>";
}, count: 2, startat: 1, cts.Token);

Assert.Equal("1 <22> <333> 4444", result);

Runes of UTF-8 bytes

EnumerateRunesFromUtf8, in the Meziantou.Framework.Text namespace, decodes UTF-8 bytes into Rune values without converting them to a string first:

ReadOnlySpan<byte> utf8 = [0x41, 0xC3, 0xA9, 0xFF, 0x42]; // A, then U+00E9 on 2 bytes, an invalid byte, B

var runes = new List<Rune>();
foreach (var rune in utf8.EnumerateRunesFromUtf8())
{
    runes.Add(rune);
}

// Invalid bytes are returned as the replacement character, and the enumeration continues
Assert.Equal([new Rune('A'), new Rune(0xE9), Rune.ReplacementChar, new Rune('B')], runes);

Invalid and incomplete sequences are returned as Rune.ReplacementChar, and the enumeration continues after them.