Skip to content

Combine #4

@feO2x

Description

@feO2x

PathX.Combine

Context

System.IO.Path.Combine inserts/omits directory separators between path segments and resets when a subsequent segment is rooted. However, it is host-bound (separator, drive/UNC semantics, case-sensitivity). This plan adds PathX.Combine overloads that mirror the .NET semantics while delegating all separator/root/case behavior to PathXOptions, making behavior host-agnostic.

References to the .NET runtime are summarized in requirements/path-analysis.md.

API Surface

Implement in src/Light.ExtendedPath/PathX.Combine.cs:

public sealed partial class PathX
{
    // Static, options-first
    public static string Combine(PathXOptions options, string? path1, string? path2);
    public static string Combine(PathXOptions options, string? path1, string? path2, string? path3);
    public static string Combine(PathXOptions options, string? path1, string? path2, string? path3, string? path4);
    public static string Combine(PathXOptions options, params ReadOnlySpan<string> paths);

    // Instance (use this.Options)
    public string Combine(string? path1, string? path2);
    public string Combine(string? path1, string? path2, string? path3);
    public string Combine(string? path1, string? path2, string? path3, string? path4);
    public string Combine(params ReadOnlySpan<string> paths);
}

Place tests in tests/Light.ExtendedPath.Tests/PathXCombineTests.cs with file-scoped namespace Light.ExtendedPath.

Behavioral Specification

  • Separator recognition and output formatting are defined by PathXOptions:
    • Use options.RecognizedSeparators to recognize directory separators in inputs.
    • Use options.PreferredSeparator for inserted separators in outputs.
    • Respect options.CollapseSeparatorRuns, options.PreserveSeparatorsAfterRoot, options.TrimTrailingSeparatorsExceptRoot.
  • Segment normalization while combining:
    • Skip empty segments (empty string). They neither add separators nor reset the output.
    • If a subsequent segment is "rooted" by options, discard previously accumulated segments and start from that segment.
  • Rooting rules (derive from options):
    • Rooted-by-separator: segment starts with a recognized separator (e.g., /foo, \bar).
    • Drive-rooted: when options.IsSupportingDriveLetters and options.VolumeSeparator is set — a segment with a drive root (e.g., C: + recognized separator) is considered rooted and resets accumulation. A bare volume like C: without a separator is NOT fully rooted and SHOULD NOT reset (align with .NET Path semantics where "C:rel" is not rooted).
    • UNC rooted: when options.IsSupportingUncPaths and segment starts with two consecutive recognized separators, treat as UNC root. Preserve the double-separator run after the root if PreserveSeparatorsAfterRoot is true.
    • Device rooted: when options.IsSupportingDevicePaths and segment matches device path starts (e.g., \\?\, \\.\) using recognized separators. Treat as rooted and preserve the prefix as-is.
  • Inserting separators between non-empty, non-rooted boundaries:
    • For the fixed-arity overloads, when not reset by rooting, we will delegate to Join-like helpers that ensure exactly one PreferredSeparator at each boundary (mirrors how Path.CombineInternal uses JoinInternal).
    • For the params overload, we will mirror .NET Path.Combine(params string[]) behavior: only inspect the last character of the accumulated builder to decide whether to add a separator before appending the next segment. This means if the previous segment ends with a separator and the next segment starts with one, runs can be preserved (not collapsed) by default—unless options request collapsing.
    • If CollapseSeparatorRuns is true, collapse multiple separators at boundaries to a single PreferredSeparator.
    • If the boundary is immediately after an identified root where double separators must be preserved (UNC) and PreserveSeparatorsAfterRoot is true, keep the required run.
  • Trailing separators in the final result:
    • If TrimTrailingSeparatorsExceptRoot is true, trim trailing recognized separators unless the entire result is a root.
  • Case sensitivity:
    • Use options.StringComparison only where comparisons are needed (e.g., device/UNC detection patterns); do not otherwise case-normalize.
  • Null handling and argument validation:
    • options must not be null (throw ArgumentNullException).
    • Fixed-arity overloads (2–4 segments): throw ArgumentNullException if any segment is null (match System.IO.Path.Combine).
    • Span overload: if paths length is 0, return string.Empty. Throw ArgumentNullException if any element in the span is null (mirrors array-based combine behavior).
  • Root override precedence:
    • When multiple segments are provided, the last encountered rooted segment (per options) resets and becomes the new base; preceding segments are ignored.

