diff --git a/CLAUDE.md b/CLAUDE.md index bacf47f..48cb46e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,14 @@ hero_001,1001|1002|1003 ``` - ID만 저장, `Evaluate(context)`로 해결 +## YAML 저장 (주석 보존) + +- `KeyValueDataRepository` / `SingleDataRepository` 가 YAML 파일을 저장할 때 `YamlCommentPreserver.Reconcile()` 을 거친다. + 디스크의 현재 파일, 그 파일을 다시 직렬화한 정규 텍스트, 저장할 데이터의 정규 텍스트를 비교해 바뀐 값만 원본 텍스트에 덮어쓴다. +- 안 바뀐 행은 바이트 단위로 그대로, 주석·빈 줄·따옴표·flow 리스트(`[1, 2]`)도 유지된다. +- 패치할 수 없거나, 결과를 다시 읽었을 때 데이터가 다르면 예전처럼 정규 텍스트를 쓴다 (데이터는 항상 정확, 주석만 best effort). +- 테스트: `Datra.Tests/YamlCommentPreservationTests.cs` (에디터 저장 경로), `YamlCommentPreserverTests.cs` (재정렬·폴백 등) + ## 테스트 방법 ### 통합 테스트 (권장) diff --git a/Datra.Tests/YamlCommentPreservationTests.cs b/Datra.Tests/YamlCommentPreservationTests.cs new file mode 100644 index 0000000..16d0b88 --- /dev/null +++ b/Datra.Tests/YamlCommentPreservationTests.cs @@ -0,0 +1,533 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Datra.Editor.DataSources; +using Datra.Interfaces; +using Datra.Repositories; +using Datra.SampleData.Generated; +using Datra.SampleData.Models; +using Datra.Serializers; +using Xunit; + +namespace Datra.Tests +{ + /// + /// Saving a YAML table from the editor must keep the comments a designer wrote in it. + /// Every test goes through the path the Unity Data Editor uses: + /// EditableKeyValueDataSource / EditableSingleDataSource -> repository.SaveAsync -> IRawDataProvider. + /// + public class YamlCommentPreservationTests + { + #region Fixtures + + public class UpgradeRow : ITableData + { + public string Id { get; set; } = string.Empty; + public string? Name { get; set; } = string.Empty; + public double Base { get; set; } + public List Levels { get; set; } = new List(); + public int FirstCost { get; set; } + public string RequiresUpgrade { get; set; } = string.Empty; + public bool Implemented { get; set; } + } + + public class EconomyConfig + { + public int StartingDeposit { get; set; } + public double GoldenBottleChance { get; set; } + public List CenterCells { get; set; } = new List(); + public List Targets { get; set; } = new List(); + } + + public class Target + { + public int Run { get; set; } + public int Deposit { get; set; } + } + + private const string UpgradesYaml = + "# Upgrades: what the player can buy between runs.\n" + + "# Costs grow by CostGrowth each level.\n" + + "\n" + + "# The first thing a new player buys.\n" + + "- Id: push-strength\n" + + " Name: Push strength\n" + + " Base: 0.5 # metres per second at level 0\n" + + " Levels: [0.9, 1.3, 1.6]\n" + + " FirstCost: 50\n" + + " RequiresUpgrade: \"\"\n" + + " Implemented: true\n" + + "\n" + + "# --- Line upgrades ---\n" + + "\n" + + "- Id: bottle-count\n" + + " Name: Longer line\n" + + " Base: 3\n" + + " # one bottle per level, capped by the stage length\n" + + " Levels:\n" + + " - 4 # fits the short stage\n" + + " - 5\n" + + " - 6 # needs the long stage\n" + + " FirstCost: 80\n" + + " RequiresUpgrade: push-strength\n" + + " Implemented: true\n" + + "\n" + + "- Id: domes\n" + + " Name: Balance dome\n" + + " Base: 3\n" + + " Levels: [4, 5]\n" + + " FirstCost: 150 # tuned in playtest 3\n" + + " RequiresUpgrade: \"\"\n" + + "# end of table\n"; + + private const string EconomyYaml = + "# Economy knobs. Money is in coins.\n" + + "StartingDeposit: 0 # nothing in the jar on run 1\n" + + "GoldenBottleChance: 0.02\n" + + "CenterCells: [B2, B3]\n" + + "\n" + + "# Deposit goals per run\n" + + "Targets:\n" + + " - Run: 1\n" + + " Deposit: 60\n" + + " # the first real wall\n" + + " - Run: 5\n" + + " Deposit: 250 # playtest 2\n" + + " - Run: 10\n" + + " Deposit: 600\n"; + + private sealed class MemoryRawDataProvider : IRawDataProvider + { + public readonly Dictionary Files = new Dictionary(); + + public Task LoadTextAsync(string path) + => Files.TryGetValue(path, out var text) + ? Task.FromResult(text) + : throw new FileNotFoundException(path); + + public Task SaveTextAsync(string path, string content) + { + Files[path] = content; + return Task.CompletedTask; + } + + public bool Exists(string path) => Files.ContainsKey(path); + + public string ResolveFilePath(string path) => path; + } + + private static async Task<(EditableKeyValueDataSource source, MemoryRawDataProvider provider, KeyValueDataRepository repository)> + OpenTableAsync(string yaml, string path = "Upgrades.yaml") + { + var provider = new MemoryRawDataProvider(); + provider.Files[path] = yaml; + var repository = new KeyValueDataRepository( + path, + provider, + new DataSerializerFactory(), + (data, serializer) => serializer.DeserializeTable(data), + (table, serializer) => serializer.SerializeTable(table)); + await repository.InitializeAsync(); + var source = new EditableKeyValueDataSource(repository); + return (source, provider, repository); + } + + private static async Task SaveAsync(EditableKeyValueDataSource source, MemoryRawDataProvider provider, string path = "Upgrades.yaml") + { + await source.SaveAsync(); + return provider.Files[path]; + } + + private static Dictionary Parse(string yaml) + => new YamlDataSerializer().DeserializeTable(yaml); + + private static string Replace(string text, string oldValue, string newValue) + { + Assert.Contains(oldValue, text); + Assert.Equal(1, CountOf(text, oldValue)); + return text.Replace(oldValue, newValue); + } + + private static int CountOf(string text, string value) + { + var count = 0; + for (var i = text.IndexOf(value, StringComparison.Ordinal); i >= 0; i = text.IndexOf(value, i + 1, StringComparison.Ordinal)) + count++; + return count; + } + + #endregion + + #region Changing values + + [Fact] + public async Task ChangingOneValue_KeepsEveryCommentAndEveryOtherByte() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("domes", nameof(UpgradeRow.FirstCost), 175, out _); + var saved = await SaveAsync(source, provider); + + Assert.Equal(Replace(UpgradesYaml, "FirstCost: 150 #", "FirstCost: 175 #"), saved); + } + + [Fact] + public async Task ChangingValueWithTrailingComment_KeepsTheComment() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("push-strength", nameof(UpgradeRow.Base), 0.75, out _); + var saved = await SaveAsync(source, provider); + + Assert.Equal(Replace(UpgradesYaml, "Base: 0.5 #", "Base: 0.75 #"), saved); + } + + [Fact] + public async Task ChangingOneEntryOfFlowList_KeepsTheListFlow() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("push-strength", nameof(UpgradeRow.Levels), new List { 0.9, 1.4, 1.6 }, out _); + var saved = await SaveAsync(source, provider); + + Assert.Equal(Replace(UpgradesYaml, "Levels: [0.9, 1.3, 1.6]", "Levels: [0.9, 1.4, 1.6]"), saved); + } + + [Fact] + public async Task ChangingOneEntryOfBlockList_KeepsCommentsInsideTheList() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("bottle-count", nameof(UpgradeRow.Levels), new List { 4, 5, 7 }, out _); + var saved = await SaveAsync(source, provider); + + Assert.Equal(Replace(UpgradesYaml, " - 6 # needs", " - 7 # needs"), saved); + } + + [Fact] + public async Task AppendingToBlockList_AddsALineInTheListsStyle() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("bottle-count", nameof(UpgradeRow.Levels), new List { 4, 5, 6, 8 }, out _); + var saved = await SaveAsync(source, provider); + + Assert.Equal(Replace(UpgradesYaml, " - 6 # needs the long stage\n", " - 6 # needs the long stage\n - 8\n"), saved); + } + + [Fact] + public async Task RemovingFromBlockList_TakesTheEntrysCommentWithIt() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("bottle-count", nameof(UpgradeRow.Levels), new List { 5, 6 }, out _); + var saved = await SaveAsync(source, provider); + + Assert.Equal(Replace(UpgradesYaml, " - 4 # fits the short stage\n", ""), saved); + } + + [Fact] + public async Task ChangingQuotedString_KeepsTheQuotes() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("domes", nameof(UpgradeRow.RequiresUpgrade), "push-strength", out _); + var saved = await SaveAsync(source, provider); + + var expected = UpgradesYaml.Replace( + " FirstCost: 150 # tuned in playtest 3\n RequiresUpgrade: \"\"\n", + " FirstCost: 150 # tuned in playtest 3\n RequiresUpgrade: \"push-strength\"\n"); + Assert.NotEqual(UpgradesYaml, expected); + Assert.Equal(expected, saved); + } + + [Fact] + public async Task SettingAFieldTheFileLeftOut_InsertsItAfterItsNeighbour() + { + // domes has no "Implemented" line; it was false by default. + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("domes", nameof(UpgradeRow.Implemented), true, out _); + var saved = await SaveAsync(source, provider); + + var expected = UpgradesYaml.Replace( + " FirstCost: 150 # tuned in playtest 3\n RequiresUpgrade: \"\"\n", + " FirstCost: 150 # tuned in playtest 3\n RequiresUpgrade: \"\"\n Implemented: true\n"); + Assert.Equal(expected, saved); + } + + [Fact] + public async Task ClearingAField_RemovesItsLineAndTheCommentAboveIt() + { + // Null is omitted on save, so the field's line (and the comment directly above it) goes. + var yaml = UpgradesYaml.Replace(" Name: Balance dome\n", " # shown in the shop\n Name: Balance dome\n"); + var (source, provider, _) = await OpenTableAsync(yaml); + + source.TrackPropertyChange("domes", nameof(UpgradeRow.Name), null, out _); + var saved = await SaveAsync(source, provider); + + Assert.Equal(Replace(yaml, " # shown in the shop\n Name: Balance dome\n", ""), saved); + } + + [Fact] + public async Task SaveWithoutChanges_LeavesTheFileByteIdentical() + { + var (source, provider, repository) = await OpenTableAsync(UpgradesYaml); + + // Force-save path: the repository rewrites the file even though nothing changed. + await repository.SaveAsync(); + Assert.Equal(UpgradesYaml, provider.Files["Upgrades.yaml"]); + + await source.SaveAsync(); + Assert.Equal(UpgradesYaml, provider.Files["Upgrades.yaml"]); + } + + [Fact] + public async Task SavingTwice_KeepsComments() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.TrackPropertyChange("domes", nameof(UpgradeRow.FirstCost), 175, out _); + await source.SaveAsync(); + source.TrackPropertyChange("push-strength", nameof(UpgradeRow.FirstCost), 55, out _); + var saved = await SaveAsync(source, provider); + + var expected = Replace(UpgradesYaml, "FirstCost: 150 #", "FirstCost: 175 #"); + expected = Replace(expected, "FirstCost: 50\n", "FirstCost: 55\n"); + Assert.Equal(expected, saved); + } + + [Fact] + public async Task CrlfFile_StaysCrlf() + { + var crlf = UpgradesYaml.Replace("\n", "\r\n"); + var (source, provider, _) = await OpenTableAsync(crlf); + + source.TrackPropertyChange("domes", nameof(UpgradeRow.FirstCost), 175, out _); + var saved = await SaveAsync(source, provider); + + Assert.Equal(Replace(crlf, "FirstCost: 150 #", "FirstCost: 175 #"), saved); + } + + #endregion + + #region Adding and deleting rows + + [Fact] + public async Task AddingRow_AppendsItWithoutTouchingTheRest() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.Add("carpet", new UpgradeRow + { + Id = "carpet", + Name = "Stair carpet", + Base = 0, + Levels = new List { 1, 2 }, + FirstCost = 350, + }); + var saved = await SaveAsync(source, provider); + + // Everything up to the end of the last row is untouched... + var lastRowEnd = UpgradesYaml.IndexOf("# end of table", StringComparison.Ordinal); + Assert.StartsWith(UpgradesYaml.Substring(0, lastRowEnd - 1), saved); + // ...the new row follows it, separated the way the other rows are... + var newRow = saved.Substring(lastRowEnd - 1); + Assert.StartsWith("\n\n- Id: carpet\n Name: Stair carpet\n", newRow); + // ...and the footer comment is still last. + Assert.EndsWith("\n# end of table\n", saved); + + var rows = Parse(saved); + Assert.Equal(new[] { "push-strength", "bottle-count", "domes", "carpet" }, rows.Keys); + Assert.Equal(new List { 1, 2 }, rows["carpet"].Levels); + Assert.Equal(350, rows["carpet"].FirstCost); + } + + [Fact] + public async Task DeletingRow_TakesItsOwnCommentsButKeepsTheFileHeader() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.Delete("push-strength"); + var saved = await SaveAsync(source, provider); + + var expected = + "# Upgrades: what the player can buy between runs.\n" + + "# Costs grow by CostGrowth each level.\n" + + "\n" + + UpgradesYaml.Substring(UpgradesYaml.IndexOf("# --- Line upgrades ---", StringComparison.Ordinal)); + Assert.Equal(expected, saved); + Assert.DoesNotContain("The first thing a new player buys", saved); + } + + [Fact] + public async Task DeletingRow_RemovesTheCommentsInsideIt_AndKeepsTheSectionComment() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.Delete("bottle-count"); + var saved = await SaveAsync(source, provider); + + var start = UpgradesYaml.IndexOf("- Id: bottle-count", StringComparison.Ordinal); + var end = UpgradesYaml.IndexOf("- Id: domes", StringComparison.Ordinal); + var expected = UpgradesYaml.Remove(start, end - start); + Assert.Equal(expected, saved); + Assert.DoesNotContain("one bottle per level", saved); + Assert.DoesNotContain("fits the short stage", saved); + Assert.Contains("# --- Line upgrades ---", saved); + } + + [Fact] + public async Task DeletingLastRow_KeepsTheFooter() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.Delete("domes"); + var saved = await SaveAsync(source, provider); + + var start = UpgradesYaml.IndexOf("\n\n- Id: domes", StringComparison.Ordinal); + var end = UpgradesYaml.IndexOf("\n# end of table", StringComparison.Ordinal); + Assert.Equal(UpgradesYaml.Remove(start, end - start), saved); + } + + [Fact] + public async Task AddDeleteAndChangeInOneSave() + { + var (source, provider, _) = await OpenTableAsync(UpgradesYaml); + + source.Delete("push-strength"); + source.TrackPropertyChange("bottle-count", nameof(UpgradeRow.Base), 4.0, out _); + source.Add("mat", new UpgradeRow { Id = "mat", Name = "Rubber mat", FirstCost = 90 }); + var saved = await SaveAsync(source, provider); + + Assert.Contains("# Upgrades: what the player can buy between runs.", saved); + Assert.Contains("# --- Line upgrades ---", saved); + Assert.Contains(" # one bottle per level, capped by the stage length\n", saved); + Assert.Contains(" - 4 # fits the short stage\n", saved); + Assert.Contains(" Base: 4\n # one bottle per level", saved); + Assert.Contains(" FirstCost: 150 # tuned in playtest 3\n", saved); + Assert.DoesNotContain("The first thing a new player buys", saved); + + var rows = Parse(saved); + Assert.Equal(new[] { "bottle-count", "domes", "mat" }, rows.Keys); + Assert.Equal(4, rows["bottle-count"].Base); + } + + #endregion + + #region Single data + + [Fact] + public async Task SingleData_ChangingValues_KeepsComments() + { + var provider = new MemoryRawDataProvider(); + provider.Files["Economy.yaml"] = EconomyYaml; + var repository = new SingleDataRepository( + "Economy.yaml", + provider, + new DataSerializerFactory(), + (data, serializer) => serializer.DeserializeSingle(data), + (data, serializer) => serializer.SerializeSingle(data)); + await repository.InitializeAsync(); + var source = new EditableSingleDataSource(repository); + + source.TrackPropertyChange(EditableSingleDataSource.SingleKey, nameof(EconomyConfig.GoldenBottleChance), 0.03, out _); + source.TrackPropertyChange(EditableSingleDataSource.SingleKey, nameof(EconomyConfig.Targets), new List + { + new Target { Run = 1, Deposit = 60 }, + new Target { Run = 5, Deposit = 300 }, + new Target { Run = 10, Deposit = 600 }, + }, out _); + await source.SaveAsync(); + + var expected = Replace(EconomyYaml, "GoldenBottleChance: 0.02", "GoldenBottleChance: 0.03"); + expected = Replace(expected, "Deposit: 250 #", "Deposit: 300 #"); + Assert.Equal(expected, provider.Files["Economy.yaml"]); + } + + #endregion + + #region Generated context + + [Fact] + public async Task GeneratedContext_SavingPolymorphicYamlTable_KeepsComments() + { + // Skills.yaml is a sample table with a header comment and polymorphic ($type) effects, + // loaded through the generated GameDataContext exactly as the Unity editor does. + var directory = Path.Combine(Path.GetTempPath(), "datra-yaml-comments-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var original = File.ReadAllText(Path.Combine(TestDataHelper.FindDataPath(), "Skills.yaml")); + var annotated = Replace(original, " ManaCost: 30\n", " ManaCost: 30 # cheap on purpose\n"); + File.WriteAllText(Path.Combine(directory, "Skills.yaml"), annotated); + + var context = new GameDataContext(new TestRawDataProvider(directory), new DataSerializerFactory(new[] { typeof(SkillEffect) })); + await context.Skill.InitializeAsync(); + var source = new EditableKeyValueDataSource(context.Skill); + + source.TrackPropertyChange("skill_heal", nameof(SkillData.ManaCost), 35, out _); + await source.SaveAsync(); + + var saved = File.ReadAllText(Path.Combine(directory, "Skills.yaml")); + Assert.Equal(Replace(annotated, "ManaCost: 30 #", "ManaCost: 35 #"), saved); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + #endregion + + #region Other formats and scale + + [Fact] + public async Task JsonTable_IsStillWrittenByTheSerializerAsBefore() + { + const string json = "[{\"Id\":\"a\",\"Name\":\"A\",\"Base\":1,\"Levels\":[],\"FirstCost\":10,\"RequiresUpgrade\":\"\",\"Implemented\":false}]"; + var (source, provider, _) = await OpenTableAsync(json, "Upgrades.json"); + + source.TrackPropertyChange("a", nameof(UpgradeRow.FirstCost), 20, out _); + var saved = await SaveAsync(source, provider, "Upgrades.json"); + + var serializer = new DataSerializerFactory().GetSerializer("Upgrades.json"); + var expected = serializer.SerializeTable(serializer.DeserializeTable(json.Replace("\"FirstCost\":10", "\"FirstCost\":20"))); + Assert.Equal(expected, saved); + } + + [Fact] + public async Task LargeTable_ChangingOneRow_IsFastAndExact() + { + var sb = new StringBuilder("# generated table\n\n"); + for (var i = 0; i < 3000; i++) + { + sb.Append("# row ").Append(i).Append('\n'); + sb.Append("- Id: row-").Append(i).Append('\n'); + sb.Append(" Name: Row ").Append(i).Append(" # name\n"); + sb.Append(" Base: ").Append(i).Append('\n'); + sb.Append(" Levels: [1, 2, 3]\n"); + sb.Append(" FirstCost: ").Append(i * 10).Append('\n'); + sb.Append(" RequiresUpgrade: \"\"\n"); + sb.Append(" Implemented: true\n\n"); + } + var yaml = sb.ToString(); + var (source, provider, _) = await OpenTableAsync(yaml); + + source.TrackPropertyChange("row-1500", nameof(UpgradeRow.FirstCost), 1, out _); + var stopwatch = Stopwatch.StartNew(); + var saved = await SaveAsync(source, provider); + stopwatch.Stop(); + + Assert.Equal(Replace(yaml, "FirstCost: 15000\n", "FirstCost: 1\n"), saved); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10), $"Saving took {stopwatch.Elapsed}."); + } + + #endregion + } +} diff --git a/Datra.Tests/YamlCommentPreserverTests.cs b/Datra.Tests/YamlCommentPreserverTests.cs new file mode 100644 index 0000000..88c0907 --- /dev/null +++ b/Datra.Tests/YamlCommentPreserverTests.cs @@ -0,0 +1,250 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Datra.Interfaces; +using Datra.Serializers; +using Xunit; + +namespace Datra.Tests +{ + /// + /// Cases for that the repository API cannot reach + /// (reordering) or that are about when it must give up. + /// + public class YamlCommentPreserverTests + { + public class Row : ITableData + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public List Tags { get; set; } = new List(); + public Stats Stats { get; set; } = new Stats(); + public string Notes { get; set; } = string.Empty; + } + + public class Stats + { + public int Hp { get; set; } + public int Speed { get; set; } + } + + private static readonly YamlDataSerializer Serializer = new YamlDataSerializer(); + + private static Dictionary Load(string yaml) => Serializer.DeserializeTable(yaml); + + private static string Save(string original, Dictionary table) + => YamlCommentPreserver.Reconcile( + original, + Serializer.SerializeTable(table), + text => Serializer.DeserializeTable(text), + t => Serializer.SerializeTable(t)); + + private const string Yaml = + "# header\n" + + "\n" + + "# about a\n" + + "- Id: a\n" + + " Name: Alpha\n" + + " Tags: [x, y]\n" + + " Stats:\n" + + " Hp: 10 # base hp\n" + + " # speed is tiles per second\n" + + " Speed: 2\n" + + "\n" + + "# about b\n" + + "- Id: b\n" + + " Name: Beta # placeholder name\n" + + " Tags:\n" + + " - one # first\n" + + " - two # second\n" + + " - three # third\n" + + "\n" + + "# about c\n" + + "- Id: c\n" + + " Name: Gamma\n"; + + [Fact] + public void ReorderingRows_MovesEachRowWithItsComments() + { + var table = Load(Yaml); + var reordered = new Dictionary { ["c"] = table["c"], ["a"] = table["a"], ["b"] = table["b"] }; + + var saved = Save(Yaml, reordered); + + var expected = + "# header\n" + + "\n" + + "# about c\n" + + "- Id: c\n" + + " Name: Gamma\n" + + "# about a\n" + + "- Id: a\n" + + " Name: Alpha\n" + + " Tags: [x, y]\n" + + " Stats:\n" + + " Hp: 10 # base hp\n" + + " # speed is tiles per second\n" + + " Speed: 2\n" + + "\n" + + "# about b\n" + + "- Id: b\n" + + " Name: Beta # placeholder name\n" + + " Tags:\n" + + " - one # first\n" + + " - two # second\n" + + " - three # third\n"; + Assert.Equal(expected, saved); + } + + [Fact] + public void RemovingMiddleOfPlainList_KeepsTheOtherEntriesComments() + { + var table = Load(Yaml); + table["b"].Tags = new List { "one", "three" }; + + var saved = Save(Yaml, table); + + Assert.Equal(Yaml.Replace(" - two # second\n", ""), saved); + } + + [Fact] + public void ChangingNestedObjectField_KeepsCommentsInTheNestedObject() + { + var table = Load(Yaml); + table["a"].Stats.Speed = 3; + + var saved = Save(Yaml, table); + + Assert.Equal(Yaml.Replace(" Speed: 2\n", " Speed: 3\n"), saved); + } + + [Fact] + public void ChangingToMultiLineText_WritesABlockScalarUnderTheField() + { + var table = Load(Yaml); + table["c"].Notes = "line one\nline two"; + + var saved = Save(Yaml, table); + + Assert.StartsWith(Yaml, saved); + Assert.Equal("line one\nline two", Load(saved)["c"].Notes); + } + + [Fact] + public void ChangingLiteralBlockText_KeepsTheCommentsAroundIt() + { + var yaml = Yaml.Replace( + " Name: Gamma\n", + " Name: Gamma\n # designer notes\n Notes: |\n first line\n second line\n # after notes\n"); + var table = Load(yaml); + table["c"].Notes = "only line"; + + var saved = Save(yaml, table); + + Assert.Equal(yaml.Replace("Notes: |\n first line\n second line\n", "Notes: only line\n"), saved); + } + + [Fact] + public void FillingAnEmptyField_WritesTheValueAfterTheColon() + { + var yaml = Yaml.Replace(" Name: Gamma\n", " Name: # to be named\n"); + var table = Load(yaml); + table["c"].Name = "Gamma"; + + var saved = Save(yaml, table); + + Assert.Equal(yaml.Replace(" Name: #", " Name: Gamma #"), saved); + } + + [Fact] + public void FieldsTheTypeDoesNotKnow_AreKeptWhenAnotherFieldChanges() + { + var yaml = Yaml.Replace(" Name: Gamma\n", " Name: Gamma\n Legacy: keep me\n"); + var table = Load(yaml); + table["c"].Name = "Gamma 2"; + + var saved = Save(yaml, table); + + Assert.Equal(yaml.Replace("Name: Gamma\n", "Name: Gamma 2\n"), saved); + } + + [Fact] + public void EmptyTableWithHeader_AddingARowKeepsTheHeader() + { + const string yaml = "# nothing yet\n[]\n"; + var table = Load(yaml); + table["a"] = new Row { Id = "a", Name = "Alpha" }; + + var saved = Save(yaml, table); + + Assert.StartsWith("# nothing yet\n- Id: a\n", saved); + Assert.Equal("Alpha", Load(saved)["a"].Name); + } + + [Fact] + public void DeletingEveryRow_KeepsTheHeader() + { + var saved = Save(Yaml, new Dictionary()); + + Assert.Equal("# header\n\n[]\n", saved); + Assert.Empty(Load(saved)); + } + + [Fact] + public void DocumentStartMarker_IsKept() + { + var yaml = "---\n" + Yaml; + var table = Load(yaml); + table["c"].Name = "Gamma 2"; + + var saved = Save(yaml, table); + + Assert.Equal(yaml.Replace("Name: Gamma\n", "Name: Gamma 2\n"), saved); + } + + [Fact] + public void AnchorsAndAliases_ChangingTheAnchoredValue_KeepsTheAliasUserCorrect() + { + const string yaml = + "# shared tags\n" + + "- Id: a\n" + + " Tags: &shared [x, y]\n" + + "- Id: b\n" + + " Tags: *shared\n"; + var table = Load(yaml); + table["a"].Tags = new List { "z" }; + + var saved = Save(yaml, table); + + // The anchored value cannot be patched in place without leaving *shared + // dangling; both rows are rewritten and the data stays right. + var reloaded = Load(saved); + Assert.Equal(new List { "z" }, reloaded["a"].Tags); + Assert.Equal(new List { "x", "y" }, reloaded["b"].Tags); + Assert.StartsWith("# shared tags\n", saved); + } + + [Fact] + public void Merge_ReturnsNullForMultipleDocuments() + { + var canonical = Serializer.SerializeTable(Load(Yaml)); + Assert.Null(YamlCommentPreserver.Merge(Yaml + "---\n- Id: z\n", canonical, canonical)); + } + + [Fact] + public void Reconcile_WithoutOriginal_ReturnsTheSerializerOutput() + { + var canonical = Serializer.SerializeTable(Load(Yaml)); + Assert.Same(canonical, YamlCommentPreserver.Reconcile>(null, canonical, Load, t => Serializer.SerializeTable(t))); + Assert.Same(canonical, YamlCommentPreserver.Reconcile>(" \n", canonical, Load, t => Serializer.SerializeTable(t))); + } + + [Fact] + public void Reconcile_WithUnreadableOriginal_ReturnsTheSerializerOutput() + { + var canonical = Serializer.SerializeTable(Load(Yaml)); + var saved = YamlCommentPreserver.Reconcile>("- Id: [unclosed\n", canonical, Load, t => Serializer.SerializeTable(t)); + Assert.Equal(canonical, saved); + } + } +} diff --git a/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs b/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs new file mode 100644 index 0000000..5da778a --- /dev/null +++ b/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs @@ -0,0 +1,99 @@ +#nullable disable +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Datra.Editor.DataSources; +using Datra.Interfaces; +using Datra.Repositories; +using Datra.Serializers; +using Datra.Unity.Editor.Providers; +using NUnit.Framework; +using UnityEditor; +using UnityEngine.TestTools; + +namespace Datra.Unity.Tests +{ + /// + /// The Data Editor's save path inside Unity: AssetDatabaseRawDataProvider reads the + /// file back through the AssetDatabase at save time, so this checks that comments + /// survive that path, including a second save after the asset was re-imported. + /// + public class YamlCommentPreservationTests + { + public class UpgradeRow : ITableData + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public List Levels { get; set; } = new List(); + public int FirstCost { get; set; } + } + + private const string Folder = "Assets/DatraYamlCommentTest"; + private const string FileName = "Upgrades.yaml"; + + private const string Yaml = + "# Upgrades: what the player can buy between runs.\n" + + "\n" + + "# The first thing a new player buys.\n" + + "- Id: push-strength\n" + + " Name: Push strength\n" + + " Levels: [0.9, 1.3, 1.6]\n" + + " FirstCost: 50 # cheap on purpose\n" + + "\n" + + "- Id: domes\n" + + " Name: Balance dome\n" + + " Levels:\n" + + " - 4 # fits the short stage\n" + + " - 5\n" + + " FirstCost: 150\n"; + + [SetUp] + public void SetUp() + { + Directory.CreateDirectory(Folder); + File.WriteAllText(Path.Combine(Folder, FileName), Yaml); + AssetDatabase.ImportAsset(Folder + "/" + FileName); + } + + [TearDown] + public void TearDown() + { + AssetDatabase.DeleteAsset(Folder); + } + + [UnityTest] + public IEnumerator SavingFromTheEditor_KeepsComments() + { + var repository = new KeyValueDataRepository( + FileName, + new AssetDatabaseRawDataProvider(basePath: Folder), + new DataSerializerFactory(), + (data, serializer) => serializer.DeserializeTable(data), + (table, serializer) => serializer.SerializeTable(table)); + yield return Wait(repository.InitializeAsync()); + + var source = new EditableKeyValueDataSource(repository); + source.TrackPropertyChange("push-strength", nameof(UpgradeRow.FirstCost), 55, out _); + yield return Wait(source.SaveAsync()); + + var expected = Yaml.Replace("FirstCost: 50 #", "FirstCost: 55 #"); + Assert.AreEqual(expected, File.ReadAllText(Path.Combine(Folder, FileName))); + + // Second save: the file is read back through the re-imported TextAsset. + source.TrackPropertyChange("domes", nameof(UpgradeRow.Levels), new List { 4, 6 }, out _); + yield return Wait(source.SaveAsync()); + + expected = expected.Replace(" - 5\n", " - 6\n"); + Assert.AreEqual(expected, File.ReadAllText(Path.Combine(Folder, FileName))); + } + + private static IEnumerator Wait(Task task) + { + while (!task.IsCompleted) + yield return null; + if (task.IsFaulted) + Assert.Fail(task.Exception?.ToString()); + } + } +} diff --git a/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs.meta b/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs.meta new file mode 100644 index 0000000..7bb1fc0 --- /dev/null +++ b/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9934a38c7d664f70bd26931b6a0c0ba8 \ No newline at end of file diff --git a/Datra/Repositories/KeyValueDataRepository.cs b/Datra/Repositories/KeyValueDataRepository.cs index 96eb443..0210be9 100644 --- a/Datra/Repositories/KeyValueDataRepository.cs +++ b/Datra/Repositories/KeyValueDataRepository.cs @@ -4,8 +4,10 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Datra.Attributes; using Datra.Interfaces; using Datra.Serializers; +using Datra.Utilities; namespace Datra.Repositories { @@ -25,6 +27,12 @@ public class KeyValueDataRepository : EditableTableRepository>? _csvDeserializeFunc; private readonly Func, string>? _csvSerializeFunc; + /// + /// The text the table was last loaded from or saved as. Used to keep a YAML file's + /// comments when the provider cannot read the file back at save time. + /// + private string? _lastKnownText; + /// /// 로드된 파일 경로 /// @@ -91,6 +99,7 @@ public KeyValueDataRepository( } LoadedFilePath = _rawDataProvider.ResolveFilePath(_filePath); + _lastKnownText = rawData; Dictionary data; @@ -143,6 +152,20 @@ protected override async Task SaveAllDataAsync( { var serializer = ResolveSerializer(); rawData = _serializeFunc(allData, serializer); + + // A YAML file is hand-written: carry its comments and layout over to the + // rewritten text instead of replacing it wholesale. + if (_deserializeFunc != null && IsYaml()) + { + var serializeFunc = _serializeFunc; + var deserializeFunc = _deserializeFunc; + var original = await ReadCurrentTextAsync(); + rawData = YamlCommentPreserver.Reconcile( + original, + rawData, + text => deserializeFunc(text, serializer), + table => serializeFunc(table, serializer)); + } } else { @@ -150,6 +173,35 @@ protected override async Task SaveAllDataAsync( } await _rawDataProvider.SaveTextAsync(_filePath, rawData); + _lastKnownText = rawData; + } + + /// + /// The file as it is now, so comments added outside the editor since loading are + /// kept too; the text last loaded or saved if the provider cannot read it. + /// + private async Task ReadCurrentTextAsync() + { + try + { + if (!_rawDataProvider.Exists(_filePath)) + return _lastKnownText; + return await _rawDataProvider.LoadTextAsync(_filePath); + } + catch (Exception) + { + return _lastKnownText; + } + } + + private bool IsYaml() + { + if (_rawDataProvider is IFormatAwareRawDataProvider fa && + fa.GetFormat(_filePath) is { } overrideFmt) + { + return overrideFmt == DataFormat.Yaml; + } + return DataFormatHelper.TryDetectFormat(_filePath, out var format) && format == DataFormat.Yaml; } /// diff --git a/Datra/Repositories/SingleDataRepository.cs b/Datra/Repositories/SingleDataRepository.cs index ec65fd0..44ac17e 100644 --- a/Datra/Repositories/SingleDataRepository.cs +++ b/Datra/Repositories/SingleDataRepository.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using Datra.Attributes; using Datra.Interfaces; using Datra.Serializers; +using Datra.Utilities; namespace Datra.Repositories { @@ -21,6 +23,12 @@ public class SingleDataRepository : EditableSingleRepository, IEdi private readonly Func _deserializeFunc; private readonly Func? _serializeFunc; + /// + /// The text the data was last loaded from or saved as; see + /// . + /// + private string? _lastKnownText; + public SingleDataRepository( string filePath, IRawDataProvider rawDataProvider, @@ -62,6 +70,7 @@ public IEnumerable EnumerateItems() { var rawData = await _rawDataProvider.LoadTextAsync(_filePath); LoadedFilePath = _rawDataProvider.ResolveFilePath(_filePath); + _lastKnownText = rawData; var serializer = ResolveSerializer(); return _deserializeFunc(rawData, serializer); } @@ -80,7 +89,45 @@ protected override async Task SaveDataAsync(TData data) var serializer = ResolveSerializer(); var rawData = _serializeFunc(data, serializer); + + // Keep a hand-written YAML file's comments and layout. + if (IsYaml()) + { + var serializeFunc = _serializeFunc; + var original = await ReadCurrentTextAsync(); + rawData = YamlCommentPreserver.Reconcile( + original, + rawData, + text => _deserializeFunc(text, serializer), + obj => serializeFunc(obj, serializer)); + } + await _rawDataProvider.SaveTextAsync(_filePath, rawData); + _lastKnownText = rawData; + } + + private async Task ReadCurrentTextAsync() + { + try + { + if (!_rawDataProvider.Exists(_filePath)) + return _lastKnownText; + return await _rawDataProvider.LoadTextAsync(_filePath); + } + catch (Exception) + { + return _lastKnownText; + } + } + + private bool IsYaml() + { + if (_rawDataProvider is IFormatAwareRawDataProvider fa && + fa.GetFormat(_filePath) is { } overrideFmt) + { + return overrideFmt == DataFormat.Yaml; + } + return DataFormatHelper.TryDetectFormat(_filePath, out var format) && format == DataFormat.Yaml; } /// diff --git a/Datra/Serializers/YamlCommentPreserver.cs b/Datra/Serializers/YamlCommentPreserver.cs new file mode 100644 index 0000000..216ce72 --- /dev/null +++ b/Datra/Serializers/YamlCommentPreserver.cs @@ -0,0 +1,1214 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace Datra.Serializers +{ + /// + /// Keeps the comments and layout of a hand-written YAML file when Datra saves it. + /// + /// Datra serializes a table by rebuilding the whole document from objects, and + /// YamlDotNet's object model has nowhere to keep comments, so a save used to throw + /// away every # line a designer wrote. This class does not try to make the + /// object model carry comments. It diffs two canonical renderings instead: + /// + /// originalCanonical: what the serializer writes for the data as it is in the file now, + /// updatedCanonical: what the serializer writes for the data being saved, + /// + /// and replays only the differences onto the original text. Rows and fields whose + /// canonical form did not change are copied from the original byte for byte, so + /// comments, blank lines, quoting and flow lists ([1, 2]) all survive. + /// + /// Comment ownership, which decides what happens on delete and reorder: + /// + /// comment lines directly above a row or field (no blank line in between) belong to it, + /// a comment at the end of a line belongs to that line, + /// a comment block separated from the next row by a blank line is a section comment and stays where it is, + /// comments before the first row's own comments are the file header and stay at the top. + /// + /// + /// Anything this cannot patch safely (anchors, aliases, multi-document files, a + /// structure it does not recognise) makes return null, and + /// then writes the plain canonical text, which is exactly + /// what Datra wrote before this class existed. also + /// re-reads its own output and falls back the same way if the data does not come + /// back identical, so a bug here can lose comments but never data. + /// + public static class YamlCommentPreserver + { + /// + /// Returns the text to write for , carrying over the + /// comments and layout of . + /// + /// The file as it is on disk now. Null or blank: nothing to preserve. + /// The canonical text the serializer produced for the data being saved. + /// The same deserializer the repository loads the file with. + /// The same serializer that produced . + public static string Reconcile( + string? originalText, + string updatedText, + Func deserialize, + Func serialize) + { + if (string.IsNullOrWhiteSpace(originalText)) + return updatedText; + + try + { + var originalCanonical = serialize(deserialize(originalText!)); + + // Nothing changed: keep the file exactly as it is. + if (originalCanonical == updatedText) + return originalText!; + + var merged = Merge(originalText!, originalCanonical, updatedText); + if (merged == null) + return updatedText; + + // The merged text must load as exactly what the plain text would load as. + // (Compared after a reload on both sides: a value the serializer omits, such + // as a null with a non-null default, does not survive a reload either way.) + var mergedReloaded = serialize(deserialize(merged)); + if (mergedReloaded != updatedText && mergedReloaded != serialize(deserialize(updatedText))) + return updatedText; + + return merged; + } + catch (Exception) + { + return updatedText; + } + } + + /// + /// Replays the difference between two canonical renderings onto the original text. + /// Returns null when the original cannot be patched safely. + /// + /// The hand-written file, with comments. + /// Canonical rendering of the data in . + /// Canonical rendering of the data to save. + public static string? Merge(string originalText, string originalCanonical, string updatedCanonical) + { + if (originalText == null) throw new ArgumentNullException(nameof(originalText)); + if (originalCanonical == null) throw new ArgumentNullException(nameof(originalCanonical)); + if (updatedCanonical == null) throw new ArgumentNullException(nameof(updatedCanonical)); + + var crlf = originalText.Contains("\r\n"); + var o = originalText.Replace("\r\n", "\n"); + var r0Text = originalCanonical.Replace("\r\n", "\n"); + var r1Text = updatedCanonical.Replace("\r\n", "\n"); + + Node? oRoot, r0Root, r1Root; + try + { + oRoot = Node.ParseDocument(o); + r0Root = Node.ParseDocument(r0Text); + r1Root = Node.ParseDocument(r1Text); + } + catch (YamlException) + { + return null; + } + + if (oRoot == null || r0Root == null || r1Root == null) + return null; + + var merger = new Merger(o, r1Text); + string? result; + + if (oRoot.Kind == NodeKind.Sequence && !oRoot.Flow) + { + if (r0Root.Kind != NodeKind.Sequence || r1Root.Kind != NodeKind.Sequence) + return null; + result = merger.MergeSequence(oRoot, r0Root, r1Root, root: true); + } + else + { + var replacement = merger.Render(oRoot, r0Root, r1Root, 0, 0, root: true); + result = replacement == null + ? null + : o.Substring(0, oRoot.Start) + replacement + o.Substring(oRoot.SpanEnd); + } + + if (result == null) + return null; + + return crlf ? result.Replace("\n", "\r\n") : result; + } + + #region Node model + + private enum NodeKind + { + Scalar, + Mapping, + Sequence, + Alias + } + + /// + /// A YAML node with its position in the source text. YamlDotNet's representation + /// model resolves aliases and loses where a block collection really ends, so this + /// is built straight from parser events. + /// + private sealed class Node + { + public NodeKind Kind; + public bool Flow; + public int Start; + + /// End of the last character that belongs to the node's content. + public int ContentEnd; + + /// + /// End of the region a rendering of this node replaces. For a block collection + /// this runs to the end of its last line, so a trailing comment on that line + /// is part of the collection; for anything else it is . + /// + public int SpanEnd; + + public string Value = string.Empty; + public ScalarStyle Style; + public string Tag = string.Empty; + public bool HasAnchor; + + public readonly List Items = new List(); + public readonly List Keys = new List(); + public readonly List Values = new List(); + + /// An implicit null (Key: or - with nothing after it). + public bool IsEmpty => Kind == NodeKind.Scalar && Style == ScalarStyle.Plain && Value.Length == 0 && ContentEnd == Start; + + public bool IsBlockCollection => (Kind == NodeKind.Mapping || Kind == NodeKind.Sequence) && !Flow; + + public static Node? ParseDocument(string text) + { + var parser = new Parser(new StringReader(text)); + parser.Consume(); + if (parser.Accept(out _)) + return null; + + parser.Consume(); + var root = Read(parser, text); + parser.Consume(); + + // More than one document: not something a data file should be. + if (!parser.Accept(out _)) + return null; + + return root; + } + + private static Node Read(IParser parser, string text) + { + var ev = parser.Current; + parser.MoveNext(); + + switch (ev) + { + case Scalar scalar: + { + var node = new Node + { + Kind = NodeKind.Scalar, + Start = (int)scalar.Start.Index, + Value = scalar.Value, + Style = scalar.Style, + Tag = scalar.Tag.IsEmpty ? string.Empty : scalar.Tag.Value, + HasAnchor = !scalar.Anchor.IsEmpty, + }; + var end = (int)scalar.End.Index; + // Block scalars (| and >) report an end past their trailing line breaks. + while (end > node.Start && char.IsWhiteSpace(text[end - 1])) + end--; + node.ContentEnd = end; + node.SpanEnd = end; + return node; + } + + case AnchorAlias alias: + return new Node + { + Kind = NodeKind.Alias, + Start = (int)alias.Start.Index, + ContentEnd = (int)alias.End.Index, + SpanEnd = (int)alias.End.Index, + }; + + case SequenceStart sequenceStart: + { + var node = new Node + { + Kind = NodeKind.Sequence, + Flow = sequenceStart.Style == SequenceStyle.Flow, + Start = (int)sequenceStart.Start.Index, + Tag = sequenceStart.Tag.IsEmpty ? string.Empty : sequenceStart.Tag.Value, + HasAnchor = !sequenceStart.Anchor.IsEmpty, + }; + while (!(parser.Current is SequenceEnd)) + node.Items.Add(Read(parser, text)); + var sequenceEnd = parser.Current; + parser.MoveNext(); + node.FinishCollection(text, sequenceEnd!); + return node; + } + + case MappingStart mappingStart: + { + var node = new Node + { + Kind = NodeKind.Mapping, + Flow = mappingStart.Style == MappingStyle.Flow, + Start = (int)mappingStart.Start.Index, + Tag = mappingStart.Tag.IsEmpty ? string.Empty : mappingStart.Tag.Value, + HasAnchor = !mappingStart.Anchor.IsEmpty, + }; + while (!(parser.Current is MappingEnd)) + { + node.Keys.Add(Read(parser, text)); + node.Values.Add(Read(parser, text)); + } + var mappingEnd = parser.Current; + parser.MoveNext(); + node.FinishCollection(text, mappingEnd!); + return node; + } + + default: + throw new YamlException($"Unexpected YAML event {ev?.GetType().Name}."); + } + } + + private void FinishCollection(string text, ParsingEvent endEvent) + { + if (Flow) + { + // The end event of a flow collection starts at its closing bracket. + var bracket = (int)endEvent.Start.Index; + if (bracket >= text.Length || (text[bracket] != ']' && text[bracket] != '}')) + throw new YamlException("Could not locate the end of a flow collection."); + ContentEnd = bracket + 1; + SpanEnd = bracket + 1; + return; + } + + // A block collection's end event sits wherever the next token starts, + // past any comments, so its real end is its last piece of content. + var end = Start; + if (Kind == NodeKind.Sequence) + { + foreach (var item in Items) + end = Math.Max(end, item.IsEmpty ? item.Start : item.ContentEnd); + } + else + { + for (var i = 0; i < Keys.Count; i++) + end = Math.Max(end, EntryContentEnd(text, Keys[i], Values[i])); + } + + ContentEnd = end; + SpanEnd = EndOfLine(text, end); + } + } + + #endregion + + #region Merger + + private sealed class Merger + { + private readonly string _o; + private readonly string _u; + + public Merger(string original, string updatedCanonical) + { + _o = original; + _u = updatedCanonical; + } + + /// + /// New text for the region [o.Start, o.SpanEnd) of the original, or null when + /// the node cannot be patched in place (the caller then replaces a bigger unit). + /// + /// Column of the key or dash that owns in the original. + /// Column of the key or dash that owns in the updated text. + public string? Render(Node o, Node? r0, Node r1, int oAnchor, int rAnchor, bool root = false) + { + if (o.Kind == NodeKind.Alias || o.HasAnchor || o.IsEmpty) + return null; + + if (r0 != null && o.IsBlockCollection && r1.IsBlockCollection && o.Kind == r0.Kind && r0.Kind == r1.Kind) + { + return o.Kind == NodeKind.Mapping + ? MergeMapping(o, r0, r1) + : MergeSequence(o, r0, r1, root: false); + } + + var text = ReplacementText(o, r1, oAnchor, rAnchor, root); + if (text == null) + return null; + + // Keep whatever followed the content on its last line (a trailing comment). + return text + _o.Substring(o.ContentEnd, o.SpanEnd - o.ContentEnd); + } + + private string? ReplacementText(Node o, Node r1, int oAnchor, int rAnchor, bool root) + { + if (r1.Kind == NodeKind.Alias) + return null; + + if (r1.Kind == NodeKind.Scalar) + return ScalarText(o, r1, oAnchor, rAnchor); + + // An empty collection is written as [] or {} and fits anywhere. + if (r1.Flow) + return Slice(_u, r1.Start, r1.ContentEnd); + + if (o.Flow && !root) + { + // Keep a flow list flow: [1, 2] stays on its line. + var flow = FlowText(r1); + if (flow != null) + return flow; + } + + if (o.Kind == NodeKind.Scalar) + return null; + + if (!CanPlaceBlock(_o, o.Start)) + return null; + + return Reindent(Slice(_u, r1.Start, r1.ContentEnd), Column(_o, o.Start) - Column(_u, r1.Start)); + } + + private string? ScalarText(Node o, Node r1, int oAnchor, int rAnchor) + { + var raw = Slice(_u, r1.Start, r1.ContentEnd); + + // Keep the author's quoting ("text" or 'text') when the new value allows it. + if (o.Kind == NodeKind.Scalar + && (o.Style == ScalarStyle.SingleQuoted || o.Style == ScalarStyle.DoubleQuoted) + && r1.Tag.Length == 0 + && (r1.Style == ScalarStyle.Plain || r1.Style == ScalarStyle.SingleQuoted || r1.Style == ScalarStyle.DoubleQuoted) + && !(r1.Style == ScalarStyle.Plain && IsNullLike(r1.Value)) + && r1.Value.IndexOf('\n') < 0) + { + return o.Style == ScalarStyle.SingleQuoted + ? SingleQuote(r1.Value) ?? DoubleQuote(r1.Value) + : DoubleQuote(r1.Value); + } + + if (o.Flow) + return raw.IndexOf('\n') < 0 ? raw : null; + + return Reindent(raw, oAnchor - rAnchor); + } + + #region Mapping + + private sealed class Edit + { + public int Start; + public int End; + public string Text = string.Empty; + public bool IsInsertion => Start == End; + } + + public string? MergeMapping(Node o, Node r0, Node r1) + { + var oIndex = IndexKeys(o); + var r0Index = IndexKeys(r0); + var r1Index = IndexKeys(r1); + if (oIndex == null || r0Index == null || r1Index == null) + return null; + + var oCol = Column(_o, o.Start); + var rCol = Column(_u, r1.Start); + var edits = new List(); + var removed = new HashSet(); + + // Fields that are gone (a value became null and is omitted). + for (var i = 0; i < r0.Keys.Count; i++) + { + var key = r0.Keys[i].Value; + if (r1Index.ContainsKey(key) || !oIndex.TryGetValue(key, out var oi)) + continue; + + var removal = RemovalEdit(o, oi); + if (removal == null) + return null; + edits.Add(removal); + removed.Add(oi); + } + + var insertions = new SortedDictionary>(); + + for (var p = 0; p < r1.Keys.Count; p++) + { + var key = r1.Keys[p].Value; + var v1 = r1.Values[p]; + Node? v0 = r0Index.TryGetValue(key, out var r0i) ? r0.Values[r0i] : null; + + if (v0 != null && DeepEqual(v0, v1)) + continue; + + if (oIndex.TryGetValue(key, out var oi)) + { + var ov = o.Values[oi]; + var rendered = Render(ov, v0, v1, oCol, rCol); + if (rendered != null) + { + edits.Add(new Edit { Start = ov.Start, End = ov.SpanEnd, Text = rendered }); + } + else + { + // Rewrite the whole "Key: value" entry; its trailing comment stays. + edits.Add(new Edit + { + Start = o.Keys[oi].Start, + End = EntryContentEnd(_o, o.Keys[oi], ov), + Text = EntryText(r1, p, oCol), + }); + } + continue; + } + + // A field the file did not spell out: insert it after its predecessor. + var after = -1; + for (var q = p - 1; q >= 0 && after < 0; q--) + { + if (oIndex.TryGetValue(r1.Keys[q].Value, out var candidate) && !removed.Contains(candidate)) + after = candidate; + } + if (after < 0) + { + for (var j = o.Keys.Count - 1; j >= 0 && after < 0; j--) + { + if (!removed.Contains(j)) + after = j; + } + } + if (after < 0) + return null; + + var position = EndOfLine(_o, EntryContentEnd(_o, o.Keys[after], o.Values[after])); + if (!insertions.TryGetValue(position, out var list)) + insertions[position] = list = new List(); + list.Add("\n" + new string(' ', oCol) + EntryText(r1, p, oCol)); + } + + foreach (var insertion in insertions) + edits.Add(new Edit { Start = insertion.Key, End = insertion.Key, Text = string.Concat(insertion.Value) }); + + return ApplyEdits(o.Start, o.SpanEnd, edits); + } + + private Edit? RemovalEdit(Node o, int index) + { + // The first entry shares its line with "- " or the parent key; leave that + // to the caller, which rewrites the whole mapping. + if (index == 0) + return null; + + var key = o.Keys[index]; + var lineStart = LineStart(_o, key.Start); + if (!IsBlank(Slice(_o, lineStart, key.Start))) + return null; + + var previousEnd = EntryContentEnd(_o, o.Keys[index - 1], o.Values[index - 1]); + var keyColumn = key.Start - lineStart; + var start = lineStart; + + // Comment lines directly above the field go with it (deeper-indented ones + // close the previous field's nested block and stay). + while (start > 0) + { + var previousLineStart = LineStart(_o, start - 1); + if (previousLineStart < previousEnd) + break; + var line = Slice(_o, previousLineStart, start - 1); + if (!IsComment(line) || IndentOf(line) > keyColumn) + break; + start = previousLineStart; + } + + if (start - 1 < o.Start) + return null; + + return new Edit + { + Start = start - 1, + End = EndOfLine(_o, EntryContentEnd(_o, key, o.Values[index])), + Text = string.Empty, + }; + } + + private string EntryText(Node r1, int index, int targetColumn) + { + var key = r1.Keys[index]; + var text = Slice(_u, key.Start, EntryContentEnd(_u, key, r1.Values[index])); + return Reindent(text, targetColumn - Column(_u, key.Start)); + } + + private string? ApplyEdits(int start, int end, List edits) + { + edits.Sort((a, b) => + { + var byStart = a.Start.CompareTo(b.Start); + if (byStart != 0) return byStart; + // At the same position an insertion goes before a replacement. + return b.IsInsertion.CompareTo(a.IsInsertion); + }); + + var sb = new StringBuilder(); + var cursor = start; + foreach (var edit in edits) + { + if (edit.Start < cursor || edit.End > end) + return null; + sb.Append(_o, cursor, edit.Start - cursor); + sb.Append(edit.Text); + cursor = edit.End; + } + sb.Append(_o, cursor, end - cursor); + return sb.ToString(); + } + + #endregion + + #region Sequence + + private sealed class Unit + { + /// Lines before the unit that are not attached to it (blank lines, section comments). + public List Detached = new List(); + /// Comment lines directly above the dash. + public List Attached = new List(); + /// Indentation of the dash line. + public string Indent = string.Empty; + /// From the dash to the end of the item's last line. + public string Body = string.Empty; + /// Indented comment lines right after the item. + public List Trailing = new List(); + } + + /// + /// New text for a block sequence. For the document root this is the whole + /// document (header and footer included); otherwise the region [o.Start, o.SpanEnd). + /// + public string? MergeSequence(Node o, Node r0, Node r1, bool root) + { + var n = o.Items.Count; + if (n == 0 || r0.Items.Count != n || o.Flow) + return null; + + var dash = new int[n]; + var bodyEnd = new int[n]; + for (var j = 0; j < n; j++) + { + var item = o.Items[j]; + if (item.Kind == NodeKind.Alias || item.HasAnchor) + return null; + dash[j] = FindDash(_o, item); + if (dash[j] < 0) + return null; + bodyEnd[j] = EndOfLine(_o, ItemContentEnd(item, dash[j])); + } + + var seqCol = Column(_o, dash[0]); + var regionStart = root ? 0 : o.Start; + var regionEnd = root ? _o.Length : o.SpanEnd; + if (regionStart > dash[0] || bodyEnd[n - 1] > regionEnd) + return null; + + var units = new Unit[n]; + for (var j = 0; j < n; j++) + units[j] = new Unit { Body = Slice(_o, dash[j], bodyEnd[j]) }; + + // Before the first dash: the file header (root) and the first item's comments. + { + var lineStart = LineStart(_o, dash[0]); + var indent = Slice(_o, lineStart, dash[0]); + if (!IsBlank(indent)) + return null; + units[0].Indent = indent; + if (root) + { + var before = Slice(_o, 0, lineStart); + var lines = SplitLines(before); + SplitAttached(lines, units[0].Detached, units[0].Attached); + } + } + + // Between items. + for (var j = 1; j < n; j++) + { + var gap = Slice(_o, bodyEnd[j - 1], dash[j]); + if (gap.Length == 0 || gap[0] != '\n') + return null; + var pieces = gap.Substring(1).Split('\n'); + var indent = pieces[pieces.Length - 1]; + if (!IsBlank(indent)) + return null; + + var k = 0; + while (k < pieces.Length - 1 && IsComment(pieces[k]) && IndentOf(pieces[k]) > seqCol) + units[j - 1].Trailing.Add(pieces[k++]); + + var rest = new List(); + for (; k < pieces.Length - 1; k++) + rest.Add(pieces[k]); + SplitAttached(rest, units[j].Detached, units[j].Attached); + units[j].Indent = indent; + } + + // After the last item (root only; a nested sequence ends at its last line). + var suffix = Slice(_o, bodyEnd[n - 1], regionEnd); + if (root && suffix.Length > 0 && suffix[0] == '\n') + { + var pieces = suffix.Substring(1).Split('\n'); + var k = 0; + while (k < pieces.Length - 1 && IsComment(pieces[k]) && IndentOf(pieces[k]) > seqCol) + units[n - 1].Trailing.Add(pieces[k++]); + suffix = "\n" + string.Join("\n", pieces, k, pieces.Length - k); + } + + var match = MatchItems(r0.Items, r1.Items); + var survives = new bool[n]; + foreach (var j in match) + { + if (j >= 0) + survives[j] = true; + } + + // Section comments of deleted items move to the next surviving item. + var detached = new List[n]; + var carry = new List(); + for (var j = 0; j < n; j++) + { + var own = j == 0 ? new List() : units[j].Detached; + if (!survives[j]) + { + if (own.Any(IsComment)) + carry = MergeBlankLines(carry, own); + continue; + } + detached[j] = MergeBlankLines(carry, own); + carry = new List(); + } + + // What goes above an added item: the blank lines this file puts between items. + var separator = new List(); + if (n >= 2 && units[n - 1].Detached.All(IsBlank)) + separator = units[n - 1].Detached; + + var sb = new StringBuilder(); + foreach (var line in units[0].Detached) + sb.Append(line).Append('\n'); + + var first = true; + var oDashCol = seqCol; + for (var k = 0; k < r1.Items.Count; k++) + { + var j = match[k]; + List lead, attached, trailing; + string indent, body; + + if (j >= 0) + { + var unit = units[j]; + lead = detached[j]; + attached = unit.Attached; + trailing = unit.Trailing; + indent = unit.Indent; + if (DeepEqual(r0.Items[j], r1.Items[k])) + { + body = unit.Body; + } + else + { + var changed = ChangedItemBody(o.Items[j], dash[j], bodyEnd[j], r0.Items[j], r1.Items[k]); + if (changed == null) + return null; + body = changed; + } + } + else + { + lead = separator; + attached = new List(); + trailing = new List(); + indent = new string(' ', oDashCol); + var added = ItemText(r1.Items[k], oDashCol); + if (added == null) + return null; + body = added; + } + + // A nested sequence starts mid-line, right where its first dash was. + var atDash = first && !root; + + if (!first) + sb.Append('\n'); + if (first) + { + var header = units[0].Detached; + if (!root) + lead = new List(); // right under the parent key + else if (header.Count == 0 || IsBlank(header[header.Count - 1])) + lead = lead.SkipWhile(IsBlank).ToList(); // no doubled blank line under the header + } + + foreach (var line in lead) + sb.Append(line).Append('\n'); + foreach (var line in attached) + { + sb.Append(atDash ? line.TrimStart() : line).Append('\n'); + atDash = false; + } + if (!atDash) + sb.Append(indent); + sb.Append(body); + foreach (var line in trailing) + sb.Append('\n').Append(line); + + first = false; + } + + if (first) + { + // Every item is gone. + if (!root) + return null; + sb.Append(units[0].Indent).Append("[]"); + } + + foreach (var line in carry) + sb.Append('\n').Append(line); + + sb.Append(suffix); + return sb.ToString(); + } + + private string? ChangedItemBody(Node item, int dashIndex, int bodyEnd, Node r0Item, Node r1Item) + { + var r1Dash = FindDash(_u, r1Item); + if (r1Dash < 0) + return null; + + if (!item.IsEmpty) + { + var rendered = Render(item, r0Item, r1Item, Column(_o, dashIndex), Column(_u, r1Dash)); + if (rendered != null) + return Slice(_o, dashIndex, item.Start) + rendered + Slice(_o, item.SpanEnd, bodyEnd); + } + + // Rewrite the whole item; a comment at the end of its last line stays. + var text = ItemText(r1Item, Column(_o, dashIndex)); + if (text == null) + return null; + return text + Slice(_o, ItemContentEnd(item, dashIndex), bodyEnd); + } + + /// An item of the updated text, from its dash, placed at . + private string? ItemText(Node r1Item, int dashColumn) + { + var r1Dash = FindDash(_u, r1Item); + if (r1Dash < 0) + return null; + var text = Slice(_u, r1Dash, ItemContentEnd(r1Item, r1Dash)); + return Reindent(text, dashColumn - Column(_u, r1Dash)); + } + + /// + /// For each updated item, the index of the original item it continues, or -1 for a new one. + /// Rows with an Id are matched by it (so reorders and deletes keep their comments); + /// other lists are aligned on the items that did not change. + /// + private static int[] MatchItems(List r0, List r1) + { + var match = new int[r1.Count]; + + var ids0 = TryGetIds(r0); + var ids1 = ids0 == null ? null : TryGetIds(r1); + if (ids0 != null && ids1 != null) + { + var byId = new Dictionary(); + for (var j = 0; j < ids0.Count; j++) + byId[ids0[j]] = j; + for (var k = 0; k < r1.Count; k++) + match[k] = byId.TryGetValue(ids1[k], out var j) ? j : -1; + return match; + } + + if ((long)r0.Count * r1.Count > 250_000) + { + for (var k = 0; k < r1.Count; k++) + match[k] = k < r0.Count ? k : -1; + return match; + } + + // Longest common subsequence of unchanged items... + var n = r0.Count; + var m = r1.Count; + var lcs = new int[n + 1, m + 1]; + for (var j = n - 1; j >= 0; j--) + { + for (var k = m - 1; k >= 0; k--) + { + lcs[j, k] = DeepEqual(r0[j], r1[k]) + ? lcs[j + 1, k + 1] + 1 + : Math.Max(lcs[j + 1, k], lcs[j, k + 1]); + } + } + + for (var k = 0; k < m; k++) + match[k] = -1; + + var anchors = new List<(int j, int k)>(); + { + int j = 0, k = 0; + while (j < n && k < m) + { + if (DeepEqual(r0[j], r1[k])) + { + anchors.Add((j, k)); + j++; + k++; + } + else if (lcs[j + 1, k] >= lcs[j, k + 1]) + { + j++; + } + else + { + k++; + } + } + } + anchors.Add((n, m)); + + // ...and between them, changed items pair up in order. + int pj = 0, pk = 0; + foreach (var (aj, ak) in anchors) + { + var pairs = Math.Min(aj - pj, ak - pk); + for (var i = 0; i < pairs; i++) + match[pk + i] = pj + i; + if (ak < m) + match[ak] = aj; + pj = aj + 1; + pk = ak + 1; + } + + return match; + } + + private static List? TryGetIds(List items) + { + var ids = new List(items.Count); + var seen = new HashSet(); + foreach (var item in items) + { + if (item.Kind != NodeKind.Mapping) + return null; + string? id = null; + for (var i = 0; i < item.Keys.Count; i++) + { + if (item.Keys[i].Kind == NodeKind.Scalar && item.Keys[i].Value == "Id" && item.Values[i].Kind == NodeKind.Scalar) + { + id = item.Values[i].Value; + break; + } + } + if (id == null || !seen.Add(id)) + return null; + ids.Add(id); + } + return ids; + } + + private static void SplitAttached(List lines, List detached, List attached) + { + var split = lines.Count; + while (split > 0 && IsComment(lines[split - 1])) + split--; + for (var i = 0; i < split; i++) + detached.Add(lines[i]); + for (var i = split; i < lines.Count; i++) + attached.Add(lines[i]); + } + + private static List MergeBlankLines(List first, List second) + { + var result = new List(first); + var skipBlank = result.Count > 0 && IsBlank(result[result.Count - 1]); + foreach (var line in second) + { + if (skipBlank && IsBlank(line)) + continue; + skipBlank = false; + result.Add(line); + } + return result; + } + + #endregion + + #region Flow rendering + + private string? FlowText(Node node) + { + switch (node.Kind) + { + case NodeKind.Scalar: + { + if (node.Tag.Length > 0) + return null; + if (node.Style == ScalarStyle.Literal || node.Style == ScalarStyle.Folded || node.Value.IndexOf('\n') >= 0) + return DoubleQuote(node.Value); + var raw = Slice(_u, node.Start, node.ContentEnd); + if (node.Style == ScalarStyle.Plain) + { + if (node.Value.Length == 0) + return "null"; + if (raw.IndexOfAny(FlowIndicators) >= 0 || raw.Contains(": ") || raw.Contains(" #")) + return DoubleQuote(node.Value); + } + return raw.IndexOf('\n') < 0 ? raw : DoubleQuote(node.Value); + } + + case NodeKind.Sequence: + { + var parts = new List(); + foreach (var item in node.Items) + { + var part = FlowText(item); + if (part == null) + return null; + parts.Add(part); + } + return "[" + string.Join(", ", parts) + "]"; + } + + case NodeKind.Mapping: + { + var parts = new List(); + for (var i = 0; i < node.Keys.Count; i++) + { + var key = FlowText(node.Keys[i]); + var value = FlowText(node.Values[i]); + if (key == null || value == null) + return null; + parts.Add(key + ": " + value); + } + return "{" + string.Join(", ", parts) + "}"; + } + + default: + return null; + } + } + + private static readonly char[] FlowIndicators = { ',', '[', ']', '{', '}' }; + + #endregion + } + + #endregion + + #region Helpers + + private static bool DeepEqual(Node a, Node b) + { + if (a.Kind != b.Kind || a.Tag != b.Tag) + return false; + + switch (a.Kind) + { + case NodeKind.Scalar: + return a.Value == b.Value && a.Style == b.Style; + + case NodeKind.Sequence: + if (a.Items.Count != b.Items.Count) + return false; + for (var i = 0; i < a.Items.Count; i++) + { + if (!DeepEqual(a.Items[i], b.Items[i])) + return false; + } + return true; + + case NodeKind.Mapping: + if (a.Keys.Count != b.Keys.Count) + return false; + for (var i = 0; i < a.Keys.Count; i++) + { + if (!DeepEqual(a.Keys[i], b.Keys[i]) || !DeepEqual(a.Values[i], b.Values[i])) + return false; + } + return true; + + default: + return false; + } + } + + private static Dictionary? IndexKeys(Node mapping) + { + var index = new Dictionary(); + for (var i = 0; i < mapping.Keys.Count; i++) + { + var key = mapping.Keys[i]; + if (key.Kind != NodeKind.Scalar || key.HasAnchor || index.ContainsKey(key.Value)) + return null; + index[key.Value] = i; + } + return index; + } + + /// End of "Key: value" content; for "Key:" with no value, just past the colon. + private static int EntryContentEnd(string text, Node key, Node value) + { + if (!value.IsEmpty) + return Math.Max(key.ContentEnd, value.ContentEnd); + + var i = key.ContentEnd; + while (i < text.Length && text[i] == ' ') + i++; + return i < text.Length && text[i] == ':' ? i + 1 : key.ContentEnd; + } + + private static int ItemContentEnd(Node item, int dashIndex) + => item.IsEmpty ? dashIndex + 1 : item.ContentEnd; + + /// The '-' of a block sequence item, or -1. + private static int FindDash(string text, Node item) + { + var i = item.Start - 1; + while (i >= 0 && (text[i] == ' ' || text[i] == '\n')) + i--; + return i >= 0 && text[i] == '-' ? i : -1; + } + + /// True when only indentation and "- " indicators precede on its line. + private static bool CanPlaceBlock(string text, int index) + { + for (var i = LineStart(text, index); i < index; i++) + { + if (text[i] != ' ' && text[i] != '-') + return false; + } + return true; + } + + private static int LineStart(string text, int index) + { + if (index <= 0) + return 0; + var newline = text.LastIndexOf('\n', index - 1); + return newline + 1; + } + + private static int EndOfLine(string text, int index) + { + var newline = text.IndexOf('\n', Math.Min(index, text.Length)); + return newline < 0 ? text.Length : newline; + } + + private static int Column(string text, int index) => index - LineStart(text, index); + + private static string Slice(string text, int start, int end) => text.Substring(start, end - start); + + private static List SplitLines(string block) + { + // "a\nb\n" -> [a, b]; "" -> [] + var lines = new List(block.Split('\n')); + if (lines.Count > 0 && lines[lines.Count - 1].Length == 0) + lines.RemoveAt(lines.Count - 1); + return lines; + } + + private static bool IsBlank(string line) => string.IsNullOrWhiteSpace(line); + + private static bool IsComment(string line) => line.TrimStart().StartsWith("#", StringComparison.Ordinal); + + private static int IndentOf(string line) + { + var i = 0; + while (i < line.Length && line[i] == ' ') + i++; + return i; + } + + private static bool IsNullLike(string value) + => value.Length == 0 || value == "~" || value == "null" || value == "Null" || value == "NULL"; + + /// Shifts every line after the first by columns. + private static string Reindent(string text, int shift) + { + if (shift == 0 || text.IndexOf('\n') < 0) + return text; + + var lines = text.Split('\n'); + for (var i = 1; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Length == 0) + continue; + if (shift > 0) + { + lines[i] = new string(' ', shift) + line; + } + else + { + var remove = Math.Min(-shift, IndentOf(line)); + lines[i] = line.Substring(remove); + } + } + return string.Join("\n", lines); + } + + private static string? SingleQuote(string value) + { + foreach (var c in value) + { + if (c < ' ') + return null; + } + return "'" + value.Replace("'", "''") + "'"; + } + + private static string DoubleQuote(string value) + { + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '\\': sb.Append("\\\\"); break; + case '"': sb.Append("\\\""); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < ' ') + sb.Append("\\u").Append(((int)c).ToString("X4")); + else + sb.Append(c); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + #endregion + } +} diff --git a/Datra/Serializers/YamlCommentPreserver.cs.meta b/Datra/Serializers/YamlCommentPreserver.cs.meta new file mode 100644 index 0000000..f69c91f --- /dev/null +++ b/Datra/Serializers/YamlCommentPreserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ef8f5c7160da4f978e1b7f223d5c69df \ No newline at end of file diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0fefd1a..f1945ff 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -149,6 +149,43 @@ var settings = ServerSettingsSerializer.DeserializeYaml(yamlContent); var yaml = ServerSettingsSerializer.SerializeYaml(settings); ``` +**Comments survive saves:** + +When a repository saves a YAML file (the Unity Data Editor, the WebEditor, or +`SaveAsync()` on a table or single repository), it keeps the file's comments and +layout. Only the values that changed are rewritten; every untouched row stays +byte for byte as it was, including blank lines, quoting and flow lists like +`[1, 2]`. + +```yaml +# Upgrades: what the player can buy between runs. <- file header, stays on top + +# The first thing a new player buys. <- belongs to the row below +- Id: push-strength + Base: 0.5 # metres per second <- belongs to this line + Levels: [0.9, 1.3, 1.6] + +# --- Line upgrades --- <- section comment, stays put + +- Id: bottle-count + Levels: + - 4 # fits the short stage <- goes with this entry +``` + +- Comment lines directly above a row or field (no blank line in between) belong to it, + and are removed when it is deleted or move with it when rows are reordered. +- A comment at the end of a line stays on that line when its value changes. +- A comment block separated from the next row by a blank line is a section + comment and stays where it is. Comments above the first row's own comments are + the file header. +- New rows are appended in the file's style, with the same blank-line spacing. + +If a file uses something that cannot be patched in place (for example an anchor +whose value changes), the save falls back to writing the plain serializer +output, which is what every save did before; the data is always written +correctly. The static `SerializeYaml` methods and `IDataSerializer` do not know +the original file, so they still return plain serializer output. + ### Auto-Detection Format is auto-detected from file extension: