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")