Algorithm Outline (mirrors .NET decomposition)

Implement multiple helpers instead of a single core, reflecting System.IO.Path:

  1. Fixed-arity helpers (private):

    • CombineInternal(PathXOptions o, string first, string second)

      • If first empty => return second.
      • If second empty => return first.
      • If IsPathRooted(o, second) => return second.
      • Else return JoinInternal(o, first.AsSpan(), second.AsSpan()).
    • CombineInternal(PathXOptions o, string first, string second, string third)

      • Handle empties like .NET: if one is empty, reduce to shorter overload.
      • If third rooted => return third.
      • Else if second rooted => return CombineInternal(o, second, third).
      • Else return JoinInternal(o, first, second, third).
    • CombineInternal(PathXOptions o, string first, string second, string third, string fourth)

      • Handle empties, then check rootedness in reverse order (fourth, third, second) and reduce accordingly.
      • Else return JoinInternal(o, first, second, third, fourth).
  2. Params/span helper (private): CombineInternal(PathXOptions o, ReadOnlySpan<string> paths)

    • Two-pass like .NET:
      • Pass 1: validate paths[i] not null; accumulate maxSize and determine firstComponent as the index of the last rooted segment (per options). For capacity, add paths[i].Length and add +1 if the segment does not end with a recognized separator.
      • Pass 2: use ValueStringBuilder with ensured capacity; iterate i = firstComponent..end:
        • Skip empty segments.
        • If builder empty, append segment as-is.
        • Else, if builder ends with a recognized separator, optionally collapse with next's leading separator depending on o.CollapseSeparatorRuns; otherwise, append o.PreferredSeparator and then append the segment.
    • Return the built string.
  3. Join-like helpers (private): JoinInternal(o, ReadOnlySpan<char> a, ReadOnlySpan<char> b) and overloads for 3 and 4 spans

    • Ensure exactly one separator between parts by inspecting both boundary sides, using o.PreferredSeparator and o.IsDirectorySeparator.
    • Implement allocation-efficient concatenation similarly to .NET JoinInternal.
  4. Rooting and separator utilities (private):

    • enum RootKind { None, SeparatorRoot, DriveRoot, UncRoot, DeviceRoot }.
    • GetRootKind(ReadOnlySpan<char> s, PathXOptions o) and IsPathRooted(o, ReadOnlySpan<char> s) built on it.
    • EndsWithSeparator, StartsWithSeparator, IsRoot to aid boundary and trimming logic.

Performance notes:

  • Use ValueStringBuilder with a reasonable initial stack buffer (e.g., 256) and fall back to heap if needed.
  • Avoid interim string allocations: work with spans and append slices.
  • Reduce boundary checks by caching EndsWithSeparator state between appends.

Test Plan (tests/Light.ExtendedPath.Tests/PathXCombineTests.cs)

