diff --git a/.github/workflows/build-zxbstudio.yml b/.github/workflows/build-zxbstudio.yml index 34ec5d1..8b9d49e 100644 --- a/.github/workflows/build-zxbstudio.yml +++ b/.github/workflows/build-zxbstudio.yml @@ -29,6 +29,9 @@ jobs: - name: Build project run: dotnet build ZXBasicStudio.sln --configuration Release --no-restore + + - name: Run tests + run: dotnet test - name: Publish for Linux run: | diff --git a/.gitignore b/.gitignore index 9491a2f..322ad2a 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# tmp/ dir +tmp/ diff --git a/ZXBStudio/BuildSystem/ZXBasicMap.cs b/ZXBStudio/BuildSystem/ZXBasicMap.cs index a17a828..7ac172f 100644 --- a/ZXBStudio/BuildSystem/ZXBasicMap.cs +++ b/ZXBStudio/BuildSystem/ZXBasicMap.cs @@ -179,7 +179,7 @@ public ZXBasicMap(ZXCodeFile MainFile, IEnumerable AllFiles, string ParseInputParameters(funcMatch.Groups[5].Value, currentFunction.InputParameters); if (funcMatch.Groups[7].Success) - currentFunction.ReturnType = StorageFromString(funcMatch.Groups[5].Value, currentFunction.Name); + currentFunction.ReturnType = StorageFromString(funcMatch.Groups[7].Value, currentFunction.Name); else currentFunction.ReturnType = ZXVariableStorage.F; @@ -202,8 +202,7 @@ public ZXBasicMap(ZXCodeFile MainFile, IEnumerable AllFiles, string if (varNameDef.Contains("(")) //array { string varName = varNameDef.Substring(0, varNameDef.IndexOf("(")).Trim(); - - if (!jointLines.Skip(buc + 1).Any(l => Regex.IsMatch(l, $"(^|[^a-zA-Z0-9_]){varName}($|[^a-zA-Z0-9_])", RegexOptions.Multiline))) + if (!jointLines.Skip(buc + 1).Any(l => Regex.IsMatch(l, $"(^|[^a-zA-Z0-9_$]){Regex.Escape(varName)}($|[^a-zA-Z0-9_$])", RegexOptions.Multiline))) continue; string[] dims = varNameDef.Substring(varNameDef.IndexOf("(") + 1).Replace(")", "").Split(",", StringSplitOptions.RemoveEmptyEntries); @@ -219,8 +218,7 @@ public ZXBasicMap(ZXCodeFile MainFile, IEnumerable AllFiles, string foreach (var vName in varNames) { string varName = vName.Trim(); - - if (!jointLines.Skip(buc + 1).Any(l => Regex.IsMatch(l, $"(^|[^a-zA-Z0-9_]){varName}($|[^a-zA-Z0-9_])", RegexOptions.Multiline))) + if (!jointLines.Skip(buc + 1).Any(l => Regex.IsMatch(l, $"(^|[^a-zA-Z0-9_$]){Regex.Escape(varName)}($|[^a-zA-Z0-9_$])", RegexOptions.Multiline))) continue; var storage = StorageFromString(dimMatch.Groups[5].Value, varName); @@ -291,15 +289,15 @@ public ZXBasicMap(ZXCodeFile MainFile, IEnumerable AllFiles, string //Search for the var in the sub/function that the location points to if (location.LocationType == ZXBasicLocationType.Sub) { - var sub = subs.Where(s => s.Name == location.Name).FirstOrDefault(); + var sub = subs.FirstOrDefault(s => string.Equals(s.Name, location.Name, StringComparison.OrdinalIgnoreCase)); if(sub != null) - foundVar = sub.LocalVariables.Where(v => v.Name == varName).FirstOrDefault(); + foundVar = sub.LocalVariables.FirstOrDefault(v => string.Equals(v.Name, varName, StringComparison.OrdinalIgnoreCase)); } else { - var func = functions.Where(f => f.Name == location.Name).FirstOrDefault(); + var func = functions.FirstOrDefault(f => string.Equals(f.Name, location.Name, StringComparison.OrdinalIgnoreCase)); if (func != null) - foundVar = func.LocalVariables.Where(v => v.Name == varName).FirstOrDefault(); + foundVar = func.LocalVariables.FirstOrDefault(v => string.Equals(v.Name, varName, StringComparison.OrdinalIgnoreCase)); } } @@ -322,15 +320,15 @@ public ZXBasicMap(ZXCodeFile MainFile, IEnumerable AllFiles, string //(to avoid the very unprobable case where the same var is defined in different files in locations that match the same range) if (possibleLocation.LocationType == ZXBasicLocationType.Sub) { - var sub = subs.Where(s => s.Name == possibleLocation.Name).FirstOrDefault(); + var sub = subs.FirstOrDefault(s => string.Equals(s.Name, possibleLocation.Name, StringComparison.OrdinalIgnoreCase)); if (sub != null) - foundVar = sub.LocalVariables.Where(v => v.Name == varName && !v.Unused).FirstOrDefault(); + foundVar = sub.LocalVariables.FirstOrDefault(v => string.Equals(v.Name, varName, StringComparison.OrdinalIgnoreCase) && !v.Unused); } else { - var func = functions.Where(f => f.Name == possibleLocation.Name).FirstOrDefault(); + var func = functions.FirstOrDefault(f => string.Equals(f.Name, possibleLocation.Name, StringComparison.OrdinalIgnoreCase)); if (func != null) - foundVar = func.LocalVariables.Where(v => v.Name == varName && !v.Unused).FirstOrDefault(); + foundVar = func.LocalVariables.FirstOrDefault(v => string.Equals(v.Name, varName, StringComparison.OrdinalIgnoreCase) && !v.Unused); } //If the criteria finds a var, return it @@ -359,11 +357,7 @@ void GetSubVars(ZXBasicSub Sub, string[] Lines) if (varNameDef.Contains("(")) //array { string varName = varNameDef.Substring(0, varNameDef.IndexOf("(")).Trim(); - - //Ignore unused vars (vars that are found only on its dim line, there may be the improbable - //case where a var is defined and used in the same line using a colon and not used - //anywhere else, but that would be an awful code :) ) - if (!Lines.Skip(buc+1).Any(l => Regex.IsMatch(l, $"(^|[^a-zA-Z0-9_]){varName}($|[^a-zA-Z0-9_])", RegexOptions.Multiline))) + if (!Lines.Skip(buc+1).Any(l => Regex.IsMatch(l, $"(^|[^a-zA-Z0-9_$]){Regex.Escape(varName)}($|[^a-zA-Z0-9_$])", RegexOptions.Multiline))) continue; string[] dims = varNameDef.Substring(varNameDef.IndexOf("(") + 1).Replace(")", "").Split(",", StringSplitOptions.RemoveEmptyEntries); @@ -379,9 +373,7 @@ void GetSubVars(ZXBasicSub Sub, string[] Lines) foreach (var vName in varNames) { string varName = vName.Trim(); - - //Ignore unused vars - if (!Lines.Skip(buc+1).Any(l => Regex.IsMatch(l, $"(^|[^a-zA-Z0-9_]){varName}($|[^a-zA-Z0-9_])", RegexOptions.Multiline))) + if (!Lines.Skip(buc+1).Any(l => Regex.IsMatch(l, $"(^|[^a-zA-Z0-9_$]){Regex.Escape(varName)}($|[^a-zA-Z0-9_$])", RegexOptions.Multiline))) continue; var storage = StorageFromString(dimMatch.Groups[5].Value, varName); @@ -415,7 +407,7 @@ public List GetBuildLocations(ZXCodeFile CodeFile) if (subMatch != null && subMatch.Success) { - loc = new ZXBasicLocation { Name = subMatch.Groups[2].Value.Trim(), LocationType = ZXBasicLocationType.Sub, FirstLine = buc, File = Path.Combine(CodeFile.Directory, CodeFile.TempFileName) }; + loc = new ZXBasicLocation { Name = subMatch.Groups[4].Value.Trim(), LocationType = ZXBasicLocationType.Sub, FirstLine = buc, File = Path.Combine(CodeFile.Directory, CodeFile.TempFileName) }; continue; } @@ -423,7 +415,7 @@ public List GetBuildLocations(ZXCodeFile CodeFile) if (funcMatch != null && funcMatch.Success) { - loc = new ZXBasicLocation { Name = funcMatch.Groups[2].Value.Trim(), LocationType = ZXBasicLocationType.Function, FirstLine = buc, File = Path.Combine(CodeFile.Directory, CodeFile.TempFileName) }; + loc = new ZXBasicLocation { Name = funcMatch.Groups[4].Value.Trim(), LocationType = ZXBasicLocationType.Function, FirstLine = buc, File = Path.Combine(CodeFile.Directory, CodeFile.TempFileName) }; continue; } } @@ -465,7 +457,7 @@ public bool ContainsBuildDim(ZXCodeFile CodeFile, string VarName, int LineNumber if (LineNumber >= lines.Length) return false; - return Regex.IsMatch(lines[LineNumber], $"(\\s|,){VarName}(\\s|,|\\(|$)", RegexOptions.Multiline); + return Regex.IsMatch(lines[LineNumber], $"(\\s|,){Regex.Escape(VarName)}(\\s|,|\\(|$)", RegexOptions.Multiline); } private static void ParseInputParameters(string ParameterString, List Storage) diff --git a/ZXBStudio/BuildSystem/ZXVariableMap.cs b/ZXBStudio/BuildSystem/ZXVariableMap.cs index 4ac7227..8af652c 100644 --- a/ZXBStudio/BuildSystem/ZXVariableMap.cs +++ b/ZXBStudio/BuildSystem/ZXVariableMap.cs @@ -42,7 +42,7 @@ public ZXVariableMap(string ICFile, string MapFile, ZXBasicMap BasicMap) } - private void ProcessGlobalVariables(string icContent, string mapContent, ZXBasicMap BasicMap) + internal void ProcessGlobalVariables(string icContent, string mapContent, ZXBasicMap BasicMap) { int splitIndex = icContent.IndexOf("--- end of user code ---"); @@ -60,7 +60,7 @@ private void ProcessGlobalVariables(string icContent, string mapContent, ZXBasic string varName = m.Groups[1].Value; string bVarName = varName.Substring(1); - var basicVar = BasicMap.GlobalVariables.FirstOrDefault(v => v.Name == bVarName); + var basicVar = BasicMap.GlobalVariables.FirstOrDefault(v => string.Equals(v.Name.TrimEnd('$'), bVarName.TrimEnd('$'), StringComparison.OrdinalIgnoreCase)); if (basicVar == null) continue; @@ -78,7 +78,7 @@ private void ProcessGlobalVariables(string icContent, string mapContent, ZXBasic ZXVariable newVar = new ZXVariable { - Name = bVarName, + Name = basicVar.Name, Address = new ZXVariableAddress { AddressType = ZXVariableAddressType.Absolute, AddressValue = addr }, Scope = ZXVariableScope.GlobalScope, VariableType = ZXVariableType.Flat, @@ -98,7 +98,7 @@ private void ProcessGlobalVariables(string icContent, string mapContent, ZXBasic string bVarName = varName.Substring(1); - var basicVar = BasicMap.GlobalVariables.FirstOrDefault(v => v.Name == bVarName); + var basicVar = BasicMap.GlobalVariables.FirstOrDefault(v => string.Equals(v.Name.TrimEnd('$'), bVarName.TrimEnd('$'), StringComparison.OrdinalIgnoreCase)); if (basicVar == null) continue; @@ -133,7 +133,7 @@ private void ProcessGlobalVariables(string icContent, string mapContent, ZXBasic ZXVariable newVar = new ZXVariable { - Name = bVarName, + Name = basicVar.Name, Address = new ZXVariableAddress { AddressType = ZXVariableAddressType.Absolute, AddressValue = addr }, Scope = ZXVariableScope.GlobalScope, VariableType = ZXVariableType.Array, @@ -144,7 +144,7 @@ private void ProcessGlobalVariables(string icContent, string mapContent, ZXBasic } } - private void ProcessLocalVariables(string icContent, string mapContent, ZXBasicMap BasicMap) + internal void ProcessLocalVariables(string icContent, string mapContent, ZXBasicMap BasicMap) { int splitIndex = icContent.IndexOf("--- end of user code ---"); @@ -179,10 +179,9 @@ private void ProcessLocalVariables(string icContent, string mapContent, ZXBasicM ZXVariableScope currentScope = new ZXVariableScope { ScopeName = locName, StartAddress = startAddr, EndAddress = endAddr }; - ZXBasicSub? sub = BasicMap.Subs.Where(m => m.Name == locName).FirstOrDefault(); - + ZXBasicSub? sub = BasicMap.Subs.FirstOrDefault(m => string.Equals(m.Name.TrimEnd('$'), locName.TrimEnd('$'), StringComparison.OrdinalIgnoreCase)); if (sub == null) - sub = BasicMap.Functions.Where(m => m.Name == locName).FirstOrDefault(); + sub = BasicMap.Functions.FirstOrDefault(m => string.Equals(m.Name.TrimEnd('$'), locName.TrimEnd('$'), StringComparison.OrdinalIgnoreCase)); //Function params if (sub != null) diff --git a/ZXBStudio/ZXBasicStudio.csproj b/ZXBStudio/ZXBasicStudio.csproj index 0edf91e..96fcd5e 100644 --- a/ZXBStudio/ZXBasicStudio.csproj +++ b/ZXBStudio/ZXBasicStudio.csproj @@ -15,6 +15,10 @@ False 1.7.0.0 + + + + diff --git a/ZXBasicStudioTest/SigilMappingTests.cs b/ZXBasicStudioTest/SigilMappingTests.cs new file mode 100644 index 0000000..225ff79 --- /dev/null +++ b/ZXBasicStudioTest/SigilMappingTests.cs @@ -0,0 +1,123 @@ +using Xunit; +using FluentAssertions; +using ZXBasicStudio.BuildSystem; +using System.Runtime.Serialization; +using System.Collections.Generic; +using System.Linq; + +namespace ZXBasicStudioTest +{ + public class SigilMappingTests + { + [Fact] + public void GetVariables_ShouldMatchSigilVariable() + { + // Arrange + // We use GetUninitializedObject to skip the constructor which depends on files + var basicMap = (ZXBasicMap)FormatterServices.GetUninitializedObject(typeof(ZXBasicMap)); + + basicMap.GlobalVariables = new[] + { + new ZXBasicVariable { Name = "a$" } + }; + basicMap.Subs = new ZXBasicSub[0]; + basicMap.Functions = new ZXBasicFunction[0]; + basicMap.BuildLocations = new ZXBasicLocation[0]; + + // IC Content mimicking a global variable '_a' + string icContent = "--- end of user code ---\n('var', '_a', '0')"; + // Map content mimicking the same variable + string mapContent = "8000: ._a"; + + // Act + var variableMap = (ZXVariableMap)FormatterServices.GetUninitializedObject(typeof(ZXVariableMap)); + // We need to initialize the private 'vars' list since we used GetUninitializedObject + var varsField = typeof(ZXVariableMap).GetField("vars", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + varsField!.SetValue(variableMap, new List()); + + variableMap.ProcessGlobalVariables(icContent, mapContent, basicMap); + var variables = variableMap.Variables; + + // Assert + variables.Should().NotBeNull(); + var variable = variables.FirstOrDefault(v => v.Name == "a$"); + variable.Should().NotBeNull("Variable 'a$' should be found even if IC uses '_a'"); + variable!.Address.AddressValue.Should().Be(0x8000); + } + + [Fact] + public void GetVariables_ShouldBeCaseInsensitive() + { + // Arrange + var basicMap = (ZXBasicMap)FormatterServices.GetUninitializedObject(typeof(ZXBasicMap)); + + basicMap.GlobalVariables = new[] + { + new ZXBasicVariable { Name = "MyVar$" } + }; + basicMap.Subs = new ZXBasicSub[0]; + basicMap.Functions = new ZXBasicFunction[0]; + basicMap.BuildLocations = new ZXBasicLocation[0]; + + // IC Content uses lowercase '_myvar' + string icContent = "--- end of user code ---\n('var', '_myvar', '0')"; + string mapContent = "9000: ._myvar"; + + // Act + var variableMap = (ZXVariableMap)FormatterServices.GetUninitializedObject(typeof(ZXVariableMap)); + var varsField = typeof(ZXVariableMap).GetField("vars", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + varsField!.SetValue(variableMap, new List()); + + variableMap.ProcessGlobalVariables(icContent, mapContent, basicMap); + var variables = variableMap.Variables; + + // Assert + var variable = variables.FirstOrDefault(v => v.Name == "MyVar$"); + variable.Should().NotBeNull("Variable 'MyVar$' should be matched case-insensitively"); + variable!.Address.AddressValue.Should().Be(0x9000); + } + + [Fact] + public void ProcessLocalVariables_ShouldMatchSubNameWithSigil() + { + // Arrange + var basicMap = (ZXBasicMap)FormatterServices.GetUninitializedObject(typeof(ZXBasicMap)); + + var sub = new ZXBasicSub { Name = "MySub$" }; + sub.LocalVariables = new List(); + sub.InputParameters = new List + { + new ZXBasicParameter { Name = "param1", Offset = -2, Storage = ZXVariableStorage.U16 } + }; + + basicMap.GlobalVariables = new ZXBasicVariable[0]; + basicMap.Subs = new[] { sub }; + basicMap.Functions = new ZXBasicFunction[0]; + basicMap.BuildLocations = new[] + { + new ZXBasicLocation { Name = "MySub$", LocationType = ZXBasicLocationType.Sub, FirstLine = 0, LastLine = 10, File = "main.bas" } + }; + + // IC Content showing start and end of MySub (sigil is stripped in label usually: _MySub) + // Note: ProcessLocalVariables extracts locName = label.Substring(1) from '_MySub' -> 'MySub' + string icContent = "('label', '_MySub')\n('label', '_MySub__leave')\n--- end of user code ---"; + // Map content showing start and end addresses + string mapContent = "8000: ._MySub\n8010: ._MySub__leave"; + + // Act + var variableMap = (ZXVariableMap)FormatterServices.GetUninitializedObject(typeof(ZXVariableMap)); + var varsField = typeof(ZXVariableMap).GetField("vars", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + varsField!.SetValue(variableMap, new List()); + + variableMap.ProcessLocalVariables(icContent, mapContent, basicMap); + var variables = variableMap.Variables; + + // Assert + variables.Should().NotBeNull(); + // If MySub$ was matched correctly, its parameters should be added + variables.Should().Contain(v => v.Name == "param1", "Sub MySub$ should be matched to label _MySub and its parameters processed"); + var param = variables.First(v => v.Name == "param1"); + param.Scope.ScopeName.Should().Be("MySub"); + } + } +} diff --git a/ZXBasicStudioTest/UnitTest1.cs b/ZXBasicStudioTest/UnitTest1.cs deleted file mode 100644 index e6a0eb5..0000000 --- a/ZXBasicStudioTest/UnitTest1.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ZXBasicStudioTest -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - int a = 10; - int b = 20; - - Assert.NotEqual(a, b); - } - } -} \ No newline at end of file diff --git a/ZXBasicStudioTest/UsageDetectionTests.cs b/ZXBasicStudioTest/UsageDetectionTests.cs new file mode 100644 index 0000000..96fb24c --- /dev/null +++ b/ZXBasicStudioTest/UsageDetectionTests.cs @@ -0,0 +1,64 @@ +using Xunit; +using FluentAssertions; +using ZXBasicStudio.BuildSystem; +using System.IO; +using System.Collections.Generic; +using System.Linq; + +namespace ZXBasicStudioTest +{ + public class UsageDetectionTests + { + [Fact] + public void ZXBasicMap_ShouldDetectSigilVariableUsage() + { + // Arrange + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + string mainFileContent = "dim a$ as string\na$ = \"Hello\"\nprint a$"; + string mainPath = Path.Combine(tempDir, "main.bas"); + File.WriteAllText(mainPath, mainFileContent); + + var mainCodeFile = new ZXCodeFile(mainPath); + var allFiles = new List { mainCodeFile }; + string buildLog = ""; // No unused warnings for now + + // Act + var basicMap = new ZXBasicMap(mainCodeFile, allFiles, buildLog); + + // Assert + basicMap.GlobalVariables.Should().Contain(v => v.Name == "a$"); + var varA = basicMap.GlobalVariables.First(v => v.Name == "a$"); + varA.Unused.Should().BeFalse("a$ is used later in the code"); + + // Cleanup + Directory.Delete(tempDir, true); + } + + [Fact] + public void ZXBasicMap_ShouldDistinguishSigilAndNonSigilVariables() + { + // Arrange + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + // a is used, a$ is NOT used (only defined) + string mainFileContent = "dim a as integer\ndim a$ as string\na = 10\nprint a"; + string mainPath = Path.Combine(tempDir, "main.bas"); + File.WriteAllText(mainPath, mainFileContent); + + var mainCodeFile = new ZXCodeFile(mainPath); + var allFiles = new List { mainCodeFile }; + string buildLog = ""; + + // Act + var basicMap = new ZXBasicMap(mainCodeFile, allFiles, buildLog); + + // Assert + basicMap.GlobalVariables.Should().Contain(v => v.Name == "a"); + basicMap.GlobalVariables.Should().NotContain(v => v.Name == "a$", "a$ is NOT used, so it should be skipped (if it's not marked as unused in buildLog, the local regex check should mark it as unused and it's skipped in GlobalVariables list generation)"); + + // Cleanup + Directory.Delete(tempDir, true); + } + } +} diff --git a/ZXBasicStudioTest/ZXBasicStudioTest.csproj b/ZXBasicStudioTest/ZXBasicStudioTest.csproj index 9b37b01..8e57e39 100644 --- a/ZXBasicStudioTest/ZXBasicStudioTest.csproj +++ b/ZXBasicStudioTest/ZXBasicStudioTest.csproj @@ -16,12 +16,18 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + +