mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-13 20:03:19 +01:00
refactor: share the case-insensitive lookup of data files
The scenery repository's include lookup becomes CaseInsensitivePaths, which takes any folder over IFileSystem and gives a name as it is on disk. Of folders named alike in other case it now tries each, not only the first. A name is joined to the folder as the simulator joins it, a leading slash counting for nothing. IFileSystem tells whether the disk ignores case; there, as on Windows, a name missed as written is not looked for any further, so no folder is listed.
This commit is contained in:
119
StarterNG.Tests/CaseInsensitivePathsTests.cs
Normal file
119
StarterNG.Tests/CaseInsensitivePathsTests.cs
Normal file
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
/// <summary>A link counts as the directory it points at, as the disk has it.</summary>
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace StarterNG.Application.Abstractions;
|
||||
|
||||
public interface IFileSystem
|
||||
{
|
||||
/// <summary>Whether a name finds its file whatever the case of its letters, as on Windows and macOS.</summary>
|
||||
bool IgnoresCase { get; }
|
||||
|
||||
bool FileExists(string path);
|
||||
|
||||
bool DirectoryExists(string path);
|
||||
|
||||
@@ -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);
|
||||
|
||||
132
StarterNG/Infrastructure/CaseInsensitivePaths.cs
Normal file
132
StarterNG/Infrastructure/CaseInsensitivePaths.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
namespace StarterNG.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class CaseInsensitivePaths
|
||||
{
|
||||
private readonly IFileSystem _files;
|
||||
private readonly Func<(string Folder, bool Files), ILookup<string, string>> _list;
|
||||
|
||||
/// <summary>Each folder's files and folders by name, listed once until <see cref="Forget"/>; null lists every time.</summary>
|
||||
private readonly ConcurrentDictionary<(string Folder, bool Files), ILookup<string, string>>? _listings;
|
||||
|
||||
/// <param name="keepListings">
|
||||
/// Lists each folder once, for many names looked up in data that stays as it is
|
||||
/// meanwhile, until <see cref="Forget"/>.
|
||||
/// </param>
|
||||
public CaseInsensitivePaths(IFileSystem files, bool keepListings = false)
|
||||
{
|
||||
_files = files;
|
||||
_list = List;
|
||||
if (keepListings)
|
||||
_listings = new ConcurrentDictionary<(string, bool), ILookup<string, string>>();
|
||||
}
|
||||
|
||||
/// <summary>Lets the next lookups list the folders again.</summary>
|
||||
public void Forget() => _listings?.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// The file named so under <paramref name="folder"/> 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.
|
||||
/// </summary>
|
||||
/// <param name="name">
|
||||
/// A name under the folder, with either slash, joined to it as the simulator joins
|
||||
/// it: a slash ahead of it counts for nothing.
|
||||
/// </param>
|
||||
/// <param name="directory">Looks for a folder instead of a file.</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <paramref name="folder"/> as <see cref="Resolve"/> does.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the parts from <paramref name="at"/> on under <paramref name="current"/>. Of
|
||||
/// folders named alike in other case - two copies of the data on Linux - each is
|
||||
/// tried, the one written as on disk first.
|
||||
/// </summary>
|
||||
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<string, string> 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<string, string> List((string Folder, bool Files) key)
|
||||
{
|
||||
var entries = Array.Empty<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed class SceneryRepository : ISceneryRepository
|
||||
private readonly SceneryParser _parser;
|
||||
|
||||
/// <summary>The scenery folders' files and folders, listed once per load of them all.</summary>
|
||||
private readonly ConcurrentDictionary<(string, bool), IReadOnlyList<string>> _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<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
|
||||
{
|
||||
_listings.Clear();
|
||||
_names.Forget();
|
||||
var files = SceneryFiles().ToList();
|
||||
var loaded = new ConcurrentBag<Scenery>();
|
||||
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.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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<string> Listing(string directory, bool files) =>
|
||||
_listings.GetOrAdd((directory, files), key =>
|
||||
{
|
||||
if (!_files.DirectoryExists(key.Item1))
|
||||
return Array.Empty<string>();
|
||||
return key.Item2 ? _files.GetFiles(key.Item1) : _files.GetDirectories(key.Item1);
|
||||
});
|
||||
private string? ResolveInclude(string name) => _names.Resolve(_paths.Scenery, name);
|
||||
|
||||
private IEnumerable<string> SceneryFiles() =>
|
||||
_files.GetFiles(_paths.Scenery, "*.scn")
|
||||
|
||||
Reference in New Issue
Block a user