Use xUnit v3 and FluentAssertions. Follow data-driven tests for happy paths and rooted overrides; Facts for single-case exceptions.

  • Parity tests (instance vs static):

    • For every case below, assert the instance method result equals the static method result.
  • Validation:

    • options == null throws ArgumentNullException (all overloads).
    • Fixed-arity overloads: any null segment throws ArgumentNullException.
    • Span overload: empty span => string.Empty; any null element => ArgumentNullException.
  • Basic combine (Unix options):

    • ("a", "b") => "a/b"
    • ("/a", "b") => "/a/b" (no duplicate separator)
    • ("a/", "b") => "a/b" (collapse boundary)
    • ("a", "/b") => "/b" (root override)
    • Multiple: ("a", "b", "c") => "a/b/c"
  • Basic combine (Windows options):

    • ("a", "b") => "a\b"
    • ("C:\\", "b") => "C:\\b"
    • ("a\\", "\\b") => "\\b" (rooted by separator)
    • Drive-rooted override: ("a", "C:\\b") => "C:\\b"
    • Bare drive (not rooted): ("C:\\base", "C:rel") => "C:\\base\\C:rel"
  • Separator recognition differences:

    • On Unix options, backslash is ordinary: ("a\\", "b") => "a\\/b" and ("a", "\\b") does NOT reset (=> "a/\\b").
    • On WindowsUncPaths options, both \\ and / are recognized: boundary collapses and root overrides with either.
  • Collapse vs preserve runs:

    • Params overload default (no collapse): previous-ends-with-sep + next-starts-with-sep retains runs (mirrors .NET). If CollapseSeparatorRuns true, collapse to one.
    • UNC preservation: WindowsUncPaths combining ("\\\\server", "share") => "\\\\server\\share" (double leading separators preserved), and subsequent boundaries collapse only if options request it.
  • Trailing separator trimming:

    • With TrimTrailingSeparatorsExceptRoot true: combining with trailing separators ends without trailing separator unless result is exactly a root (e.g., / or C:\\).
  • Root override precedence:

    • ("a", "/b", "c") => "/b/c" (first rooted resets, then continue)
    • ("a", "/b", "/c") => "/c" (last rooted wins)
  • Span overload equivalence:

    • Provide cases identical to fixed-arity by projecting into a span; ensure identical results and errors.
  • Edge UNC/device (if supported in options):

    • Windows options: ("\\\\?\\C:\\foo", "bar") => "\\\\?\\C:\\foo\\bar" (device-root preserved)
    • WindowsUncPaths: ("\\\\server", "share", "dir") => "\\\\server\\share\\dir"

Acceptance Criteria

  • All eight overloads are implemented with exact signatures.
  • Instance overloads delegate to static helpers using this.Options.
  • Fixed-arity implementations mirror .NET decomposition with CombineInternal(o, ...) overloads that delegate to JoinInternal when not rooted.
  • Params/span implementation mirrors the two-pass approach (capacity + firstComponent) and boundary rule of only inspecting the builder’s last char before adding a separator.
  • Behavior mirrors .NET Path.Combine for: rooted override, boundary separator insertion (including potential runs in params overload), empty segment skipping, and null argument behavior.
  • Separator and root detection use PathXOptions exclusively (no host OS queries), including UNC/drive/device as enabled.
  • Output uses PreferredSeparator and honors CollapseSeparatorRuns, PreserveSeparatorsAfterRoot, and TrimTrailingSeparatorsExceptRoot.
  • Implementation uses allocation-conscious construction (e.g., ValueStringBuilder).
  • Comprehensive unit tests added to PathXCombineTests.cs, data-driven where practical, with static vs instance parity.

Additional Notes

  • Do not normalize or validate entire path shapes beyond what is necessary to determine rooting and separator boundaries. Validation of device/UNC specifics can be extended later.
  • Keep helpers private/internal to PathX for now; they can be promoted if reused by other APIs (e.g., Join, GetFullPath in future tasks).
  • Document in XML comments that behavior is defined by PathXOptions and may differ from the host OS unless ForCurrentOperatingSystem is used.

Reference: System.IO.Path excerpts (for structure)

The following excerpts are copied from .NET runtime (MIT license) to serve as structural references. Replace PathInternal and separator/rooting decisions with PathXOptions-driven logic in our implementation.

Source: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs

public static string Combine(string path1, string path2)
{
    ArgumentNullException.ThrowIfNull(path1);
    ArgumentNullException.ThrowIfNull(path2);

    return CombineInternal(path1, path2);
}

public static string Combine(string path1, string path2, string path3)
{
    ArgumentNullException.ThrowIfNull(path1);
    ArgumentNullException.ThrowIfNull(path2);
    ArgumentNullException.ThrowIfNull(path3);

    return CombineInternal(path1, path2, path3);
}

public static string Combine(string path1, string path2, string path3, string path4)
{
    ArgumentNullException.ThrowIfNull(path1);
    ArgumentNullException.ThrowIfNull(path2);
    ArgumentNullException.ThrowIfNull(path3);
    ArgumentNullException.ThrowIfNull(path4);

    return CombineInternal(path1, path2, path3, path4);
}

