Skip to content

Commit 54c833f

Browse files
committed
Introduce PetroglyphFileSystem abstraction for cross-platform filesystem operations.
1 parent 07026bb commit 54c833f

9 files changed

Lines changed: 547 additions & 3 deletions
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly:InternalsVisibleTo("PG.StarWarsGame.Engine")]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using PG.StarWarsGame.Engine.Utilities;
4+
5+
namespace PG.StarWarsGame.Engine.IO;
6+
7+
public sealed partial class PetroglyphFileSystem
8+
{
9+
public string CombinePath(string pathA, string pathB)
10+
{
11+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
12+
return _underlyingFileSystem.Path.Combine(pathA, pathB);
13+
14+
if (pathA == null)
15+
throw new ArgumentNullException(nameof(pathA));
16+
if (pathB == null)
17+
throw new ArgumentNullException(nameof(pathB));
18+
return CombineInternal(pathA, pathB);
19+
}
20+
21+
internal void JoinPath(ReadOnlySpan<char> path1, ReadOnlySpan<char> path2, ref ValueStringBuilder stringBuilder)
22+
{
23+
if (path1.Length == 0 && path2.Length == 0)
24+
return;
25+
26+
if (path1.Length == 0 || path2.Length == 0)
27+
{
28+
ref var pathToUse = ref path1.Length == 0 ? ref path2 : ref path1;
29+
stringBuilder.Append(pathToUse);
30+
return;
31+
}
32+
33+
stringBuilder.Append(path1);
34+
35+
var hasSeparator = IsDirectorySeparator(path1[path1.Length - 1]) || IsDirectorySeparator(path2[0]);
36+
if (!hasSeparator)
37+
stringBuilder.Append(_underlyingFileSystem.Path.DirectorySeparatorChar);
38+
39+
stringBuilder.Append(path2);
40+
}
41+
42+
private string CombineInternal(string first, string second)
43+
{
44+
if (string.IsNullOrEmpty(first))
45+
return second;
46+
47+
if (string.IsNullOrEmpty(second))
48+
return first;
49+
50+
if (IsPathRooted(second.AsSpan()))
51+
return second;
52+
53+
return JoinInternal(first, second);
54+
}
55+
56+
private string JoinInternal(string first, string second)
57+
{
58+
var hasSeparator = IsDirectorySeparator(first[first.Length - 1]) || IsDirectorySeparator(second[0]);
59+
return hasSeparator
60+
? string.Concat(first, second)
61+
: string.Concat(first, _underlyingFileSystem.Path.DirectorySeparatorChar, second);
62+
}
63+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.IO;
4+
using System.Runtime.CompilerServices;
5+
using System.Runtime.InteropServices;
6+
using AnakinRaW.CommonUtilities.FileSystem;
7+
using PG.StarWarsGame.Engine.Utilities;
8+
#if NETSTANDARD2_1 || NET
9+
using System.Diagnostics.CodeAnalysis;
10+
#endif
11+
12+
namespace PG.StarWarsGame.Engine.IO;
13+
14+
public sealed partial class PetroglyphFileSystem
15+
{
16+
internal bool FileExists(
17+
ReadOnlySpan<char> filePath,
18+
ref ValueStringBuilder stringBuilder,
19+
ReadOnlySpan<char> gameDirectory)
20+
{
21+
stringBuilder.Length = 0;
22+
23+
if (IsPathFullyQualified_Exists(filePath))
24+
stringBuilder.Append(filePath);
25+
else
26+
JoinPath(gameDirectory, filePath, ref stringBuilder);
27+
28+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
29+
{
30+
NormalizePath(ref stringBuilder);
31+
32+
var actualFilePath = stringBuilder.AsSpan();
33+
return FileExistsCaseInsensitive(actualFilePath, ref stringBuilder);
34+
}
35+
36+
// We *could* also use the slightly faster GetFileAttributesA.
37+
// However, CreateFileA and GetFileAttributesA are implemented complete independent.
38+
// The game uses CreateFileA.
39+
// Thus, we should stick to what the game uses in order to be as close to the engine as possible
40+
// NB: It's also important that the string builder is zero-terminated, as otherwise CreateFileA might get invalid data.
41+
var fileHandle = CreateFile(
42+
in stringBuilder.GetPinnableReference(true),
43+
FileAccess.Read,
44+
FileShare.Read,
45+
IntPtr.Zero,
46+
FileMode.Open,
47+
FileAttributes.Normal, IntPtr.Zero);
48+
49+
return IsValidAndClose(fileHandle);
50+
}
51+
52+
// NB: This method assumes backslashes have been normalized to forward slashes
53+
// NB: This method operates on the actual file system
54+
private bool FileExistsCaseInsensitive(ReadOnlySpan<char> filePath, ref ValueStringBuilder stringBuilder)
55+
{
56+
Debug.Assert(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows));
57+
58+
var pathString = filePath.ToString();
59+
if (_underlyingFileSystem.File.Exists(pathString))
60+
return true;
61+
62+
var directory = _underlyingFileSystem.Path.GetDirectoryName(pathString);
63+
var fileName = _underlyingFileSystem.Path.GetFileName(pathString);
64+
65+
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName))
66+
return false;
67+
68+
if (!_underlyingFileSystem.Directory.Exists(directory))
69+
{
70+
if (!FileExistsCaseInsensitive(directory.AsSpan(), ref stringBuilder))
71+
return false;
72+
73+
directory = stringBuilder.AsSpan().ToString();
74+
}
75+
76+
var files = _underlyingFileSystem.Directory.GetFiles(directory);
77+
var directories = _underlyingFileSystem.Directory.GetDirectories(directory);
78+
79+
foreach (var file in files)
80+
{
81+
var name = _underlyingFileSystem.Path.GetFileName(file);
82+
if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase))
83+
{
84+
stringBuilder.Length = 0;
85+
stringBuilder.Append(file);
86+
return true;
87+
}
88+
}
89+
90+
foreach (var dir in directories)
91+
{
92+
var name = _underlyingFileSystem.Path.GetFileName(dir);
93+
if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase))
94+
{
95+
stringBuilder.Length = 0;
96+
stringBuilder.Append(dir);
97+
return true;
98+
}
99+
}
100+
101+
return false;
102+
}
103+
104+
private bool IsPathFullyQualified_Exists(ReadOnlySpan<char> path)
105+
{
106+
// This is really tricky, because under Windows "/" or "\" do NOT
107+
// indicate a fully qualified path, under Linux however "/" does.
108+
// The PGFileSystem is implemented to treat backslashes as directory separators.
109+
// However, this must not happen here, since we are operating on the actual file system.
110+
// E.g, \\Data\\Art\\... MUST not be treated as a fully qualified path
111+
// This means, ultimately, we can just delegate to the underlying file system.
112+
113+
return _underlyingFileSystem.Path.IsPathFullyQualified(path);
114+
}
115+
116+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
117+
private static bool IsValidAndClose(IntPtr handle)
118+
{
119+
var isValid = handle != IntPtr.Zero && handle != new IntPtr(-1);
120+
if (isValid)
121+
CloseHandle(handle);
122+
return isValid;
123+
}
124+
125+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
126+
private static extern IntPtr CreateFile(
127+
in char lpFileName,
128+
[MarshalAs(UnmanagedType.U4)] FileAccess access,
129+
[MarshalAs(UnmanagedType.U4)] FileShare share,
130+
IntPtr securityAttributes,
131+
[MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
132+
[MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes,
133+
IntPtr templateFile);
134+
135+
[DllImport("kernel32.dll", SetLastError = true)]
136+
[return: MarshalAs(UnmanagedType.Bool)]
137+
private static extern bool CloseHandle(IntPtr hObject);
138+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using AnakinRaW.CommonUtilities.FileSystem;
4+
#if NETSTANDARD2_1 || NET
5+
using System.Diagnostics.CodeAnalysis;
6+
#endif
7+
8+
namespace PG.StarWarsGame.Engine.IO;
9+
10+
public sealed partial class PetroglyphFileSystem
11+
{
12+
13+
#if NETSTANDARD2_1 || NET
14+
[return: NotNullIfNotNull(nameof(path))]
15+
#endif
16+
public string? GetFileName(string? path)
17+
{
18+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
19+
return _underlyingFileSystem.Path.GetFileName(path);
20+
21+
if (path == null)
22+
return null;
23+
var result = GetFileName(path.AsSpan());
24+
if (path.Length == result.Length)
25+
return path;
26+
return result.ToString();
27+
}
28+
29+
public ReadOnlySpan<char> GetFileName(ReadOnlySpan<char> path)
30+
{
31+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
32+
return _underlyingFileSystem.Path.GetFileName(path);
33+
34+
var root = GetPathRoot(path).Length;
35+
var i = path.LastIndexOfAny(DirectorySeparatorChar, AltDirectorySeparatorChar);
36+
return path.Slice(i < root ? root : i + 1);
37+
}
38+
39+
#if NETSTANDARD2_1 || NET
40+
[return: NotNullIfNotNull(nameof(path))]
41+
#endif
42+
public string? GetFileNameWithoutExtension(string? path)
43+
{
44+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
45+
return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path);
46+
47+
if (path == null)
48+
return null;
49+
50+
var result = GetFileNameWithoutExtension(path.AsSpan());
51+
return path.Length == result.Length
52+
? path
53+
: result.ToString();
54+
}
55+
56+
public ReadOnlySpan<char> GetFileNameWithoutExtension(ReadOnlySpan<char> path)
57+
{
58+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
59+
return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path);
60+
var fileName = GetFileName(path);
61+
var lastPeriod = fileName.LastIndexOf('.');
62+
return lastPeriod < 0
63+
? fileName
64+
: // No extension was found
65+
fileName.Slice(0, lastPeriod);
66+
}
67+
68+
#if NETSTANDARD2_1 || NET
69+
[return: NotNullIfNotNull(nameof(path))]
70+
#endif
71+
public string? ChangeExtension(string? path, string? extension)
72+
{
73+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
74+
return _underlyingFileSystem.Path.ChangeExtension(path, extension);
75+
76+
if (path == null)
77+
return null;
78+
79+
var subLength = path.Length;
80+
if (subLength == 0)
81+
return string.Empty;
82+
83+
for (var i = path.Length - 1; i >= 0; i--)
84+
{
85+
var ch = path[i];
86+
87+
if (ch == '.')
88+
{
89+
subLength = i;
90+
break;
91+
}
92+
93+
if (IsDirectorySeparator(ch))
94+
break;
95+
}
96+
97+
if (extension == null)
98+
return path.Substring(0, subLength);
99+
100+
#if NETCOREAPP3_0_OR_GREATER
101+
var subpath = path.AsSpan(0, subLength);
102+
return extension.StartsWith('.') ?
103+
string.Concat(subpath, extension) :
104+
string.Concat(subpath, ".", extension);
105+
#else
106+
var subPath = path.Substring(0, subLength);
107+
if (extension.Length >= 1 && extension[0] == '.')
108+
return string.Concat(subPath, extension);
109+
return string.Concat(subPath, ".", extension);
110+
#endif
111+
}
112+
113+
public ReadOnlySpan<char> GetDirectoryName(ReadOnlySpan<char> path)
114+
{
115+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
116+
return _underlyingFileSystem.Path.GetDirectoryName(path);
117+
118+
if (IsEffectivelyEmpty(path))
119+
return ReadOnlySpan<char>.Empty;
120+
121+
var end = GetDirectoryNameOffset(path);
122+
return end >= 0 ? path.Slice(0, end) : ReadOnlySpan<char>.Empty;
123+
}
124+
125+
private static int GetDirectoryNameOffset(ReadOnlySpan<char> path)
126+
{
127+
var rootLength = GetRootLength(path);
128+
var end = path.Length;
129+
if (end <= rootLength)
130+
return -1;
131+
132+
while (end > rootLength && !IsDirectorySeparator(path[--end])) ;
133+
134+
// Trim off any remaining separators (to deal with C:\foo\\bar)
135+
while (end > rootLength && IsDirectorySeparator(path[end - 1]))
136+
end--;
137+
138+
return end;
139+
}
140+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System;
2+
using System.IO;
3+
using System.IO.Abstractions;
4+
using System.Runtime.CompilerServices;
5+
using System.Runtime.InteropServices;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using PG.StarWarsGame.Engine.Utilities;
8+
9+
namespace PG.StarWarsGame.Engine.IO;
10+
11+
public sealed partial class PetroglyphFileSystem
12+
{
13+
internal void NormalizePath(ref ValueStringBuilder stringBuilder)
14+
{
15+
stringBuilder.Length = NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length));
16+
}
17+
18+
// TODO: Check whether we can eliminate the double slash normalization
19+
// once we migrated to PGFileSystem
20+
private static int NormalizePath(Span<char> path)
21+
{
22+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
23+
return path.Length;
24+
25+
var writePos = 0;
26+
var lastWasSeparator = false;
27+
for (var i = 0; i < path.Length; i++)
28+
{
29+
var c = path[i];
30+
var isSeparator = c is '\\' or '/';
31+
if (isSeparator && lastWasSeparator)
32+
continue;
33+
path[writePos++] = isSeparator ? '/' : c;
34+
lastWasSeparator = isSeparator;
35+
}
36+
37+
return writePos;
38+
}
39+
}

0 commit comments

Comments
 (0)