You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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:
Fixed-arity helpers (private):
CombineInternal(PathXOptions o, string first, string second)
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.
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.
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)
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.
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.
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.
publicstaticstringCombine(stringpath1,stringpath2){ArgumentNullException.ThrowIfNull(path1);ArgumentNullException.ThrowIfNull(path2);returnCombineInternal(path1,path2);}publicstaticstringCombine(stringpath1,stringpath2,stringpath3){ArgumentNullException.ThrowIfNull(path1);ArgumentNullException.ThrowIfNull(path2);ArgumentNullException.ThrowIfNull(path3);returnCombineInternal(path1,path2,path3);}publicstaticstringCombine(stringpath1,stringpath2,stringpath3,stringpath4){ArgumentNullException.ThrowIfNull(path1);ArgumentNullException.ThrowIfNull(path2);ArgumentNullException.ThrowIfNull(path3);ArgumentNullException.ThrowIfNull(path4);returnCombineInternal(path1,path2,path3,path4);}publicstaticstringCombine(paramsstring[]paths){ArgumentNullException.ThrowIfNull(paths);intmaxSize=0;intfirstComponent=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(inti=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;}charch=paths[i][paths[i].Length-1];if(!PathInternal.IsDirectorySeparator(ch))maxSize++;}varbuilder=newValueStringBuilder(stackallocchar[260]);// MaxShortPath on Windowsbuilder.EnsureCapacity(maxSize);for(inti=firstComponent;i<paths.Length;i++){if(paths[i].Length==0){continue;}if(builder.Length==0){builder.Append(paths[i]);}else{charch=builder[builder.Length-1];if(!PathInternal.IsDirectorySeparator(ch)){builder.Append(PathInternal.DirectorySeparatorChar);}builder.Append(paths[i]);}}returnbuilder.ToString();}privatestaticstringCombineInternal(stringfirst,stringsecond){if(string.IsNullOrEmpty(first))returnsecond;if(string.IsNullOrEmpty(second))returnfirst;if(IsPathRooted(second.AsSpan()))returnsecond;returnJoinInternal(first.AsSpan(),second.AsSpan());}privatestaticstringCombineInternal(stringfirst,stringsecond,stringthird){if(string.IsNullOrEmpty(first))returnCombineInternal(second,third);if(string.IsNullOrEmpty(second))returnCombineInternal(first,third);if(string.IsNullOrEmpty(third))returnCombineInternal(first,second);if(IsPathRooted(third.AsSpan()))returnthird;if(IsPathRooted(second.AsSpan()))returnCombineInternal(second,third);returnJoinInternal(first.AsSpan(),second.AsSpan(),third.AsSpan());}privatestaticstringCombineInternal(stringfirst,stringsecond,stringthird,stringfourth){if(string.IsNullOrEmpty(first))returnCombineInternal(second,third,fourth);if(string.IsNullOrEmpty(second))returnCombineInternal(first,third,fourth);if(string.IsNullOrEmpty(third))returnCombineInternal(first,second,fourth);if(string.IsNullOrEmpty(fourth))returnCombineInternal(first,second,third);if(IsPathRooted(fourth.AsSpan()))returnfourth;if(IsPathRooted(third.AsSpan()))returnCombineInternal(third,fourth);if(IsPathRooted(second.AsSpan()))returnCombineInternal(second,third,fourth);returnJoinInternal(first.AsSpan(),second.AsSpan(),third.AsSpan(),fourth.AsSpan());}privatestaticstringJoinInternal(ReadOnlySpan<char>first,ReadOnlySpan<char>second){Debug.Assert(first.Length>0&&second.Length>0,"should have dealt with empty paths");boolhasSeparator=PathInternal.IsDirectorySeparator(first[^1])||PathInternal.IsDirectorySeparator(second[0]);returnhasSeparator?string.Concat(first,second):string.Concat(first,PathInternal.DirectorySeparatorCharAsString,second);}privatestaticstringJoinInternal(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");boolfirstHasSeparator=PathInternal.IsDirectorySeparator(first[^1])||PathInternal.IsDirectorySeparator(second[0]);boolsecondHasSeparator=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),};}privatestaticunsafestringJoinInternal(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 typevarstate=newJoinInternalState{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,};returnstring.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}privatestructJoinInternalState// used to avoid rooting ValueTuple`7{publicIntPtrReadOnlySpanPtr1,ReadOnlySpanPtr2,ReadOnlySpanPtr3,ReadOnlySpanPtr4;publicbyteNeedSeparator1,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.
PathX.Combine
Context
System.IO.Path.Combineinserts/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 addsPathX.Combineoverloads that mirror the .NET semantics while delegating all separator/root/case behavior toPathXOptions, 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:Place tests in
tests/Light.ExtendedPath.Tests/PathXCombineTests.cswith file-scoped namespaceLight.ExtendedPath.Behavioral Specification
PathXOptions:options.RecognizedSeparatorsto recognize directory separators in inputs.options.PreferredSeparatorfor inserted separators in outputs.options.CollapseSeparatorRuns,options.PreserveSeparatorsAfterRoot,options.TrimTrailingSeparatorsExceptRoot./foo,\bar).options.IsSupportingDriveLettersandoptions.VolumeSeparatoris set — a segment with a drive root (e.g.,C:+ recognized separator) is considered rooted and resets accumulation. A bare volume likeC:without a separator is NOT fully rooted and SHOULD NOT reset (align with .NET Path semantics where"C:rel"is not rooted).options.IsSupportingUncPathsand segment starts with two consecutive recognized separators, treat as UNC root. Preserve the double-separator run after the root ifPreserveSeparatorsAfterRootis true.options.IsSupportingDevicePathsand segment matches device path starts (e.g.,\\?\,\\.\) using recognized separators. Treat as rooted and preserve the prefix as-is.PreferredSeparatorat each boundary (mirrors howPath.CombineInternalusesJoinInternal).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.CollapseSeparatorRunsis true, collapse multiple separators at boundaries to a singlePreferredSeparator.PreserveSeparatorsAfterRootis true, keep the required run.TrimTrailingSeparatorsExceptRootis true, trim trailing recognized separators unless the entire result is a root.options.StringComparisononly where comparisons are needed (e.g., device/UNC detection patterns); do not otherwise case-normalize.optionsmust not be null (throwArgumentNullException).ArgumentNullExceptionif any segment is null (matchSystem.IO.Path.Combine).pathslength is 0, returnstring.Empty. ThrowArgumentNullExceptionif any element in the span isnull(mirrors array-based combine behavior).Algorithm Outline (mirrors .NET decomposition)
Implement multiple helpers instead of a single core, reflecting
System.IO.Path:Fixed-arity helpers (private):
CombineInternal(PathXOptions o, string first, string second)firstempty => returnsecond.secondempty => returnfirst.IsPathRooted(o, second)=> returnsecond.JoinInternal(o, first.AsSpan(), second.AsSpan()).CombineInternal(PathXOptions o, string first, string second, string third)thirdrooted => returnthird.secondrooted => returnCombineInternal(o, second, third).JoinInternal(o, first, second, third).CombineInternal(PathXOptions o, string first, string second, string third, string fourth)JoinInternal(o, first, second, third, fourth).Params/span helper (private):
CombineInternal(PathXOptions o, ReadOnlySpan<string> paths)paths[i]not null; accumulatemaxSizeand determinefirstComponentas the index of the last rooted segment (per options). For capacity, addpaths[i].Lengthand add+1if the segment does not end with a recognized separator.ValueStringBuilderwith ensured capacity; iteratei = firstComponent..end:o.CollapseSeparatorRuns; otherwise, appendo.PreferredSeparatorand then append the segment.Join-like helpers (private):
JoinInternal(o, ReadOnlySpan<char> a, ReadOnlySpan<char> b)and overloads for 3 and 4 spanso.PreferredSeparatorando.IsDirectorySeparator.JoinInternal.Rooting and separator utilities (private):
enum RootKind { None, SeparatorRoot, DriveRoot, UncRoot, DeviceRoot }.GetRootKind(ReadOnlySpan<char> s, PathXOptions o)andIsPathRooted(o, ReadOnlySpan<char> s)built on it.EndsWithSeparator,StartsWithSeparator,IsRootto aid boundary and trimming logic.Performance notes:
ValueStringBuilderwith a reasonable initial stack buffer (e.g., 256) and fall back to heap if needed.stringallocations: work with spans and append slices.EndsWithSeparatorstate 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):
Validation:
options == nullthrowsArgumentNullException(all overloads).nullsegment throwsArgumentNullException.string.Empty; anynullelement =>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)("a", "b", "c") => "a/b/c"Basic combine (Windows options):
("a", "b") => "a\b"("C:\\", "b") => "C:\\b"("a\\", "\\b") => "\\b"(rooted by separator)("a", "C:\\b") => "C:\\b"("C:\\base", "C:rel") => "C:\\base\\C:rel"Separator recognition differences:
("a\\", "b") => "a\\/b"and("a", "\\b") does NOT reset (=> "a/\\b").\\and/are recognized: boundary collapses and root overrides with either.Collapse vs preserve runs:
CollapseSeparatorRunstrue, collapse to one.("\\\\server", "share") => "\\\\server\\share"(double leading separators preserved), and subsequent boundaries collapse only if options request it.Trailing separator trimming:
TrimTrailingSeparatorsExceptRoottrue: combining with trailing separators ends without trailing separator unless result is exactly a root (e.g.,/orC:\\).Root override precedence:
("a", "/b", "c") => "/b/c"(first rooted resets, then continue)("a", "/b", "/c") => "/c"(last rooted wins)Span overload equivalence:
Edge UNC/device (if supported in options):
("\\\\?\\C:\\foo", "bar") => "\\\\?\\C:\\foo\\bar"(device-root preserved)("\\\\server", "share", "dir") => "\\\\server\\share\\dir"Acceptance Criteria
this.Options.CombineInternal(o, ...)overloads that delegate toJoinInternalwhen not rooted.firstComponent) and boundary rule of only inspecting the builder’s last char before adding a separator.Path.Combinefor: rooted override, boundary separator insertion (including potential runs in params overload), empty segment skipping, and null argument behavior.PathXOptionsexclusively (no host OS queries), including UNC/drive/device as enabled.PreferredSeparatorand honorsCollapseSeparatorRuns,PreserveSeparatorsAfterRoot, andTrimTrailingSeparatorsExceptRoot.ValueStringBuilder).PathXCombineTests.cs, data-driven where practical, with static vs instance parity.Additional Notes
PathXfor now; they can be promoted if reused by other APIs (e.g.,Join,GetFullPathin future tasks).PathXOptionsand may differ from the host OS unlessForCurrentOperatingSystemis used.Reference: System.IO.Path excerpts (for structure)
The following excerpts are copied from .NET runtime (MIT license) to serve as structural references. Replace
PathInternaland separator/rooting decisions withPathXOptions-driven logic in our implementation.Source: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs
Implementation note: in
PathX, replace separator checks and values (PathInternal.IsDirectorySeparator,DirectorySeparatorChar, etc.) withoptions.IsDirectorySeparator(c)andoptions.PreferredSeparator; replaceIsPathRootedwith an options-driven rooting check as outlined earlier.