Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions WitcherScriptMerger.Core/Tools/ScriptUnitExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 +
Expand Down Expand Up @@ -143,6 +158,11 @@ public ExtractionException(string message) : base(message) { }
@"^[ \t]*(?:(?:" + SpecifierAlternation + @")\s+)*(?:(?<varkw>var|autobind)\s+(?<names>\w+(?:\s*,\s*\w+)*)\s*:|(?<defkw>default)\s+(?<defname>\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+(?<name>\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 ->
Expand Down Expand Up @@ -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;
Expand All @@ -194,17 +215,20 @@ 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
// matches MemberDeclRegex, but its annotation line sits strictly
// 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
Expand Down Expand Up @@ -324,6 +348,34 @@ static ScriptUnit ExtractField(string text, string mask, List<int> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@
var result = Merge(baseText, oldText, newText);

Assert.True(result.Applied);
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(result.MergedText, @"function NEW\(").Count);

Check warning on line 487 in WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs

View workflow job for this annotation

GitHub Actions / Build & format check

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)

Check warning on line 487 in WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs

View workflow job for this annotation

GitHub Actions / Build & format check

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)
Assert.True(FunctionLevelMergeEngine.PassesReassemblySanityGate(result.MergedText, out _));
}

Expand All @@ -501,7 +501,7 @@
var result = Merge(baseText, oldText, newText);

Assert.True(result.Applied);
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(result.MergedText, @"function NEW\(").Count);

Check warning on line 504 in WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs

View workflow job for this annotation

GitHub Actions / Build & format check

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)

Check warning on line 504 in WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs

View workflow job for this annotation

GitHub Actions / Build & format check

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)
Assert.Contains("\tn();\r\n}", result.MergedText);
}

Expand Down Expand Up @@ -530,7 +530,7 @@
var result = Merge(baseText, oldText, baseText);

Assert.True(result.Applied);
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(result.MergedText, @"function NEW\(").Count);

Check warning on line 533 in WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs

View workflow job for this annotation

GitHub Actions / Build & format check

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)

Check warning on line 533 in WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs

View workflow job for this annotation

GitHub Actions / Build & format check

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)
}

[Fact]
Expand Down Expand Up @@ -597,6 +597,23 @@
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()
{
Expand Down
19 changes: 19 additions & 0 deletions WitcherScriptMerger.Tests/Tools/ScriptUnitExtractorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading