2
2
// Licensed under the MIT license. See License.txt in the project root for license information.
3
3
4
4
using System ;
5
- using System . Net ;
5
+ using System . Buffers ;
6
6
using System . Runtime . InteropServices ;
7
7
using Microsoft . CodeAnalysis . Razor ;
8
8
@@ -12,60 +12,186 @@ internal static class FilePathNormalizer
12
12
{
13
13
public static string NormalizeDirectory ( string ? directoryFilePath )
14
14
{
15
- var normalized = Normalize ( directoryFilePath ) ;
15
+ if ( directoryFilePath . IsNullOrEmpty ( ) )
16
+ {
17
+ return "/" ;
18
+ }
19
+
20
+ var directoryFilePathSpan = directoryFilePath . AsSpan ( ) ;
21
+
22
+ // Ensure that the array is at least 1 character larger, so that we can add
23
+ // a trailing space after normalization if necessary.
24
+ var arrayLength = directoryFilePathSpan . Length + 1 ;
25
+ using var _ = ArrayPool < char > . Shared . GetPooledArray ( arrayLength , out var array ) ;
26
+ var arraySpan = array . AsSpan ( 0 , arrayLength ) ;
27
+ var ( start , length ) = NormalizeCore ( directoryFilePathSpan , arraySpan ) ;
28
+ ReadOnlySpan < char > normalizedSpan = arraySpan . Slice ( start , length ) ;
29
+
30
+ // Add a trailing slash if the normalized span doesn't end in one.
31
+ if ( normalizedSpan [ ^ 1 ] != '/' )
32
+ {
33
+ arraySpan [ start + length ] = '/' ;
34
+ normalizedSpan = arraySpan . Slice ( start , length + 1 ) ;
35
+ }
16
36
17
- if ( ! normalized . EndsWith ( "/" , StringComparison . Ordinal ) )
37
+ if ( directoryFilePathSpan . Equals ( normalizedSpan , StringComparison . Ordinal ) )
18
38
{
19
- normalized += '/' ;
39
+ return directoryFilePath ;
20
40
}
21
41
22
- return normalized ;
42
+ return CreateString ( normalizedSpan ) ;
23
43
}
24
44
25
45
public static string Normalize ( string ? filePath )
26
46
{
27
- if ( string . IsNullOrEmpty ( filePath ) )
47
+ if ( filePath . IsNullOrEmpty ( ) )
28
48
{
29
49
return "/" ;
30
50
}
31
51
32
- var decodedPath = filePath . AssumeNotNull ( ) . Contains ( "%" ) ? WebUtility . UrlDecode ( filePath ) : filePath ;
33
- var normalized = decodedPath . Replace ( '\\ ' , '/' ) ;
52
+ var filePathSpan = filePath . AsSpan ( ) ;
34
53
35
- if ( RuntimeInformation . IsOSPlatform ( OSPlatform . Windows ) &&
36
- normalized [ 0 ] == '/' &&
37
- ! normalized . StartsWith ( "//" , StringComparison . OrdinalIgnoreCase ) )
54
+ // Rent a buffer for Normalize to write to.
55
+ using var _ = ArrayPool < char > . Shared . GetPooledArray ( filePathSpan . Length , out var array ) ;
56
+ var normalizedSpan = NormalizeCoreAndGetSpan ( filePathSpan , array ) ;
57
+
58
+ // If we didn't change anything, just return the original string.
59
+ if ( filePathSpan . Equals ( normalizedSpan , StringComparison . Ordinal ) )
38
60
{
39
- // We've been provided a path that probably looks something like /C:/path/to
40
- normalized = normalized [ 1 ..] ;
61
+ return filePath ;
41
62
}
42
- else
63
+
64
+ // Otherwise, create a new string from our normalized char buffer.
65
+ return CreateString ( normalizedSpan ) ;
66
+ }
67
+
68
+ /// <summary>
69
+ /// Returns the directory portion of the given file path in normalized form.
70
+ /// </summary>
71
+ public static string GetNormalizedDirectoryName ( string ? filePath )
72
+ {
73
+ if ( filePath . IsNullOrEmpty ( ) )
43
74
{
44
- // Already a valid path like C:/path or //path
75
+ return "/" ;
76
+ }
77
+
78
+ var filePathSpan = filePath . AsSpan ( ) ;
79
+
80
+ using var _1 = ArrayPool < char > . Shared . GetPooledArray ( filePathSpan . Length , out var array ) ;
81
+ var normalizedSpan = NormalizeCoreAndGetSpan ( filePathSpan , array ) ;
82
+
83
+ var lastSlashIndex = normalizedSpan . LastIndexOf ( '/' ) ;
84
+
85
+ var directoryNameSpan = lastSlashIndex >= 0
86
+ ? normalizedSpan [ ..( lastSlashIndex + 1 ) ] // Include trailing slash
87
+ : normalizedSpan ;
88
+
89
+ if ( filePathSpan . Equals ( directoryNameSpan , StringComparison . Ordinal ) )
90
+ {
91
+ return filePath ;
45
92
}
46
93
47
- return normalized ;
94
+ return CreateString ( directoryNameSpan ) ;
48
95
}
49
96
50
- public static string GetDirectory ( string filePath )
97
+ public static bool FilePathsEquivalent ( string ? filePath1 , string ? filePath2 )
51
98
{
52
- if ( string . IsNullOrEmpty ( filePath ) )
99
+ var filePathSpan1 = filePath1 . AsSpanOrDefault ( ) ;
100
+ var filePathSpan2 = filePath2 . AsSpanOrDefault ( ) ;
101
+
102
+ if ( filePathSpan1 . IsEmpty )
103
+ {
104
+ return filePathSpan2 . IsEmpty ;
105
+ }
106
+ else if ( filePathSpan2 . IsEmpty )
53
107
{
54
- throw new InvalidOperationException ( filePath ) ;
108
+ return false ;
55
109
}
56
110
57
- var normalizedPath = Normalize ( filePath ) ;
58
- var lastSeparatorIndex = normalizedPath . LastIndexOf ( '/' ) ;
111
+ using var _1 = ArrayPool < char > . Shared . GetPooledArray ( filePathSpan1 . Length , out var array1 ) ;
112
+ var normalizedSpan1 = NormalizeCoreAndGetSpan ( filePathSpan1 , array1 ) ;
113
+
114
+ using var _2 = ArrayPool < char > . Shared . GetPooledArray ( filePathSpan2 . Length , out var array2 ) ;
115
+ var normalizedSpan2 = NormalizeCoreAndGetSpan ( filePathSpan2 , array2 ) ;
59
116
60
- var directory = normalizedPath [ ..( lastSeparatorIndex + 1 ) ] ;
61
- return directory ;
117
+ return normalizedSpan1 . Equals ( normalizedSpan2 , FilePathComparison . Instance ) ;
62
118
}
63
119
64
- public static bool FilePathsEquivalent ( string filePath1 , string filePath2 )
120
+ private static ReadOnlySpan < char > NormalizeCoreAndGetSpan ( ReadOnlySpan < char > source , Span < char > destination )
65
121
{
66
- var normalizedFilePath1 = Normalize ( filePath1 ) ;
67
- var normalizedFilePath2 = Normalize ( filePath2 ) ;
122
+ var ( start , length ) = NormalizeCore ( source , destination ) ;
123
+ return destination . Slice ( start , length ) ;
124
+ }
68
125
69
- return FilePathComparer . Instance . Equals ( normalizedFilePath1 , normalizedFilePath2 ) ;
126
+ /// <summary>
127
+ /// Normalizes the given <paramref name="source"/> file path and writes the result in <paramref name="destination"/>.
128
+ /// </summary>
129
+ /// <param name="source">The span to normalize.</param>
130
+ /// <param name="destination">The span to write to.</param>
131
+ /// <returns>
132
+ /// Returns a tuple containing the start index and length of the normalized path within <paramref name="destination"/>.
133
+ /// </returns>
134
+ private static ( int start , int length ) NormalizeCore ( ReadOnlySpan < char > source , Span < char > destination )
135
+ {
136
+ if ( source . IsEmpty )
137
+ {
138
+ if ( destination . Length < 1 )
139
+ {
140
+ throw new ArgumentException ( "Destination length must be at least 1 if the source is empty." , nameof ( destination ) ) ;
141
+ }
142
+
143
+ destination [ 0 ] = '/' ;
144
+
145
+ return ( start : 0 , length : 1 ) ;
146
+ }
147
+
148
+ if ( destination . Length < source . Length )
149
+ {
150
+ throw new ArgumentException ( "Destination length must be greater or equal to the source length." , nameof ( destination ) ) ;
151
+ }
152
+
153
+ int charsWritten ;
154
+
155
+ // Note: We check for '%' characters before calling UrlDecoder.Decode to ensure that we *only*
156
+ // decode when there are '%XX' entities. So, calling Normalize on a path and then calling Normalize
157
+ // on the result will not call Decode twice.
158
+ if ( source . Contains ( "%" . AsSpan ( ) , StringComparison . Ordinal ) )
159
+ {
160
+ UrlDecoder . Decode ( source , destination , out charsWritten ) ;
161
+ }
162
+ else
163
+ {
164
+ source . CopyTo ( destination ) ;
165
+ charsWritten = source . Length ;
166
+ }
167
+
168
+ // Replace slashes in our normalized span.
169
+ destination [ ..charsWritten ] . Replace ( '\\ ' , '/' ) ;
170
+
171
+ if ( RuntimeInformation . IsOSPlatform ( OSPlatform . Windows ) &&
172
+ destination is [ '/' , not '/' , ..] )
173
+ {
174
+ // We've been provided a path that probably looks something like /C:/path/to.
175
+ // So, we adjust the result to skip the leading '/'.
176
+ return ( start : 1 , length : charsWritten - 1 ) ;
177
+ }
178
+ else
179
+ {
180
+ // Already a valid path like C:/path or //path
181
+ return ( start : 0 , length : charsWritten ) ;
182
+ }
183
+ }
184
+
185
+ private static unsafe string CreateString ( ReadOnlySpan < char > source )
186
+ {
187
+ if ( source . IsEmpty )
188
+ {
189
+ return string . Empty ;
190
+ }
191
+
192
+ fixed ( char * ptr = source )
193
+ {
194
+ return new string ( ptr , 0 , source . Length ) ;
195
+ }
70
196
}
71
197
}
0 commit comments