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:
maj00r
2026-11-13 16:04:49 +01:00
parent 744720d922
commit 48af11b4b2
6 changed files with 264 additions and 41 deletions

View 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"));
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);

View 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);
}
}

View File

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