diff --git a/Schema.Test/SchemaDataSourceTests.cs b/Schema.Test/SchemaDataSourceTests.cs index 62421a3..770098d 100644 --- a/Schema.Test/SchemaDataSourceTests.cs +++ b/Schema.Test/SchemaDataSourceTests.cs @@ -107,6 +107,71 @@ public void TestCodeGeneratorOutputPathResolves() Assert.AreEqual(Path.GetFullPath(Path.Combine(workingDirectory, "generated")), resolved.ToString()); } + /// + /// A resolved path is normalised, not merely concatenated. A schema beside the data it + /// describes is the easy case; one that reaches a sibling directory through .. is the + /// case that tells a resolver from a string join, and the resolved value is compared, + /// displayed and used as a key, so it has to come back as the directory it names rather than + /// as a route to it. + /// + [TestMethod] + public void TestFilePathsResolveThroughTraversalSegments() + { + Schema schema = CreateAnchoredSchema("../shared/./items.json"); + + Assert.IsTrue(schema.GetDataSource("Items".As())!.TryResolveFile(out AbsoluteFilePath resolved)); + Assert.AreEqual( + Path.GetFullPath(Path.Combine(workingDirectory, "../shared/./items.json")), + resolved.ToString()); + Assert.IsFalse(resolved.ToString().Contains("..", StringComparison.Ordinal), resolved.ToString()); + } + + [TestMethod] + public void TestDirectoryPathsResolveThroughTraversalSegments() + { + Schema schema = CreateAnchoredSchema(); + SchemaCodeGenerator generator = schema.AddCodeGenerator("CSharp".As())!; + generator.OutputPath = "../build/./generated".As(); + + Assert.IsTrue(generator.TryResolveOutputPath(out AbsoluteDirectoryPath resolved)); + Assert.AreEqual( + Path.GetFullPath(Path.Combine(workingDirectory, "../build/./generated")), + resolved.ToString()); + Assert.IsFalse(resolved.ToString().Contains("..", StringComparison.Ordinal), resolved.ToString()); + } + + /// + /// The anchor is the directory holding the schema file, and nothing else about the file. + /// + [TestMethod] + public void TestTheAnchorIsTheSchemaFilesDirectory() + { + Schema schema = new(); + schema.SetSourceFile(SchemaPath); + + Assert.AreEqual(workingDirectory, schema.SourceDirectory.ToString()); + Assert.AreEqual("test.schema.json", schema.SourceFileName); + } + + /// + /// Anchoring a schema must not disturb the path it was handed. Callers pass an instance they + /// keep using - the editor records the same one as a recent file straight afterwards - and + /// reading AbsoluteFilePath.AbsoluteDirectoryPath to find the anchor silently breaks + /// equality and the hash code of the instance it is read from (ktsu.Semantics.Paths 5.4.2), + /// while leaving its text alone. That is why the anchor is still taken from the string. + /// + [TestMethod] + public void TestSettingTheSourceFileLeavesTheCallersPathEqualToItself() + { + AbsoluteFilePath handedIn = SchemaPath; + AbsoluteFilePath untouched = SchemaPath; + + new Schema().SetSourceFile(handedIn); + + Assert.AreEqual(untouched, handedIn, "Anchoring the schema changed the path it was given."); + Assert.AreEqual(untouched.GetHashCode(), handedIn.GetHashCode(), "Anchoring the schema changed the hash code of the path it was given."); + } + [TestMethod] public void TestLoadWithASourcePathAnchorsTheSchema() { diff --git a/Schema/Models/Schema.Paths.cs b/Schema/Models/Schema.Paths.cs index 646dff8..eecf914 100644 --- a/Schema/Models/Schema.Paths.cs +++ b/Schema/Models/Schema.Paths.cs @@ -16,6 +16,19 @@ namespace ktsu.Schema.Models; /// The anchor is supplied by whoever read the file, so the serializer itself stays free of the /// filesystem. A schema that was never read from a file has no anchor and cannot resolve /// anything, which every resolution API reports rather than guessing at the working directory. +/// +/// Resolution goes through AsAbsolute rather than the / combine operator: a schema +/// may reach a sibling directory through .., and only the former normalises those segments +/// away. The operator joins, which hands back a route to the file rather than the file. +/// +/// The anchor is still taken with rather than +/// AbsoluteFilePath.AbsoluteDirectoryPath, which would read better. Reading that property +/// mutates the instance it is read from: in ktsu.Semantics.Paths 5.4.2 an +/// AbsoluteFilePath stops comparing equal to an identical one, and its hash code changes, +/// once the property has been touched, while its text stays the same. Callers hand the same +/// instance on afterwards - the editor records it as a recent file - so reading it here corrupted +/// equality for a value this code does not own. AsAbsolute carries no such hazard, which is +/// why only the resolution moved. /// public partial class Schema { @@ -40,7 +53,7 @@ public bool TryResolvePath(RelativeFilePath relativePath, out AbsoluteFilePath r return false; } - resolved = Path.GetFullPath(Path.Combine(SourceDirectory, relativePath)).As(); + resolved = relativePath.AsAbsolute(SourceDirectory); return true; } @@ -59,7 +72,7 @@ public bool TryResolvePath(RelativeDirectoryPath relativePath, out AbsoluteDirec return false; } - resolved = Path.GetFullPath(Path.Combine(SourceDirectory, relativePath)).As(); + resolved = relativePath.AsAbsolute(SourceDirectory); return true; }