public static string Combine(params string[] paths)
{
    ArgumentNullException.ThrowIfNull(paths);

    int maxSize = 0;
    int firstComponent = 0;

    // We have two passes, the first calculates how large a buffer to allocate and does some precondition
    // checks on the paths passed in.  The second actually does the combination.

    for (int i = 0; i < paths.Length; i++)
    {
        ArgumentNullException.ThrowIfNull(paths[i], nameof(paths));

        if (paths[i].Length == 0)
        {
            continue;
        }

        if (IsPathRooted(paths[i]))
        {
            firstComponent = i;
            maxSize = paths[i].Length;
        }
        else
        {
            maxSize += paths[i].Length;
        }

        char ch = paths[i][paths[i].Length - 1];
        if (!PathInternal.IsDirectorySeparator(ch))
            maxSize++;
    }

    var builder = new ValueStringBuilder(stackalloc char[260]); // MaxShortPath on Windows
    builder.EnsureCapacity(maxSize);

    for (int i = firstComponent; i < paths.Length; i++)
    {
        if (paths[i].Length == 0)
        {
            continue;
        }

        if (builder.Length == 0)
        {
            builder.Append(paths[i]);
        }
        else
        {
            char ch = builder[builder.Length - 1];
            if (!PathInternal.IsDirectorySeparator(ch))
            {
                builder.Append(PathInternal.DirectorySeparatorChar);
            }

            builder.Append(paths[i]);
        }
    }

    return builder.ToString();
}

private static string CombineInternal(string first, string second)
{
    if (string.IsNullOrEmpty(first))
        return second;

    if (string.IsNullOrEmpty(second))
        return first;

    if (IsPathRooted(second.AsSpan()))
        return second;

    return JoinInternal(first.AsSpan(), second.AsSpan());
}

private static string CombineInternal(string first, string second, string third)
{
    if (string.IsNullOrEmpty(first))
        return CombineInternal(second, third);
    if (string.IsNullOrEmpty(second))
        return CombineInternal(first, third);
    if (string.IsNullOrEmpty(third))
        return CombineInternal(first, second);

    if (IsPathRooted(third.AsSpan()))
        return third;
    if (IsPathRooted(second.AsSpan()))
        return CombineInternal(second, third);

    return JoinInternal(first.AsSpan(), second.AsSpan(), third.AsSpan());
}

private static string CombineInternal(string first, string second, string third, string fourth)
{
    if (string.IsNullOrEmpty(first))
        return CombineInternal(second, third, fourth);
    if (string.IsNullOrEmpty(second))
        return CombineInternal(first, third, fourth);
    if (string.IsNullOrEmpty(third))
        return CombineInternal(first, second, fourth);
    if (string.IsNullOrEmpty(fourth))
        return CombineInternal(first, second, third);

    if (IsPathRooted(fourth.AsSpan()))
        return fourth;
    if (IsPathRooted(third.AsSpan()))
        return CombineInternal(third, fourth);
    if (IsPathRooted(second.AsSpan()))
        return CombineInternal(second, third, fourth);

    return JoinInternal(first.AsSpan(), second.AsSpan(), third.AsSpan(), fourth.AsSpan());
}

private static string JoinInternal(ReadOnlySpan<char> first, ReadOnlySpan<char> second)
{
    Debug.Assert(first.Length > 0 && second.Length > 0, "should have dealt with empty paths");

    bool hasSeparator = PathInternal.IsDirectorySeparator(first[^1]) || PathInternal.IsDirectorySeparator(second[0]);

    return hasSeparator ?
        string.Concat(first, second) :
        string.Concat(first, PathInternal.DirectorySeparatorCharAsString, second);
}

private static string JoinInternal(ReadOnlySpan<char> first, ReadOnlySpan<char> second, ReadOnlySpan<char> third)
{
    Debug.Assert(first.Length > 0 && second.Length > 0 && third.Length > 0, "should have dealt with empty paths");

    bool firstHasSeparator = PathInternal.IsDirectorySeparator(first[^1]) || PathInternal.IsDirectorySeparator(second[0]);
    bool secondHasSeparator = PathInternal.IsDirectorySeparator(second[^1]) || PathInternal.IsDirectorySeparator(third[0]);

    return (firstHasSeparator, secondHasSeparator) switch
    {
        (false, false) => string.Concat(first, PathInternal.DirectorySeparatorCharAsString, second, PathInternal.DirectorySeparatorCharAsString, third),
        (false, true) => string.Concat(first, PathInternal.DirectorySeparatorCharAsString, second, third),
        (true, false) => string.Concat(first, second, PathInternal.DirectorySeparatorCharAsString, third),
        (true, true) => string.Concat(first, second, third),
    };
}

private static unsafe string JoinInternal(ReadOnlySpan<char> first, ReadOnlySpan<char> second, ReadOnlySpan<char> third, ReadOnlySpan<char> fourth)
{
    Debug.Assert(first.Length > 0 && second.Length > 0 && third.Length > 0 && fourth.Length > 0, "should have dealt with empty paths");

#pragma warning disable CS8500 // This takes the address of, gets the size of, or declares a pointer to a managed type
    var state = new JoinInternalState
    {
        ReadOnlySpanPtr1 = (IntPtr)(&first),
        ReadOnlySpanPtr2 = (IntPtr)(&second),
        ReadOnlySpanPtr3 = (IntPtr)(&third),
        ReadOnlySpanPtr4 = (IntPtr)(&fourth),
        NeedSeparator1 = PathInternal.IsDirectorySeparator(first[^1]) || PathInternal.IsDirectorySeparator(second[0]) ? (byte)0 : (byte)1,
        NeedSeparator2 = PathInternal.IsDirectorySeparator(second[^1]) || PathInternal.IsDirectorySeparator(third[0]) ? (byte)0 : (byte)1,
        NeedSeparator3 = PathInternal.IsDirectorySeparator(third[^1]) || PathInternal.IsDirectorySeparator(fourth[0]) ? (byte)0 : (byte)1,
    };

    return string.Create(
        first.Length + second.Length + third.Length + fourth.Length + state.NeedSeparator1 + state.NeedSeparator2 + state.NeedSeparator3,
        state,
        static (destination, state) =>
        {
            ReadOnlySpan<char> first = *(ReadOnlySpan<char>*)state.ReadOnlySpanPtr1;
            first.CopyTo(destination);
            destination = destination.Slice(first.Length);

            if (state.NeedSeparator1 != 0)
            {
                destination[0] = PathInternal.DirectorySeparatorChar;
                destination = destination.Slice(1);
            }

            ReadOnlySpan<char> second = *(ReadOnlySpan<char>*)state.ReadOnlySpanPtr2;
            second.CopyTo(destination);
            destination = destination.Slice(second.Length);

            if (state.NeedSeparator2 != 0)
            {
                destination[0] = PathInternal.DirectorySeparatorChar;
                destination = destination.Slice(1);
            }

            ReadOnlySpan<char> third = *(ReadOnlySpan<char>*)state.ReadOnlySpanPtr3;
            third.CopyTo(destination);
            destination = destination.Slice(third.Length);

            if (state.NeedSeparator3 != 0)
            {
                destination[0] = PathInternal.DirectorySeparatorChar;
                destination = destination.Slice(1);
            }

            ReadOnlySpan<char> fourth = *(ReadOnlySpan<char>*)state.ReadOnlySpanPtr4;
            Debug.Assert(fourth.Length == destination.Length);
            fourth.CopyTo(destination);
        });
#pragma warning restore CS8500
}

private struct JoinInternalState // used to avoid rooting ValueTuple`7
{
    public IntPtr ReadOnlySpanPtr1, ReadOnlySpanPtr2, ReadOnlySpanPtr3, ReadOnlySpanPtr4;
    public byte NeedSeparator1, NeedSeparator2, NeedSeparator3;
}

Implementation note: in PathX, replace separator checks and values (PathInternal.IsDirectorySeparator, DirectorySeparatorChar, etc.) with options.IsDirectorySeparator(c) and options.PreferredSeparator; replace IsPathRooted with an options-driven rooting check as outlined earlier.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions