dotnet / dotnet/runtime

[API Proposal]: MemoryExtensions.CommonPrefixLength with StringComparison parameter

Open
#126,756 3 comments 1 reaction 0 assignees View on GitHub
api-suggestion area-System.Runtime
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Background and motivation

Presciently, the api review for `MemoryExtensions.CommonPrefixLength` [noted](https://github.com/dotnet/runtime/issues/64271#issuecomment-1097103048):

> - We might need to add an overload that is specific to char and takes StringComparison

There are two major reasons to add this overload:

1. It enables vectorization when doing an ordinalignorecase comparison. (Performance)
2. It no longer requires you to implement your own `IEqualityComparer` to gain the desired behavior. (Usability)

Currently, if you are looking for a common prefix case-insensitively among strings, you have to entirely give up on vectorization by passing an `IEqualityComparer` (which you also have to define yourself) which will then be called into for every char.

Prior art is the [`MemoryExtensions.Equals`](https://learn.microsoft.com/en-us/dotnet/api/system.memoryextensions.equals) extension on spans of chars that takes a `StringComparison` parameter. The rationale that applied there, applies here.

### API Proposal

```csharp
namespace System;

public static class MemoryExtensions
{
extension(ReadOnlySpan span)
{
public int CommonPrefixLength(ReadOnlySpan other, StringComparison comparisonType);
}
}
```

ℹ️ An extension is not defined on `Span`. This is due to the C# 14 language feature [_first-class spans_](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14#implicit-span-conversions) which enables the `ReadOnlySpan` extensions to appear when dotting off any `Span` expression.

### API Usage

Finding a common base folder among a bunch of file and folder paths, where paths are case-insensitive:

```csharp
public static string GetCommonPath(params IEnumerable paths)
{
using var enumerator = paths.GetEnumerator();

if (!enumerator.MoveNext())
return string.Empty;

var firstString = enumerator.Current;
var commonLength = firstString.Length;
if (commonLength == 0)
return firstString;

var first = firstString.AsSpan();

while (enumerator.MoveNext())
{
var current = enumerator.Current;
var newLength = first[..commonLength].CommonPrefixLength(current, StringComparison.OrdinalIgnoreCase);

var atSegmentBoundary =
(newLength == commonLength || first[newLength] is '/' or '\\')
&& (newLength == current.Length || current[newLength] is '/' or '\\');

commonLength = atSegmentBoundary
? newLength
: first[..newLength].LastIndexOfAny('/', '\\');

if (commonLength <= 0)
return "";
}

return commonLength == first.Length ? firstString : first[..commonLength].ToString();
}
```

### Alternative Designs

_No response_

### Risks

_No response_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.