diff --git a/StarterNG.Tests/CaseInsensitivePathsTests.cs b/StarterNG.Tests/CaseInsensitivePathsTests.cs
new file mode 100644
index 0000000..9d8bfeb
--- /dev/null
+++ b/StarterNG.Tests/CaseInsensitivePathsTests.cs
@@ -0,0 +1,119 @@
+using StarterNG.Infrastructure;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class CaseInsensitivePathsTests
+{
+ private static InMemoryFileSystem Disk() => new InMemoryFileSystem(caseSensitive: true)
+ .WithFile("game/scenery/Braniewo/Base.ctr", "")
+ .WithFile("game/scenery/Braniewo/rozklady/TGS3657.txt", "")
+ .WithDirectory("game/textures/mini");
+
+ [Theory]
+ [InlineData("braniewo/base.ctr", "Braniewo/Base.ctr")]
+ [InlineData(@"BRANIEWO\Rozklady\tgs3657.TXT", "Braniewo/rozklady/TGS3657.txt")]
+ [InlineData("Braniewo/Base.ctr", "Braniewo/Base.ctr")]
+ [InlineData("braniewo//base.ctr", "Braniewo/Base.ctr")]
+ [InlineData("/braniewo/base.ctr", "Braniewo/Base.ctr")]
+ [InlineData(@"\Braniewo\Base.ctr", "Braniewo/Base.ctr")]
+ public void A_name_in_any_case_is_given_as_it_is_on_disk(string name, string onDisk) =>
+ Assert.Equal(onDisk, new CaseInsensitivePaths(Disk()).Resolve("game/scenery", name));
+
+ [Theory]
+ [InlineData("braniewo/brak.ctr")]
+ [InlineData("brak/base.ctr")]
+ [InlineData("")]
+ public void A_name_nothing_answers_to_is_not_found(string name) =>
+ Assert.Null(new CaseInsensitivePaths(Disk()).Resolve("game/scenery", name));
+
+ [Fact]
+ public void A_folder_is_found_only_when_one_is_asked_for()
+ {
+ var names = new CaseInsensitivePaths(Disk());
+
+ Assert.Equal("Braniewo/rozklady", names.Resolve("game/scenery", "braniewo/ROZKLADY", directory: true));
+ Assert.Null(names.Resolve("game/scenery", "braniewo/ROZKLADY"));
+ Assert.Null(names.Resolve("game/scenery", "braniewo/base.ctr", directory: true));
+ }
+
+ [Fact]
+ public void The_name_written_as_on_disk_wins_over_one_in_other_case()
+ {
+ var files = Disk().WithFile("game/scenery/braniewo/base.ctr", "");
+ var names = new CaseInsensitivePaths(files);
+
+ Assert.Equal("braniewo/base.ctr", names.Resolve("game/scenery", "braniewo/base.ctr"));
+ Assert.Equal("Braniewo/Base.ctr", names.Resolve("game/scenery", "Braniewo/base.CTR"));
+ }
+
+ [Fact]
+ public void Of_folders_named_alike_the_one_holding_the_file_is_taken()
+ {
+ var files = Disk().WithFile("game/scenery/braniewo/tory.scm", "");
+ var names = new CaseInsensitivePaths(files);
+
+ Assert.Equal("Braniewo/Base.ctr", names.Resolve("game/scenery", "braniewo/base.ctr"));
+ Assert.Equal("braniewo/tory.scm", names.Resolve("game/scenery", "Braniewo/Tory.scm"));
+ }
+
+ [Fact]
+ public void The_full_path_joins_the_folder_and_the_name_on_disk() =>
+ Assert.Equal(Path.Combine("game/scenery", "Braniewo", "Base.ctr"),
+ new CaseInsensitivePaths(Disk()).Find("game/scenery", "BRANIEWO/BASE.CTR"));
+
+ [Fact]
+ public void A_full_path_is_found_from_its_own_root()
+ {
+ string scenery = Path.GetFullPath(Path.Combine("game", "scenery"));
+ var files = new InMemoryFileSystem(caseSensitive: true)
+ .WithFile(Path.Combine(scenery, "Braniewo", "Base.ctr"), "");
+
+ Assert.Equal(Path.Combine(scenery, "Braniewo", "Base.ctr"),
+ new CaseInsensitivePaths(files).Find("elsewhere", Path.Combine(scenery, "BRANIEWO", "base.CTR")));
+ }
+
+ [Fact]
+ public void A_disk_that_ignores_case_gives_the_name_back_as_written()
+ {
+ var files = new InMemoryFileSystem().WithFile("game/scenery/Braniewo/Base.ctr", "");
+ var names = new CaseInsensitivePaths(files);
+
+ Assert.Equal("BRANIEWO/base.CTR", names.Resolve("game/scenery", "BRANIEWO/base.CTR"));
+ Assert.Null(names.Resolve("game/scenery", "braniewo/brak.ctr"));
+ }
+
+ [Fact]
+ public void Kept_listings_hold_until_forgotten()
+ {
+ var files = Disk();
+ var names = new CaseInsensitivePaths(files, keepListings: true);
+ Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
+
+ files.WithFile("game/scenery/Braniewo/Nowy.scm", "");
+ Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
+
+ names.Forget();
+ Assert.Equal("Braniewo/Nowy.scm", names.Resolve("game/scenery", "braniewo/nowy.scm"));
+ }
+
+ [Fact]
+ public void Listings_not_kept_see_a_file_added_since()
+ {
+ var files = Disk();
+ var names = new CaseInsensitivePaths(files);
+ Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
+
+ files.WithFile("game/scenery/Braniewo/Nowy.scm", "");
+
+ Assert.Equal("Braniewo/Nowy.scm", names.Resolve("game/scenery", "braniewo/nowy.scm"));
+ }
+
+ [Fact]
+ public void A_folder_that_cannot_be_listed_holds_nothing_to_find()
+ {
+ var files = Disk().DenyListing("game/scenery");
+
+ Assert.Null(new CaseInsensitivePaths(files).Resolve("game/scenery", "braniewo/base.ctr"));
+ }
+}
diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
index 79108cb..0709309 100644
--- a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
+++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
@@ -97,6 +97,8 @@ public sealed class InMemoryFileSystem : IFileSystem
return this;
}
+ public bool IgnoresCase => _comparison == StringComparison.OrdinalIgnoreCase;
+
public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
/// A link counts as the directory it points at, as the disk has it.
diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs
index e04f862..b9c0e3a 100644
--- a/StarterNG/Application/Abstractions/IFileSystem.cs
+++ b/StarterNG/Application/Abstractions/IFileSystem.cs
@@ -7,6 +7,9 @@ namespace StarterNG.Application.Abstractions;
public interface IFileSystem
{
+ /// Whether a name finds its file whatever the case of its letters, as on Windows and macOS.
+ bool IgnoresCase { get; }
+
bool FileExists(string path);
bool DirectoryExists(string path);
diff --git a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
index 15ae4ee..14ab1d6 100644
--- a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
+++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
@@ -10,6 +10,9 @@ public sealed class PhysicalFileSystem : IFileSystem
{
public const string TempSuffix = ".starter.tmp";
+ // Their disks as they come; a Linux one tells case apart.
+ public bool IgnoresCase { get; } = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
+
public bool FileExists(string path) => File.Exists(path);
public bool DirectoryExists(string path) => Directory.Exists(path);
diff --git a/StarterNG/Infrastructure/CaseInsensitivePaths.cs b/StarterNG/Infrastructure/CaseInsensitivePaths.cs
new file mode 100644
index 0000000..51c81d4
--- /dev/null
+++ b/StarterNG/Infrastructure/CaseInsensitivePaths.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Infrastructure;
+
+///
+/// Finds a file or folder of the game's data by a name whose letters may stand in
+/// another case than on disk, as Windows, Wine and the simulator find it, and gives
+/// the name as the disk has it. The data is made on Windows: its names and the
+/// names written in it seldom agree letter for letter, which on Linux matters.
+///
+public sealed class CaseInsensitivePaths
+{
+ private readonly IFileSystem _files;
+ private readonly Func<(string Folder, bool Files), ILookup> _list;
+
+ /// Each folder's files and folders by name, listed once until ; null lists every time.
+ private readonly ConcurrentDictionary<(string Folder, bool Files), ILookup>? _listings;
+
+ ///
+ /// Lists each folder once, for many names looked up in data that stays as it is
+ /// meanwhile, until .
+ ///
+ public CaseInsensitivePaths(IFileSystem files, bool keepListings = false)
+ {
+ _files = files;
+ _list = List;
+ if (keepListings)
+ _listings = new ConcurrentDictionary<(string, bool), ILookup>();
+ }
+
+ /// Lets the next lookups list the folders again.
+ public void Forget() => _listings?.Clear();
+
+ ///
+ /// The file named so under as it is named on disk, relative
+ /// to the folder with forward slashes; null when nothing there answers to it. A name
+ /// written with its letters as on disk wins over one differing only in their case;
+ /// a disk that does not tell case apart gives it back as written.
+ ///
+ ///
+ /// A name under the folder, with either slash, joined to it as the simulator joins
+ /// it: a slash ahead of it counts for nothing.
+ ///
+ /// Looks for a folder instead of a file.
+ public string? Resolve(string folder, string name, bool directory = false)
+ {
+ string[] parts = name.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 0)
+ return null;
+
+ string exact = string.Join('/', parts);
+ string whole = Path.Combine(folder, exact);
+ if (directory ? _files.DirectoryExists(whole) : _files.FileExists(whole))
+ return exact;
+ // Where case does not matter, missed as written is missed in any case.
+ if (_files.IgnoresCase)
+ return null;
+
+ var found = new string[parts.Length];
+ return Walk(folder, parts, 0, directory, found) ? string.Join('/', found) : null;
+ }
+
+ ///
+ /// The full path of the file named so, its letters as on disk; null when there is
+ /// none. A name given in full is looked up from its own root, any other under
+ /// as does.
+ ///
+ public string? Find(string folder, string name, bool directory = false)
+ {
+ if (Path.IsPathFullyQualified(name))
+ {
+ folder = Path.GetPathRoot(name)!;
+ name = name[folder.Length..];
+ }
+
+ return Resolve(folder, name, directory) is { } found
+ ? Path.Combine(folder, found.Replace('/', Path.DirectorySeparatorChar))
+ : null;
+ }
+
+ ///
+ /// Finds the parts from on under . Of
+ /// folders named alike in other case - two copies of the data on Linux - each is
+ /// tried, the one written as on disk first.
+ ///
+ private bool Walk(string current, string[] parts, int at, bool directory, string[] found)
+ {
+ if (at == parts.Length)
+ return true;
+
+ string part = parts[at];
+ if (part is "." or "..")
+ {
+ found[at] = part;
+ return Walk(Path.Combine(current, part), parts, at + 1, directory, found);
+ }
+
+ bool last = at == parts.Length - 1;
+ var matches = Listing(current, files: last && !directory)[part]
+ .OrderBy(entry => string.Equals(Path.GetFileName(entry), part, StringComparison.Ordinal) ? 0 : 1);
+ foreach (string match in matches)
+ {
+ found[at] = Path.GetFileName(match);
+ if (Walk(match, parts, at + 1, directory, found))
+ return true;
+ }
+ return false;
+ }
+
+ private ILookup Listing(string folder, bool files) =>
+ _listings is null ? List((folder, files)) : _listings.GetOrAdd((folder, files), _list);
+
+ // A folder that cannot be listed holds nothing to be found.
+ private ILookup List((string Folder, bool Files) key)
+ {
+ var entries = Array.Empty();
+ try
+ {
+ if (_files.DirectoryExists(key.Folder))
+ entries = (key.Files ? _files.GetFiles(key.Folder) : _files.GetDirectories(key.Folder)).ToArray();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Nothing listed, nothing found.
+ }
+ return entries.ToLookup(entry => Path.GetFileName(entry), StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
index 0aa4008..62c68ad 100644
--- a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
+++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
@@ -23,7 +23,7 @@ public sealed class SceneryRepository : ISceneryRepository
private readonly SceneryParser _parser;
/// The scenery folders' files and folders, listed once per load of them all.
- private readonly ConcurrentDictionary<(string, bool), IReadOnlyList> _listings = new();
+ private readonly CaseInsensitivePaths _names;
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
{
@@ -31,11 +31,12 @@ public sealed class SceneryRepository : ISceneryRepository
_paths = paths;
_log = log;
_parser = parser;
+ _names = new CaseInsensitivePaths(files, keepListings: true);
}
public IReadOnlyList LoadAll(IProgress? progress = null)
{
- _listings.Clear();
+ _names.Forget();
var files = SceneryFiles().ToList();
var loaded = new ConcurrentBag();
int total = Math.Max(1, files.Count);
@@ -58,7 +59,7 @@ public sealed class SceneryRepository : ISceneryRepository
public Scenery? Load(string path)
{
- _listings.Clear();
+ _names.Forget();
return LoadOne(path);
}
@@ -126,44 +127,7 @@ public sealed class SceneryRepository : ISceneryRepository
/// copy written for a start carries in place of the include, so they come along
/// on Linux too.
///
- private string? ResolveInclude(string name)
- {
- string relative = name.Replace('\\', '/');
- if (_files.FileExists(Path.Combine(_paths.Scenery, relative)))
- return relative;
-
- string[] parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
- string directory = _paths.Scenery;
- var found = new List();
- for (int i = 0; i < parts.Length; i++)
- {
- if (parts[i] is "." or "..")
- {
- directory = Path.Combine(directory, parts[i]);
- found.Add(parts[i]);
- continue;
- }
-
- bool last = i == parts.Length - 1;
- string? match = Listing(directory, last)
- .FirstOrDefault(entry => string.Equals(Path.GetFileName(entry), parts[i],
- StringComparison.OrdinalIgnoreCase));
- if (match is null)
- return null;
- directory = match;
- found.Add(Path.GetFileName(match));
- }
-
- return string.Join('/', found);
- }
-
- private IReadOnlyList Listing(string directory, bool files) =>
- _listings.GetOrAdd((directory, files), key =>
- {
- if (!_files.DirectoryExists(key.Item1))
- return Array.Empty();
- return key.Item2 ? _files.GetFiles(key.Item1) : _files.GetDirectories(key.Item1);
- });
+ private string? ResolveInclude(string name) => _names.Resolve(_paths.Scenery, name);
private IEnumerable SceneryFiles() =>
_files.GetFiles(_paths.Scenery, "*.scn")