From 79f2d3bdb16017ec3b6eda1cec4c87fc7250b004 Mon Sep 17 00:00:00 2001 From: Chris Knight Date: Tue, 11 Aug 2026 10:56:40 -0400 Subject: [PATCH] Extract whole enum declarations as units so mod-added members survive merges Enum members were the one mod-added content class still living in gap territory after gap-handling v2 (var/default/autobind were promoted; enums weren't). Gap content reverts to vanilla on reassembly, so a mod extending a vanilla enum had the new member silently dropped while the code using it survived - "I dont know any 'HVS_Modcrab'", observed live (modalchemyrequiresmeditation extends hud.ws's EHudVisibilitySource). The merge's own audit note even flagged the loss ("content from modalchemyrequiresmeditation near this position was not preserved"). A whole `enum Name { ... }` block is now ONE unit (per-member extraction isn't viable - members are bare identifiers, not statements), keyed "enum:Name" so it can't collide with a same-named function. One side editing the enum takes that side's block; both sides editing goes through the normal per-unit 3-way merge/tiebreak; the #33/#34 output invariants cover enum units automatically. Verified against the real hud.ws pair: enum extracted on both sides, byte-exact round trips, HVS_Modcrab survives TryMerge. 2 new tests (158 total). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah --- .../Tools/ScriptUnitExtractor.cs | 58 ++++++++++++++++++- .../Tools/FunctionLevelMergeEngineTests.cs | 17 ++++++ .../Tools/ScriptUnitExtractorTests.cs | 19 ++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/WitcherScriptMerger.Core/Tools/ScriptUnitExtractor.cs b/WitcherScriptMerger.Core/Tools/ScriptUnitExtractor.cs index f14a654..9bb02ad 100644 --- a/WitcherScriptMerger.Core/Tools/ScriptUnitExtractor.cs +++ b/WitcherScriptMerger.Core/Tools/ScriptUnitExtractor.cs @@ -9,6 +9,16 @@ public enum ScriptUnitKind { Function, Field, + // A whole `enum Name { ... }` declaration, extracted as ONE unit (members and + // braces included). Enum members are bare identifiers, not statements, so they + // can't be per-member units - but leaving whole enums in gap territory meant a + // mod ADDING a member had that addition silently reverted to vanilla while the + // code using the new member survived ("I dont know any 'HVS_Modcrab'", + // observed live: modalchemyrequiresmeditation extends hud.ws's + // EHudVisibilitySource). As a single unit, an enum edited by one side takes + // that side's whole block, and a both-sides edit goes through the normal + // per-unit 3-way merge/tiebreak. + EnumDeclaration, // A plain (non-@addField) member declaration: `[specifiers] var a, b : T;`, // `default x = value;`, or `[specifiers] autobind c : T = ...;`. Promoted to // unit status (rather than living in gap territory) because real mods add @@ -58,7 +68,12 @@ public ScriptUnit(string name, string scopedName, ScriptUnitKind kind, bool hasB // The human-facing noun for audit/decision messages - "function OnSpawned" vs // "declaration mCSMCR" - so FunctionLevelMergeEngine's notes don't call a // variable a function. - public string DescribeKind() => Kind == ScriptUnitKind.Function ? "function" : "declaration"; + public string DescribeKind() => Kind switch + { + ScriptUnitKind.Function => "function", + ScriptUnitKind.EnumDeclaration => "enum", + _ => "declaration", + }; } // A file split into alternating gap/unit segments: Gaps[0] + Units[0].FullText + @@ -143,6 +158,11 @@ public ExtractionException(string message) : base(message) { } @"^[ \t]*(?:(?:" + SpecifierAlternation + @")\s+)*(?:(?var|autobind)\s+(?\w+(?:\s*,\s*\w+)*)\s*:|(?default)\s+(?\w+)\s*=)", RegexOptions.Compiled | RegexOptions.Multiline); + // A whole-enum unit's header. Anchored like DeclarationRegex; the block is + // consumed through its matching close brace (see ExtractEnum). + static readonly Regex EnumHeaderRegex = new Regex( + @"^[ ]*enum\s+(?\w+)", RegexOptions.Compiled | RegexOptions.Multiline); + // Top-level type headers, for scope tracking (see ScriptUnit.ScopedName). // Matched against the mask; types are top-level-only in WitcherScript (see this // class's own header comment), so scanning header -> matching close brace -> @@ -186,6 +206,7 @@ public static ScriptDocument Extract(string text) { var funcMatch = DeclarationRegex.Match(mask, pos); var memberMatch = MemberDeclRegex.Match(mask, pos); + var enumMatch = EnumHeaderRegex.Match(mask, pos); while (addFieldIndex < addFieldLineStarts.Count && addFieldLineStarts[addFieldIndex] < pos) ++addFieldIndex; @@ -194,8 +215,9 @@ public static ScriptDocument Extract(string text) var funcPos = funcMatch.Success ? funcMatch.Index : int.MaxValue; var fieldPos = fieldLineStart ?? int.MaxValue; var memberPos = memberMatch.Success ? memberMatch.Index : int.MaxValue; + var enumPos = enumMatch.Success ? enumMatch.Index : int.MaxValue; - if (funcPos == int.MaxValue && fieldPos == int.MaxValue && memberPos == int.MaxValue) + if (funcPos == int.MaxValue && fieldPos == int.MaxValue && memberPos == int.MaxValue && enumPos == int.MaxValue) break; // Earliest match wins. An @addField unit's own `var ...` line also @@ -203,8 +225,10 @@ public static ScriptDocument Extract(string text) // earlier, so the field extraction always claims it first and consumes // through the terminating ';' before the member scan can see it. ScriptUnit unit; - if (fieldPos <= funcPos && fieldPos <= memberPos) + if (fieldPos <= funcPos && fieldPos <= memberPos && fieldPos <= enumPos) unit = ExtractField(text, mask, lineStarts, fieldPos, cursor, typeRanges); + else if (enumPos < funcPos && enumPos <= memberPos) + unit = ExtractEnum(text, mask, enumMatch, cursor); else if (memberPos < funcPos) unit = ExtractMemberDeclaration(text, mask, memberMatch, cursor, typeRanges); else @@ -324,6 +348,34 @@ static ScriptUnit ExtractField(string text, string mask, List lineStarts, i return new ScriptUnit(name, QualifyName(typeRanges, annotationLineStart, name), ScriptUnitKind.Field, hasBody: false, unitStart, unitEnd, fullText); } + // A whole `enum Name { ... }` block as one unit - see + // ScriptUnitKind.EnumDeclaration's comment for why per-member extraction isn't + // viable and what silently broke while enums were gap territory. Keyed + // "enum:Name" so an enum can never collide with a same-named function's + // identity. Enums are top-level in WitcherScript, so no scope qualification. + static ScriptUnit ExtractEnum(string text, string mask, Match enumMatch, int cursor) + { + var unitStart = Math.Max(cursor, enumMatch.Index); + + var openBrace = FindNextChar(mask, enumMatch.Index + enumMatch.Length, '{'); + if (openBrace < 0) + throw new ExtractionException( + "Reached end of file looking for '{' after the enum declaration of '" + + enumMatch.Groups["name"].Value + "' starting at offset " + enumMatch.Index + "."); + + var closeBrace = FindMatchingDelimiter(mask, openBrace, '{', '}'); + if (closeBrace < 0) + throw new ExtractionException( + "Unbalanced braces in the body of enum '" + enumMatch.Groups["name"].Value + + "' starting at offset " + enumMatch.Index + "."); + + var unitEnd = closeBrace + 1; + var name = "enum:" + enumMatch.Groups["name"].Value; + return new ScriptUnit( + name, name, ScriptUnitKind.EnumDeclaration, hasBody: true, + unitStart, unitEnd, text.Substring(unitStart, unitEnd - unitStart)); + } + // A plain member declaration - see MemberDeclRegex for the shapes covered. The // unit is the whole statement through its terminating ';'. `default x = ...` is // keyed "default:x", distinct from the member variable x it initializes - a mod diff --git a/WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs b/WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs index 84245ef..4a9bc10 100644 --- a/WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs +++ b/WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs @@ -597,6 +597,23 @@ public void ValidateWholeFileMergeOutput_ToleratesPreexistingDuplicateLocalFromA Assert.True(FunctionLevelMergeEngine.ValidateWholeFileMergeOutput(baseText, oldText, baseText, oldText, "x.ws", out _)); } + // The hud.ws shape: one mod extends a vanilla enum with a new member while the + // other side leaves it untouched - the enum is a unit now, so the extending + // side's whole block wins instead of vanilla's gap text silently reverting it. + [Fact] + public void TryMerge_OneModAddsAnEnumMember_AdditionSurvives() + { + var baseText = + "enum EVis\r\n{\r\n\tHVS_None,\r\n\tHVS_Combat\r\n}\r\n\r\n" + + Fn("A", "\ta();\r\n"); + var oldText = baseText.Replace("\tHVS_Combat\r\n}", "\tHVS_Combat,\r\n\tHVS_Modcrab\r\n}"); + + var result = Merge(baseText, oldText, baseText); + + Assert.True(result.Applied); + Assert.Contains("HVS_Modcrab", result.MergedText); + } + [Fact] public void HasDuplicatedLocalVarDecls_IgnoresCommentedOutDeclarations() { diff --git a/WitcherScriptMerger.Tests/Tools/ScriptUnitExtractorTests.cs b/WitcherScriptMerger.Tests/Tools/ScriptUnitExtractorTests.cs index fcc6171..c9ee97f 100644 --- a/WitcherScriptMerger.Tests/Tools/ScriptUnitExtractorTests.cs +++ b/WitcherScriptMerger.Tests/Tools/ScriptUnitExtractorTests.cs @@ -360,6 +360,25 @@ public void Extract_AddFieldUnit_StillWinsOverPlainMemberScanForItsOwnVarLine() Assert.Equal(ScriptUnitKind.Function, doc.Units[1].Kind); } + [Fact] + public void Extract_EnumDeclaration_IsOneUnitAndRoundTrips() + { + var text = + "enum EColors\r\n{\r\n\tEC_Red,\r\n\tEC_Blue\r\n}\r\n\r\n" + + "class Foo\r\n{\r\n\tfunction A()\r\n\t{\r\n\t\treturn;\r\n\t}\r\n}\r\n"; + + var doc = ScriptUnitExtractor.Extract(text); + + // Whole enum = one unit, keyed distinctly from any same-named function - + // a mod ADDING an enum member must go through per-unit resolution instead + // of being silently reverted with vanilla's gap text ("I dont know any + // 'HVS_Modcrab'", observed live). + Assert.Equal(text, ScriptUnitExtractor.Reassemble(doc)); + Assert.Equal(new[] { "enum:EColors", "Foo::A" }, doc.Units.Select(u => u.ScopedName)); + Assert.Equal(ScriptUnitKind.EnumDeclaration, doc.Units[0].Kind); + Assert.Contains("EC_Blue", doc.Units[0].FullText); + } + #endregion } }