Skip to content

Commit 6edb318

Browse files
committed
Make the encoder cross-platform
1 parent 2590ae8 commit 6edb318

File tree

2 files changed

+64
-57
lines changed

2 files changed

+64
-57
lines changed

src/DotNext.Tests/IO/FileUriTests.cs

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,29 @@ namespace DotNext.IO;
44

55
public sealed class FileUriTests : Test
66
{
7-
public static TheoryData<string, string> GetPaths()
7+
public static TheoryData<string, string> GetPaths() => new()
88
{
9-
var data = new TheoryData<string, string>();
10-
if (OperatingSystem.IsWindows())
11-
{
12-
data.Add(@"C:\without\whitespace", @"C:\without\whitespace");
13-
data.Add(@"C:\with whitespace", @"C:\with whitespace");
14-
data.Add(@"C:\with\trailing\backslash", @"C:\with\trailing\backslash");
15-
data.Add(@"C:\with\trailing\backslash and space", @"C:\with\trailing\backslash and space");
16-
data.Add(@"C:\with\..\relative\.\components\", @"C:\relative\components\");
17-
data.Add(@"C:\with\specials\chars\#\$\", @"C:\with\specials\chars\#\$\");
18-
data.Add(@"C:\с\кириллицей", @"C:\с\кириллицей");
19-
data.Add(@"C:\ελληνικά\γράμματα", @"C:\ελληνικά\γράμματα");
20-
data.Add(@"\\unc\path", @"\\unc\path");
21-
}
22-
else
23-
{
24-
data.Add("/without/whitespace", "/without/whitespace");
25-
data.Add("/with whitespace", "/with whitespace");
26-
data.Add("/with/trailing/slash/", "/with/trailing/slash/");
27-
data.Add("/with/trailing/slash and space/", "/with/trailing/slash and space/");
28-
data.Add("/with/../relative/./components/", "/relative/components/");
29-
data.Add("/with/special/chars/?/>/</", "/with/special/chars/?/>/</");
30-
data.Add("/с/кириллицей", "/с/кириллицей");
31-
data.Add("/ελληνικά/γράμματα", "/ελληνικά/γράμματα");
32-
}
9+
// Windows path
10+
{ @"C:\without\whitespace", @"C:\without\whitespace" },
11+
{ @"C:\with whitespace", @"C:\with whitespace" },
12+
{ @"C:\with\trailing\backslash", @"C:\with\trailing\backslash" },
13+
{ @"C:\with\trailing\backslash and space", @"C:\with\trailing\backslash and space" },
14+
{ @"C:\with\..\relative\.\components\", @"C:\relative\components\" },
15+
{ @"C:\with\specials\chars\#\$\", @"C:\with\specials\chars\#\$\" },
16+
{ @"C:\с\кириллицей", @"C:\с\кириллицей" },
17+
{ @"C:\ελληνικά\γράμματα", @"C:\ελληνικά\γράμματα" },
18+
{ @"\\unc\path", @"\\unc\path" },
3319

34-
return data;
35-
}
20+
// Unix path
21+
{ "/without/whitespace", "/without/whitespace" },
22+
{ "/with whitespace", "/with whitespace" },
23+
{ "/with/trailing/slash/", "/with/trailing/slash/" },
24+
{ "/with/trailing/slash and space/", "/with/trailing/slash and space/" },
25+
{ "/with/../relative/./components/", "/relative/components/" },
26+
{ "/with/special/chars/?/>/</", "/with/special/chars/?/>/</" },
27+
{ "/с/кириллицей", "/с/кириллицей" },
28+
{ "/ελληνικά/γράμματα", "/ελληνικά/γράμματα" }
29+
};
3630

3731
[Theory]
3832
[MemberData(nameof(GetPaths))]
@@ -60,4 +54,13 @@ public static void MaxEncodedLength()
6054
const string path = "/some/path";
6155
True(FileUri.GetMaxEncodedLength(path) > path.Length);
6256
}
57+
58+
[Theory]
59+
[InlineData("~/path/name")]
60+
[InlineData("C:path\\name")]
61+
[InlineData("")]
62+
public static void CheckFullyQualifiedPath(string path)
63+
{
64+
Throws<ArgumentException>(() => FileUri.Encode(path));
65+
}
6366
}

src/DotNext/IO/FileUri.cs

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ public static class FileUri
1818
// \\?\folder => file://?/folder
1919
// \\.\folder => file://./folder
2020
private const string FileScheme = "file://";
21+
private const char UriPathSeparator = '/';
22+
private static readonly SearchValues<char> UnixDirectorySeparators = SearchValues.Create([UriPathSeparator]);
23+
private static readonly SearchValues<char> WindowsDirectorySeparators = SearchValues.Create([UriPathSeparator, '\\']);
2124

2225
/// <summary>
2326
/// Encodes file name as URI.
@@ -28,7 +31,9 @@ public static class FileUri
2831
/// <exception cref="ArgumentException"><paramref name="fileName"/> is not fully-qualified.</exception>
2932
public static string Encode(ReadOnlySpan<char> fileName, TextEncoderSettings? settings = null)
3033
{
31-
ThrowIfPartiallyQualified(fileName);
34+
if (fileName.IsEmpty)
35+
throw new ArgumentException(ExceptionMessages.FullyQualifiedPathExpected, nameof(fileName));
36+
3237
var encoder = settings is null ? UrlEncoder.Default : UrlEncoder.Create(settings);
3338
var maxLength = GetMaxEncodedLengthCore(fileName, encoder);
3439
using var buffer = (uint)maxLength <= (uint)SpanOwner<char>.StackallocThreshold
@@ -47,11 +52,11 @@ public static string Encode(ReadOnlySpan<char> fileName, TextEncoderSettings? se
4752
/// <param name="encoder">The encoder.</param>
4853
/// <returns>The maximum number of characters that can be produced by the encoder.</returns>
4954
public static int GetMaxEncodedLength(ReadOnlySpan<char> fileName, UrlEncoder? encoder = null)
50-
=> GetMaxEncodedLengthCore(fileName, encoder ?? UrlEncoder.Default);
55+
=> fileName.IsEmpty ? 0 : GetMaxEncodedLengthCore(fileName, encoder ?? UrlEncoder.Default);
5156

5257
private static int GetMaxEncodedLengthCore(ReadOnlySpan<char> fileName, UrlEncoder encoder)
5358
=> FileScheme.Length
54-
+ Unsafe.BitCast<bool, byte>(OperatingSystem.IsWindows())
59+
+ Unsafe.BitCast<bool, byte>(fileName[0] is not UriPathSeparator)
5560
+ encoder.MaxOutputCharactersPerInputCharacter * fileName.Length;
5661

5762
/// <summary>
@@ -65,45 +70,44 @@ private static int GetMaxEncodedLengthCore(ReadOnlySpan<char> fileName, UrlEncod
6570
/// <exception cref="ArgumentException"><paramref name="fileName"/> is not fully-qualified.</exception>
6671
public static bool TryEncode(ReadOnlySpan<char> fileName, UrlEncoder? encoder, Span<char> output, out int charsWritten)
6772
{
68-
ThrowIfPartiallyQualified(fileName);
73+
if (fileName.IsEmpty)
74+
throw new ArgumentException(ExceptionMessages.FullyQualifiedPathExpected, nameof(fileName));
6975

7076
return TryEncodeCore(fileName, encoder ?? UrlEncoder.Default, output, out charsWritten);
7177
}
7278

73-
[StackTraceHidden]
74-
private static void ThrowIfPartiallyQualified(ReadOnlySpan<char> fileName)
75-
{
76-
if (!Path.IsPathFullyQualified(fileName))
77-
throw new ArgumentException(ExceptionMessages.FullyQualifiedPathExpected, nameof(fileName));
78-
}
79-
8079
private static bool TryEncodeCore(ReadOnlySpan<char> fileName, UrlEncoder encoder, Span<char> output, out int charsWritten)
8180
{
82-
const char slash = '/';
83-
const char driveSeparator = ':';
84-
const char escapedDriveSeparatorChar = '|';
8581
var writer = new SpanWriter<char>(output);
8682
writer.Write(FileScheme);
8783

8884
bool endsWithTrailingSeparator;
89-
if (!OperatingSystem.IsWindows())
85+
SearchValues<char> directoryPathSeparators;
86+
switch (fileName)
9087
{
91-
// nothing to do
92-
}
93-
else if (fileName is ['\\', '\\', .. var rest]) // UNC path
94-
{
95-
fileName = rest;
96-
}
97-
else if (GetPathComponent(ref fileName, out endsWithTrailingSeparator) is [.. var drive, driveSeparator])
98-
{
99-
writer.Add(slash);
100-
writer.Write(drive);
101-
writer.Write(endsWithTrailingSeparator ? [escapedDriveSeparatorChar, slash] : [escapedDriveSeparatorChar]);
88+
case [UriPathSeparator, ..]: // Unix path
89+
directoryPathSeparators = UnixDirectorySeparators;
90+
break;
91+
case ['\\', '\\', .. var rest]: // Windows UNC path
92+
directoryPathSeparators = WindowsDirectorySeparators;
93+
fileName = rest;
94+
break;
95+
default: // Windows path
96+
const char driveSeparator = ':';
97+
const char escapedDriveSeparatorChar = '|';
98+
if (GetPathComponent(ref fileName, directoryPathSeparators = WindowsDirectorySeparators, out endsWithTrailingSeparator)
99+
is not [.. var drive, driveSeparator])
100+
throw new ArgumentException(ExceptionMessages.FullyQualifiedPathExpected, nameof(fileName));
101+
102+
writer.Add(UriPathSeparator);
103+
writer.Write(drive);
104+
writer.Write(endsWithTrailingSeparator ? [escapedDriveSeparatorChar, UriPathSeparator] : [escapedDriveSeparatorChar]);
105+
break;
102106
}
103107

104-
for (;; writer.Add(slash))
108+
for (;; writer.Add(UriPathSeparator))
105109
{
106-
var component = GetPathComponent(ref fileName, out endsWithTrailingSeparator);
110+
var component = GetPathComponent(ref fileName, directoryPathSeparators, out endsWithTrailingSeparator);
107111
if (encoder.Encode(component, writer.RemainingSpan, out _, out charsWritten) is not OperationStatus.Done)
108112
return false;
109113

@@ -116,10 +120,10 @@ private static bool TryEncodeCore(ReadOnlySpan<char> fileName, UrlEncoder encode
116120
return true;
117121
}
118122

119-
private static ReadOnlySpan<char> GetPathComponent(ref ReadOnlySpan<char> fileName, out bool endsWithTrailingSeparator)
123+
private static ReadOnlySpan<char> GetPathComponent(ref ReadOnlySpan<char> fileName, SearchValues<char> directorySeparatorChars, out bool endsWithTrailingSeparator)
120124
{
121125
ReadOnlySpan<char> component;
122-
var index = fileName.IndexOf(Path.DirectorySeparatorChar);
126+
var index = fileName.IndexOfAny(directorySeparatorChars);
123127
if (endsWithTrailingSeparator = index >= 0)
124128
{
125129
component = fileName.Slice(0, index);

0 commit comments

Comments
 (0)