diff --git a/.gitignore b/.gitignore index 1da3c07..7ca4e16 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ project.lock.json *.userosscache *.sln.docstates +# install.sh WASM staging output +FadeBasic/FadeBasic.Export.Web/staging/ + # Build results (scoped to .NET bin/obj so they don't match source folders named debug/release) **/bin/[Dd]ebug/ **/bin/[Dd]ebugPublic/ @@ -27,6 +30,8 @@ project.lock.json x64/ x86/ build/ +!FadeBasic/FadeBasic.Export.Web/build/ +!FadeBasic/FadeBasic.Export.Web/build/** bld/ [Bb]in/ [Oo]bj/ @@ -40,4 +45,7 @@ msbuild.wrn # Plugin signing material (JetBrains Marketplace) *.pem -*.crt \ No newline at end of file +*.crt + +# BenchmarkDotNet output +**/BenchmarkDotNet.Artifacts/ \ No newline at end of file diff --git a/FadeBasic/ApplicationSupport/ApplicationSupport.csproj b/FadeBasic/ApplicationSupport/ApplicationSupport.csproj index 73b83df..bf0e239 100644 --- a/FadeBasic/ApplicationSupport/ApplicationSupport.csproj +++ b/FadeBasic/ApplicationSupport/ApplicationSupport.csproj @@ -15,8 +15,9 @@ + - + diff --git a/FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs b/FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs index 15d8fea..8adcf90 100644 --- a/FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs +++ b/FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs @@ -15,29 +15,59 @@ public class LaunchableGenerator public const string TAG_MAIN = "__MAIN__"; public const string TAG_ENCODED_BYTECODE = "__ENCODED_BYTE_CODE__"; public const string TAG_ENCODED_DEBUGDATA = "__ENCODED_DEBUG_DATA__"; + public const string TAG_ENCODED_TESTMANIFEST = "__ENCODED_TEST_MANIFEST__"; public const string TAG_COMMAND_ARRAY = "__COMMAND_ARR__"; public const string TEMPLATE_BYTECODE_TAB = " "; public const string TEMPLATE_ENCODED_BYTE_VAR = "encodedByteCode"; public const string TEMPLATE_ENCODED_DEBUGDATA_VAR = "encodedDebugData"; + public const string TEMPLATE_ENCODED_TESTMANIFEST_VAR = "encodedTestManifest"; public const string TEMPLATE_BYTECODE_VAR = "_byteCode"; public const string TEMPLATE_DEBUGDATA_VAR = "_debugData"; + public const string TEMPLATE_TESTMANIFEST_VAR = "_testManifest"; + // Default Main when FadeEnableTesting is off. Forwards args into the + // existing test-aware Launcher dispatcher (handles --fade-test=name etc.). public static readonly string MainTemplate = $@" - public static void Main(string[] args) + public static int Main(string[] args) {{ - Launcher.Run<{TAG_CLASSNAME}>(); + return Launcher.Main<{TAG_CLASSNAME}>(args); }} "; - public static readonly string ClassTemplate = + + // Main when FadeEnableTesting is on. Routes Microsoft.Testing.Platform + // invocations (dotnet test, --list-tests, --filter, --server, ...) through + // FadeBasic.Testing.FadeTestApplicationBuilder; everything else still goes + // to the existing Launcher path so `dotnet run` and --fade-test keep working. + // + // Custom IFadeTestHost is picked up by attribute-based discovery: tag the + // class [FadeBasic.Testing.FadeTestHost] and FadeTestApplicationBuilder + // resolves it at startup. If none is found, DefaultFadeTestHost is used. + public static readonly string MainTemplateWithTesting = +$@" + public static int Main(string[] args) + {{ + if (global::FadeBasic.Testing.FadeTestApplicationBuilder.IsTestInvocation(args)) + {{ + var instance = new {TAG_CLASSNAME}(); + return global::FadeBasic.Testing.FadeTestApplicationBuilder + .RunAsync(instance, args) + .GetAwaiter().GetResult(); + }} + return Launcher.Main<{TAG_CLASSNAME}>(args); + }} +"; + + public static readonly string ClassTemplate = $@"// This is a generated file. Do not edit directly. using {nameof(System)}; +using {nameof(System)}.{nameof(System.Collections)}.{nameof(System.Collections.Generic)}; using {nameof(FadeBasic)}; using {nameof(FadeBasic)}.{nameof(FadeBasic.Launch)}; using {nameof(FadeBasic)}.{nameof(FadeBasic.Virtual)}; -public class {TAG_CLASSNAME} : {nameof(ILaunchable)} +public partial class {TAG_CLASSNAME} : {nameof(ITestLaunchable)} {{ {TAG_MAIN} @@ -49,6 +79,8 @@ public class {TAG_CLASSNAME} : {nameof(ILaunchable)} public DebugData DebugData => {TEMPLATE_DEBUGDATA_VAR}; + public IReadOnlyList TestManifest => {TEMPLATE_TESTMANIFEST_VAR}; + #region method table private static readonly CommandCollection _collection = new CommandCollection( {TAG_COMMAND_ARRAY} @@ -64,21 +96,35 @@ public class {TAG_CLASSNAME} : {nameof(ILaunchable)} protected byte[] {TEMPLATE_BYTECODE_VAR} = {nameof(LaunchUtil)}.{nameof(LaunchUtil.Unpack64)}({TEMPLATE_ENCODED_BYTE_VAR}); protected const string {TEMPLATE_ENCODED_BYTE_VAR} = {TAG_ENCODED_BYTECODE}; #endregion + + #region testManifest + protected IReadOnlyList {TEMPLATE_TESTMANIFEST_VAR} = {nameof(LaunchUtil)}.{nameof(LaunchUtil.UnpackTestManifest)}({TEMPLATE_ENCODED_TESTMANIFEST_VAR}); + protected const string {TEMPLATE_ENCODED_TESTMANIFEST_VAR} = {TAG_ENCODED_TESTMANIFEST}; + #endregion }} "; - public static void GenerateLaunchable(string className, - string filePath, - CodeUnit unit, - CommandCollection collection, - List commandClasses, + public static void GenerateLaunchable(string className, + string filePath, + CodeUnit unit, + CommandCollection collection, + List commandClasses, bool includeMain=true, - bool generateDebug=false) + bool generateDebug=false, + bool enableTesting=false) { var compiler = unit.program.Compile(collection, new CompilerOptions { GenerateDebugData = generateDebug }); + + // Stamp originating .fbasic file paths onto each test manifest entry + // before we pack it into the generated launchable. Multi-file projects + // need this so IDE Test Explorer (Stage 11H VSTest adapter) can + // source-link each test to the right file. CodeUnit always carries a + // SourceMap when it comes from the build-task / SDK pipelines. + FadeBasic.Launch.LaunchUtil.ApplySourceMap(compiler.TestManifest, unit.sourceMap); + var byteCode = compiler.Program.ToArray(); var src = ClassTemplate; @@ -86,15 +132,25 @@ public static void GenerateLaunchable(string className, string byteCodeReplacement = "\"" + byteCodeStr + "\""; var commandArray = GetCommandTable(commandClasses); - + var debugDataStr = generateDebug ? LaunchUtil.PackDebugData(compiler.DebugData) : ""; string debugDataReplacement = "\"" + debugDataStr + "\""; - - var main = includeMain ? MainTemplate : ""; - src = src.Replace(TAG_MAIN, main); + + // Always pack the test manifest. Empty when the source has no tests. + var testManifestStr = LaunchUtil.PackTestManifest(compiler.TestManifest); + string testManifestReplacement = "\"" + testManifestStr + "\""; + + string mainBlock = ""; + if (includeMain) + { + mainBlock = enableTesting ? MainTemplateWithTesting : MainTemplate; + } + + src = src.Replace(TAG_MAIN, mainBlock); src = src.Replace(TAG_COMMAND_ARRAY, commandArray); src = src.Replace(TAG_ENCODED_BYTECODE, byteCodeReplacement); src = src.Replace(TAG_ENCODED_DEBUGDATA, debugDataReplacement); + src = src.Replace(TAG_ENCODED_TESTMANIFEST, testManifestReplacement); src = src.Replace(TAG_CLASSNAME, className); var dir = Path.GetDirectoryName(filePath); @@ -111,6 +167,7 @@ static string GetCommandTable(List commandClasses) } return string.Join(", ", instantiates); } + static string GetCommandTable(ProjectContext context) { // IMethod collection = new CommandCollection() diff --git a/FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs b/FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs index ba77ef3..cf63286 100644 --- a/FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs +++ b/FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs @@ -239,39 +239,40 @@ public static (ProjectCommandInfo, AssemblyLoadContext) LoadCommands(string libP var sources = new List(); var loadContext = new AssemblyLoadContext("metadata", isCollectible: true); - // TODO: technically there could be multiple lib paths, right? one for each library? - // var libPath = Path.GetDirectoryName(libraries[0].absoluteOutputDllPath); - // var libPath = AppContext.BaseDirectory; - + // Probe the consumer's TargetDir (libPath) AND the directory of each + // loaded library DLL. A macro command in lib A can call into a sibling + // assembly B that A project-references; B sits next to A in A's own bin + // (or in the NuGet lib/ folder), but on a clean build it has not yet + // been copied into the consumer's TargetDir when this resolver runs. + var probeDirs = new List { libPath }; + foreach (var lib in libraries) + { + var dir = Path.GetDirectoryName(lib.absoluteOutputDllPath); + if (!string.IsNullOrEmpty(dir) && !probeDirs.Contains(dir)) + probeDirs.Add(dir); + } + loadContext.Resolving += (assemblyContext, assemblyName) => { if (assemblyName.FullName == typeof(IMethodSource).Assembly.GetName().FullName) { - // log("!!! Trying to load common assembly."); return typeof(IMethodSource).Assembly; } - - - // log($"!!! Requested: [{assemblyName.FullName}]"); - //log($"!!! Compared: [{typeof(IMethodSource).Assembly.GetName().FullName}]"); - - string candidatePath = Path.Combine( - libPath, - assemblyName.Name + ".dll"); - // log($"!!! candidate-path=[{candidatePath}]"); - - if (!File.Exists(candidatePath)) - return null; + foreach (var dir in probeDirs) + { + var candidatePath = Path.Combine(dir, assemblyName.Name + ".dll"); + if (!File.Exists(candidatePath)) + continue; - var foundName = AssemblyName.GetAssemblyName(candidatePath); - // log($"!!! candidate-name=[{foundName.Name}] vs requested=[{assemblyName.Name}]"); - - if (foundName.Name != assemblyName.Name) - return null; + var foundName = AssemblyName.GetAssemblyName(candidatePath); + if (foundName.Name != assemblyName.Name) + continue; + + return assemblyContext.LoadIntoMemory(candidatePath); + } - // log($"!!! Proxied: [{candidatePath}]"); - return assemblyContext.LoadIntoMemory(candidatePath); + return null; }; using var _ = loadContext.EnterContextualReflection(); diff --git a/FadeBasic/ApplicationSupport/Project/ProjectDocs.cs b/FadeBasic/ApplicationSupport/Project/ProjectDocs.cs index b4c7064..ff431d7 100644 --- a/FadeBasic/ApplicationSupport/Project/ProjectDocs.cs +++ b/FadeBasic/ApplicationSupport/Project/ProjectDocs.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text; using System.Xml.Linq; @@ -310,21 +312,44 @@ public static void ParseBlock(IDocParser parser, XElement summary, StringBuilder public static ProjectDocs LoadDocs(this List metadatas, Action onDocParseError = null) where T : IDocParser, new() { - // Build command name -> group lookup so can resolve links + // Two lookups so can resolve from either the + // Fade-script call name (`"texture"`) OR the underlying C# method + // name (`"LoadTexture"`). The XML docs in our source use both forms + // freely; without the second map the C# names fall through to the + // inline-code fallback in MarkdownDocParser.ConvertSee and never + // produce a clickable link. var commandToGroup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var methodNameToCallName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var metadata in metadatas) { foreach (var command in metadata.commands) { commandToGroup[command.callName] = metadata.className; + var shortMethodName = ExtractShortMethodName(command.methodName, command.sig); + if (!string.IsNullOrEmpty(shortMethodName) && !methodNameToCallName.ContainsKey(shortMethodName)) + methodNameToCallName[shortMethodName] = command.callName; } } var parser = new T(); + // Fragment-style URL the playground intercepts in help.ts and routes + // through helpCtl.selectCommand. The browser treats `#…` as + // same-page navigation, so a stray middle-click / new-tab doesn't + // 404 against a path that nothing serves. The fragment's payload + // is URI-encoded so callNames with spaces (`"push asset"`) survive. parser.ResolveSeeRef = cref => { - if (commandToGroup.TryGetValue(cref, out var group)) - return "/command/" + group + "/" + cref; + if (string.IsNullOrEmpty(cref)) return null; + // Strip any trailing `(...)` so `` matches the + // bare `Sync` we have in the methodName map. The XML source uses + // both forms; both should link. + var key = cref; + var paren = key.IndexOf('('); + if (paren > 0) key = key.Substring(0, paren); + if (methodNameToCallName.TryGetValue(key, out var callName)) + return "#fade-cmd:" + Uri.EscapeDataString(callName); + if (commandToGroup.ContainsKey(key)) + return "#fade-cmd:" + Uri.EscapeDataString(key); return null; }; @@ -340,7 +365,14 @@ public static ProjectDocs LoadDocs(this List metadatas, Acti { var doc = new CommandDocs(); group.commands.Add(doc); - docs.map[command.sig] = doc; + // Key by callName + sig — `command.sig` alone is only the + // type signature (e.g. "voidR9"), shared by every command + // with the same return type and arg shape. Without callName + // in the key, two commands with the same sig clobber each + // other in this map and Lookup returns the wrong CommandDocs + // (or none, if a later command overwrote the slot). Match + // CommandInfo.UniqueName on the lookup side. + docs.map[command.callName + command.sig] = doc; doc.command = command; doc.commandName = command.callName; doc.methodDocs = ParseMethodDocs(parser, command.docString, ex => @@ -353,6 +385,25 @@ public static ProjectDocs LoadDocs(this List metadatas, Acti return docs; } + // The source generator emits `MethodName = "Call__"` per + // command. Crefs in the XML docs reference the underlying C# method + // ("Push", "LoadTexture") rather than the generated wrapper, so we + // strip the `Call_` prefix and `_` suffix to recover the short + // name we can match cref keys against. + internal static string ExtractShortMethodName(string methodName, string sig) + { + if (string.IsNullOrEmpty(methodName)) return null; + const string prefix = "Call_"; + if (!methodName.StartsWith(prefix, StringComparison.Ordinal)) return methodName; + var stripped = methodName.Substring(prefix.Length); + if (!string.IsNullOrEmpty(sig)) + { + var suffix = "_" + sig; + if (stripped.EndsWith(suffix, StringComparison.Ordinal)) + stripped = stripped.Substring(0, stripped.Length - suffix.Length); + } + return stripped; + } } public class ProjectDocs diff --git a/FadeBasic/ApplicationSupport/Project/ProjectDocsCommandDocsProvider.cs b/FadeBasic/ApplicationSupport/Project/ProjectDocsCommandDocsProvider.cs new file mode 100644 index 0000000..a5cc9e5 --- /dev/null +++ b/FadeBasic/ApplicationSupport/Project/ProjectDocsCommandDocsProvider.cs @@ -0,0 +1,59 @@ +// Adapter: ProjectDocs ⟶ FadeBasic.LSP.Core.ICommandDocsProvider. +// +// Lets any host that already has a ProjectDocs (native LSP, WebRuntime, +// docs site) plug into LSP.Core's hover/completion handlers without +// duplicating the XML-doc parsing pipeline. The lookup is by +// `CommandInfo.UniqueName` (name + sig), matching the key ProjectDocs +// builds via LoadDocs. Keying by sig alone collapses commands that share +// a type signature (e.g. every void-returning string-taking command). + +using FadeBasic.LSP.Core; +using FadeBasic.Virtual; + +namespace FadeBasic.ApplicationSupport.Project; + +public sealed class ProjectDocsCommandDocsProvider : ICommandDocsProvider +{ + private readonly ProjectDocs _docs; + private readonly Func? _urlForCommand; + + public ProjectDocsCommandDocsProvider(ProjectDocs docs, Func? urlForCommand = null) + { + _docs = docs; + _urlForCommand = urlForCommand; + } + + public ICommandDocs? Lookup(CommandInfo command) + { + if (_docs?.map == null) return null; + if (!_docs.map.TryGetValue(command.UniqueName ?? string.Empty, out var found)) return null; + return new CommandDocsAdapter(found, _urlForCommand); + } + + private sealed class CommandDocsAdapter : ICommandDocs + { + private readonly CommandDocs _src; + private readonly Func? _urlForCommand; + public CommandDocsAdapter(CommandDocs src, Func? urlForCommand) + { + _src = src; + _urlForCommand = urlForCommand; + } + public string? Summary => _src.methodDocs?.summary; + public string? Returns => _src.methodDocs?.returns; + public string? Remarks => _src.methodDocs?.remarks; + public IReadOnlyList Parameters => + _src.methodDocs?.parameters?.Select(p => (ICommandParameterDoc)new ParamAdapter(p)).ToList() + ?? new List(); + public IReadOnlyList Examples => _src.methodDocs?.examples ?? new List(); + public string? Url => _urlForCommand?.Invoke(_src.commandName ?? string.Empty); + } + + private sealed class ParamAdapter : ICommandParameterDoc + { + private readonly XmlDocMethodParameter _src; + public ParamAdapter(XmlDocMethodParameter src) { _src = src; } + public string? Name => _src.name; + public string? Body => _src.body; + } +} diff --git a/FadeBasic/Benchmarks/Benchmarks.csproj b/FadeBasic/Benchmarks/Benchmarks.csproj index 1c77130..8845704 100644 --- a/FadeBasic/Benchmarks/Benchmarks.csproj +++ b/FadeBasic/Benchmarks/Benchmarks.csproj @@ -2,15 +2,14 @@ Exe - net6.0 + net8.0 enable enable true - - + diff --git a/FadeBasic/Benchmarks/CompilerBenchmarks.cs b/FadeBasic/Benchmarks/CompilerBenchmarks.cs new file mode 100644 index 0000000..7ba0da3 --- /dev/null +++ b/FadeBasic/Benchmarks/CompilerBenchmarks.cs @@ -0,0 +1,68 @@ +using BenchmarkDotNet.Attributes; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; +using System.Linq; + +namespace Benchmarks; + +[MemoryDiagnoser] +public class CompilerBenchmarks +{ + private CommandCollection _commands; + private ProgramNode _shortProg; + private ProgramNode _mediumProg; + private ProgramNode _largeProg; + + [GlobalSetup] + public void Setup() + { + var lexer = new Lexer(); + _commands = new CommandCollection(new FadeBasicCommands()); + + _shortProg = Parse(lexer, BenchmarkCorpus.Short, nameof(BenchmarkCorpus.Short)); + _mediumProg = Parse(lexer, BenchmarkCorpus.Medium, nameof(BenchmarkCorpus.Medium)); + _largeProg = Parse(lexer, BenchmarkCorpus.Large, nameof(BenchmarkCorpus.Large)); + } + + private ProgramNode Parse(Lexer lexer, string source, string name) + { + var lex = lexer.TokenizeWithErrors(source, _commands); + if (lex.tokenErrors is { Count: > 0 }) + throw new InvalidOperationException( + $"Corpus '{name}' produced lex errors: {string.Join(", ", lex.tokenErrors.Select(e => e.Display))}"); + + var stream = new TokenStream(lex.tokens, lex.tokenErrors); + var prog = new Parser(stream, _commands).ParseProgram(); + var errs = prog.GetAllErrors(); + if (errs.Count > 0) + throw new InvalidOperationException( + $"Corpus '{name}' produced parse errors: {string.Join(", ", errs.Select(e => e.Display))}"); + + return prog; + } + + [Benchmark(Baseline = true)] + public List CompileShort() + { + var compiler = new Compiler(_commands, CompilerOptions.Default); + compiler.Compile(_shortProg); + return compiler.Program; + } + + [Benchmark] + public List CompileMedium() + { + var compiler = new Compiler(_commands, CompilerOptions.Default); + compiler.Compile(_mediumProg); + return compiler.Program; + } + + [Benchmark] + public List CompileLarge() + { + var compiler = new Compiler(_commands, CompilerOptions.Default); + compiler.Compile(_largeProg); + return compiler.Program; + } +} diff --git a/FadeBasic/Benchmarks/Json.cs b/FadeBasic/Benchmarks/Json.cs index 314e945..458cca6 100644 --- a/FadeBasic/Benchmarks/Json.cs +++ b/FadeBasic/Benchmarks/Json.cs @@ -8,49 +8,178 @@ namespace Benchmarks; [MemoryDiagnoser] -public class Json +public class JsonBenchmarks { - private DebugToken _token; - private JsonSerializerOptions _options; + // ── tiny (original) ───────────────────────────────────────────────────── + private DebugToken _singleToken; + + // ── realistic DebugData (50 statement tokens, 20 vars, 5 functions) ───── + private DebugData _debugData; + private string _debugDataJson; + + // ── realistic InternedData (5 types, 15 functions, 30 strings) ────────── + private InternedData _internedData; + private string _internedDataJson; + + private JsonSerializerOptions _sysJsonOptions; [GlobalSetup] public void GlobalSetup() { - _options = new JsonSerializerOptions + _sysJsonOptions = new JsonSerializerOptions { IncludeFields = true, IgnoreReadOnlyProperties = true }; - _token = new DebugToken + + _singleToken = new DebugToken { insIndex = 3, - token = new Token - { - lineNumber = 12, - charNumber = 3, - raw = "tuna" - } + token = new Token { lineNumber = 12, charNumber = 3, raw = "tuna" } }; + + _debugData = BuildDebugData(); + _debugDataJson = _debugData.Jsonify(); + + _internedData = BuildInternedData(); + _internedDataJson = _internedData.Jsonify(); } - + + // ── single token (baseline, matches old benchmark) ────────────────────── + + [Benchmark(Baseline = true)] + public string SingleToken_Serialize() => _singleToken.Jsonify(); + + [Benchmark] + public DebugToken SingleToken_Deserialize() => JsonableExtensions.FromJson(_singleToken.Jsonify()); + + // ── DebugData round-trip ───────────────────────────────────────────────── + + [Benchmark] + public string DebugData_Serialize() => _debugData.Jsonify(); + [Benchmark] - public DebugToken SystemJson() + public DebugData DebugData_Deserialize() => JsonableExtensions.FromJson(_debugDataJson); + + [Benchmark] + public DebugData DebugData_RoundTrip() { - var json = System.Text.Json.JsonSerializer.Serialize(_token, _options); - return System.Text.Json.JsonSerializer.Deserialize(json); + var json = _debugData.Jsonify(); + return JsonableExtensions.FromJson(json); } - + + // ── InternedData round-trip ────────────────────────────────────────────── + [Benchmark] - public DebugToken Newton() + public string InternedData_Serialize() => _internedData.Jsonify(); + + [Benchmark] + public InternedData InternedData_Deserialize() => JsonableExtensions.FromJson(_internedDataJson); + + [Benchmark] + public InternedData InternedData_RoundTrip() { - var json = JsonConvert.SerializeObject(_token); - return JsonConvert.DeserializeObject(json); + var json = _internedData.Jsonify(); + return JsonableExtensions.FromJson(json); } - - [Benchmark] - public DebugToken Fade() + + // ── helpers ────────────────────────────────────────────────────────────── + + static Token MakeToken(int line, int ch, string raw) => + new Token { lineNumber = line, charNumber = ch, raw = raw }; + + static DebugData BuildDebugData() { - var json = _token.Jsonify(); - return JsonableExtensions.FromJson(json); + var d = new DebugData(); + + // 50 statement tokens + for (int i = 0; i < 50; i++) + d.statementTokens.Add(new DebugToken + { + insIndex = i * 4, + token = MakeToken(i, i % 40, $"stmt{i}"), + isComputed = i % 7 == 0 ? 1 : 0 + }); + + // 20 variables + for (int i = 0; i < 20; i++) + d.insToVariable[i * 4] = new DebugVariable + { + insIndex = i * 4, + name = $"variable_{i}", + isPtr = i % 3 == 0 ? 1 : 0 + }; + + // 5 functions + for (int i = 0; i < 5; i++) + d.insToFunction[i * 20] = new DebugToken + { + insIndex = i * 20, + token = MakeToken(i * 10, 0, $"function_{i}"), + isComputed = 0 + }; + + return d; + } + + static InternedData BuildInternedData() + { + var d = new InternedData + { + types = new Dictionary(), + maxRegisterAddress = 0x1234567890ABCDEF, + }; + + // 5 types with 4 fields each + for (int t = 0; t < 5; t++) + { + var type = new InternedType + { + name = $"Type{t}", + byteSize = 16 + t * 8, + typeId = t, + fields = new Dictionary() + }; + for (int f = 0; f < 4; f++) + { + type.fields[$"field{f}"] = new InternedField + { + offset = f * 4, + length = 4, + typeCode = (byte)(f % 8), + typeName = $"type{f % 3}", + typeId = f % 5 + }; + } + d.types[$"Type{t}"] = type; + } + + // 15 functions with 2 parameters each + for (int fn = 0; fn < 15; fn++) + { + var func = new InternedFunction + { + name = $"function_{fn}", + insIndex = fn * 8, + typeCode = fn % 4, + typeId = fn % 5, + parameters = new List + { + new InternedFunctionParameter { name = "a", index = 0, typeCode = 1, typeId = 0 }, + new InternedFunctionParameter { name = "b", index = 1, typeCode = 2, typeId = 1 }, + } + }; + d.functions[$"function_{fn}"] = func; + } + + // 30 strings + for (int s = 0; s < 30; s++) + d.strings.Add(new InternedString + { + value = $"string literal number {s} with some content", + indexReferences = new[] { s * 2, s * 2 + 1 } + }); + + return d; } -} \ No newline at end of file +} diff --git a/FadeBasic/Benchmarks/LexerBenchmarks.cs b/FadeBasic/Benchmarks/LexerBenchmarks.cs new file mode 100644 index 0000000..0a0280e --- /dev/null +++ b/FadeBasic/Benchmarks/LexerBenchmarks.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using FadeBasic; + +namespace Benchmarks; + +[MemoryDiagnoser] +public class LexerBenchmarks +{ + private Lexer _lexer; + private CommandCollection _commands; + + [GlobalSetup] + public void Setup() + { + _lexer = new Lexer(); + _commands = new CommandCollection(new FadeBasicCommands()); + ValidateCorpus(BenchmarkCorpus.Short, nameof(BenchmarkCorpus.Short)); + ValidateCorpus(BenchmarkCorpus.Medium, nameof(BenchmarkCorpus.Medium)); + ValidateCorpus(BenchmarkCorpus.Large, nameof(BenchmarkCorpus.Large)); + } + + private void ValidateCorpus(string source, string name) + { + var result = _lexer.TokenizeWithErrors(source, _commands); + if (result.tokenErrors is { Count: > 0 }) + throw new InvalidOperationException( + $"Corpus '{name}' produced lex errors: {result.tokenErrors[0]}"); + } + + [Benchmark(Baseline = true)] + public LexerResults LexShort() => _lexer.TokenizeWithErrors(BenchmarkCorpus.Short, _commands); + + [Benchmark] + public LexerResults LexMedium() => _lexer.TokenizeWithErrors(BenchmarkCorpus.Medium, _commands); + + [Benchmark] + public LexerResults LexLarge() => _lexer.TokenizeWithErrors(BenchmarkCorpus.Large, _commands); +} diff --git a/FadeBasic/Benchmarks/ParserBenchmarks.cs b/FadeBasic/Benchmarks/ParserBenchmarks.cs new file mode 100644 index 0000000..f04a87c --- /dev/null +++ b/FadeBasic/Benchmarks/ParserBenchmarks.cs @@ -0,0 +1,57 @@ +using BenchmarkDotNet.Attributes; +using FadeBasic; +using FadeBasic.Ast; +using System.Linq; + +namespace Benchmarks; + +[MemoryDiagnoser] +public class ParserBenchmarks +{ + private CommandCollection _commands; + private LexerResults _shortLex; + private LexerResults _mediumLex; + private LexerResults _largeLex; + + [GlobalSetup] + public void Setup() + { + var lexer = new Lexer(); + _commands = new CommandCollection(new FadeBasicCommands()); + _shortLex = lexer.TokenizeWithErrors(BenchmarkCorpus.Short, _commands); + _mediumLex = lexer.TokenizeWithErrors(BenchmarkCorpus.Medium, _commands); + _largeLex = lexer.TokenizeWithErrors(BenchmarkCorpus.Large, _commands); + + ValidateCorpus(_shortLex, nameof(BenchmarkCorpus.Short)); + ValidateCorpus(_mediumLex, nameof(BenchmarkCorpus.Medium)); + ValidateCorpus(_largeLex, nameof(BenchmarkCorpus.Large)); + } + + private void ValidateCorpus(LexerResults lex, string name) + { + if (lex.tokenErrors is { Count: > 0 }) + throw new InvalidOperationException( + $"Corpus '{name}' produced lex errors: {string.Join(", ", lex.tokenErrors.Select(e => e.Display))}"); + } + + [Benchmark(Baseline = true)] + public ProgramNode ParseShort() + { + var stream = new TokenStream(_shortLex.tokens, _shortLex.tokenErrors); + return new Parser(stream, _commands).ParseProgram(); + } + + [Benchmark] + public ProgramNode ParseMedium() + { + var stream = new TokenStream(_mediumLex.tokens, _mediumLex.tokenErrors); + return new Parser(stream, _commands).ParseProgram(); + } + + [Benchmark] + public ProgramNode ParseLarge() + { + var stream = new TokenStream(_largeLex.tokens, _largeLex.tokenErrors); + return new Parser(stream, _commands).ParseProgram(); + } +} diff --git a/FadeBasic/Benchmarks/Program.cs b/FadeBasic/Benchmarks/Program.cs index 1e7d8b0..c9a0467 100644 --- a/FadeBasic/Benchmarks/Program.cs +++ b/FadeBasic/Benchmarks/Program.cs @@ -1,9 +1,3 @@ -// See https://aka.ms/new-console-template for more information - using BenchmarkDotNet.Running; -using Benchmarks; -using MoonSharp.Interpreter; - -// Script.RunString("a = 3 + 2"); -var summary = BenchmarkRunner.Run(); \ No newline at end of file +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/FadeBasic/Benchmarks/VmBenchmarks.cs b/FadeBasic/Benchmarks/VmBenchmarks.cs new file mode 100644 index 0000000..e511ffe --- /dev/null +++ b/FadeBasic/Benchmarks/VmBenchmarks.cs @@ -0,0 +1,117 @@ +using BenchmarkDotNet.Attributes; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; +using System.Linq; + +namespace Benchmarks; + +[MemoryDiagnoser] +public class VmBenchmarks +{ + private FadeBasic.Virtual.HostMethodTable _methodTable; + private byte[] _shortBytecode; + private byte[] _mediumBytecode; + private byte[] _largeBytecode; + + [GlobalSetup] + public void Setup() + { + var lexer = new Lexer(); + var commands = new CommandCollection(new FadeBasicCommands()); + + _shortBytecode = Compile(lexer, commands, BenchmarkCorpus.Short, nameof(BenchmarkCorpus.Short)); + _mediumBytecode = Compile(lexer, commands, BenchmarkCorpus.Medium, nameof(BenchmarkCorpus.Medium)); + _largeBytecode = Compile(lexer, commands, BenchmarkCorpus.Large, nameof(BenchmarkCorpus.Large)); + + // All three share the same command set so one MethodTable covers all. + var compiler = new Compiler(commands, CompilerOptions.Default); + compiler.Compile(new Parser( + new TokenStream(lexer.TokenizeWithErrors(BenchmarkCorpus.Short, commands).tokens), + commands).ParseProgram()); + _methodTable = compiler.methodTable; + } + + private static byte[] Compile(Lexer lexer, CommandCollection commands, string source, string name) + { + var lex = lexer.TokenizeWithErrors(source, commands); + if (lex.tokenErrors is { Count: > 0 }) + throw new InvalidOperationException( + $"Corpus '{name}' lex errors: {string.Join(", ", lex.tokenErrors.Select(e => e.Display))}"); + + var stream = new TokenStream(lex.tokens, lex.tokenErrors); + var prog = new Parser(stream, commands).ParseProgram(); + var errs = prog.GetAllErrors(); + if (errs.Count > 0) + throw new InvalidOperationException( + $"Corpus '{name}' parse errors: {string.Join(", ", errs.Select(e => e.Display))}"); + + var compiler = new Compiler(commands, CompilerOptions.Default); + compiler.Compile(prog); + return compiler.Program.ToArray(); + } + + // ── Full-run benchmarks ────────────────────────────────────────────────── + + [Benchmark(Baseline = true)] + public VirtualMachine RunShort() + { + var vm = new VirtualMachine(_shortBytecode); + vm.hostMethods = _methodTable; + vm.Execute3(0); + return vm; + } + + [Benchmark] + public VirtualMachine RunMedium() + { + var vm = new VirtualMachine(_mediumBytecode); + vm.hostMethods = _methodTable; + vm.Execute3(0); + return vm; + } + + [Benchmark] + public VirtualMachine RunLarge() + { + var vm = new VirtualMachine(_largeBytecode); + vm.hostMethods = _methodTable; + vm.Execute3(0); + return vm; + } + + // ── Budgeted tight-loop benchmarks (budget = 100 instructions/slice) ───── + + [Benchmark] + public VirtualMachine RunShort_Budget100() + { + var vm = new VirtualMachine(_shortBytecode); + vm.hostMethods = _methodTable; + while (vm.instructionIndex < vm.program.Length && + vm.error.type == VirtualRuntimeErrorType.NONE) + vm.Execute3(100); + return vm; + } + + [Benchmark] + public VirtualMachine RunMedium_Budget100() + { + var vm = new VirtualMachine(_mediumBytecode); + vm.hostMethods = _methodTable; + while (vm.instructionIndex < vm.program.Length && + vm.error.type == VirtualRuntimeErrorType.NONE) + vm.Execute3(100); + return vm; + } + + [Benchmark] + public VirtualMachine RunLarge_Budget100() + { + var vm = new VirtualMachine(_largeBytecode); + vm.hostMethods = _methodTable; + while (vm.instructionIndex < vm.program.Length && + vm.error.type == VirtualRuntimeErrorType.NONE) + vm.Execute3(100); + return vm; + } +} diff --git a/FadeBasic/Benchmarks/Vms.cs b/FadeBasic/Benchmarks/Vms.cs index 2d080db..52a87c4 100644 --- a/FadeBasic/Benchmarks/Vms.cs +++ b/FadeBasic/Benchmarks/Vms.cs @@ -1,94 +1,33 @@ using BenchmarkDotNet.Attributes; using FadeBasic; using FadeBasic.Virtual; -using MoonSharp.Interpreter; namespace Benchmarks; [MemoryDiagnoser] public class Vms { - private List _compilerProgram; - private VirtualMachine _vm; + public string Source { get; set; } = + "dim x(4):x(0) = 2:x(1) = x(0) * 2:x(2) = x(1) * x(0):x(3) = x(2) * x(1) * x(0):y = x(3)"; - private Script _lua; + private List _program; + private VirtualMachine _vm; + private CommandCollection _commands; - // [Params( - // // "3 + 2", - // // "(1 + 2 * 4) * (5+2+1) * 2", - // "" - // )] - public string Source { get; set; } = - "dim x(4);x(0) = 2;x(1) = x(0) * 2;x(2) = x(1) * x(0);x(3) = x(2) * x(1) * x(0);y = x(3)"; - [GlobalSetup] public void Setup() { - // var src = Source; - // var lexer = new Lexer(); - // var tokens = lexer.Tokenize(src); - // var parser = new Parser(new TokenStream(tokens), StandardCommands.LimitedCommands); - // var exprAst = parser.ParseProgram(); - // - // var compiler = new Compiler(StandardCommands.LimitedCommands); - // compiler.Compile(exprAst); - // _compilerProgram = compiler.Program; - // _vm = new VirtualMachine(_compilerProgram); - // - Script.WarmUp(); - _lua = new Script(); + _commands = new CommandCollection(); + var lexer = new Lexer(); + var tokens = lexer.TokenizeWithErrors(Source, _commands); + var parser = new Parser(tokens.stream, _commands); + var ast = parser.ParseProgram(); + var compiler = new Compiler(_commands); + compiler.Compile(ast); + _program = compiler.Program; + _vm = new VirtualMachine(_program); } -// -// [Benchmark()] -// public void Dbp() -// { -// var src = Source; -// var lexer = new Lexer(); -// var tokens = lexer.Tokenize(src); -// var parser = new Parser(new TokenStream(tokens), StandardCommands.LimitedCommands); -// var exprAst = parser.ParseProgram(); -// -// var compiler = new Compiler(StandardCommands.LimitedCommands); -// compiler.Compile(exprAst); -// var _compilerProgram = compiler.Program; -// var _vm = new VirtualMachine(_compilerProgram); -// _vm.Execute2(); -// -// } -// - // [Benchmark()] - // public void Dbp_Cached() - // { - // _vm.Execute2(); - // } - // - // [Benchmark()] - // public void Csharp() - // { - // int[] x = new int[] { 2, 4, 6, 8 }; - // int y = x[0] * x[1]; - // } - -// -// [Benchmark()] -// public void Lua() -// { -// Script.RunString(@" -// x = 1 -// y = 2 -// z = 3 -// "); -// } - - [Benchmark()] - public void Lua_Cached() - { - _lua.DoString(@" -x = 1 -y = 2 -z = 3 -"); - } -// -} \ No newline at end of file + // [Benchmark] + // public void Execute() => _vm.Execute3(); +} diff --git a/FadeBasic/CHANGELOG.md b/FadeBasic/CHANGELOG.md index 4b3ce52..468582b 100644 --- a/FadeBasic/CHANGELOG.md +++ b/FadeBasic/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.65] +### Added +- `TEST` keyword and related mechanisms +- `LEN` keyword for checking string and single dimension array length + +### Changed +- Commands that accept a typed `isParams` arg can be fulfilled with a fade array + +### Fixed +- boolean type inference works through binary operators + ## [0.0.64] - 2026-04-28 ### Added - Rider IDE Plugin Support diff --git a/FadeBasic/DAP/FadeDebugAdapter.cs b/FadeBasic/DAP/FadeDebugAdapter.cs index dcfe2ab..fd45358 100644 --- a/FadeBasic/DAP/FadeDebugAdapter.cs +++ b/FadeBasic/DAP/FadeDebugAdapter.cs @@ -87,38 +87,11 @@ protected override AttachResponse HandleAttachRequest(AttachArguments arguments) _session = new RemoteDebugSession(port, _logger.Log); - _session.HitBreakpointCallback = () => - { - Protocol.SendEvent(new StoppedEvent - { - Reason = StoppedEvent.ReasonValue.Breakpoint, - Description = "Hit a breakpoint", - ThreadId = 1, - AllThreadsStopped = true, - HitBreakpointIds = new List(){0} - }); - }; + WireSessionEvents(); - _session.Exited = () => - { - Protocol.SendEvent(new ExitedEvent()); - Protocol.SendEvent(new TerminatedEvent()); - }; - - _session.RuntimeException = (error) => - { - _logger.Log($"Received runtime exception message=[{error}]"); - Protocol.SendEvent(new StoppedEvent(StoppedEvent.ReasonValue.Exception) - { - Text = "Fatal Exception", - Description = error, - AllThreadsStopped = true, - }); - }; - - // as soon as this event is sent- debugger info will appear. + // as soon as this event is sent- debugger info will appear. Protocol.SendEvent(new InitializedEvent()); - + _logger.Log("Attaching to debug application"); _session.Connect(); @@ -182,9 +155,17 @@ protected override LaunchResponse HandleLaunchRequest(LaunchArguments arguments) // at this point, we can actually kick off the process. var path = Path.GetDirectoryName(_fileName); var port = LaunchUtil.FreeTcpPort(); + + // public const string ENV_DEBUG_DOTNET_COMMAND = "FADE_BASIC_DEBUG_DOTNET_COMMAND"; + + var dotnetCommand = Environment.GetEnvironmentVariable("FADE_BASIC_DEBUG_DOTNET_COMMAND"); + if (string.IsNullOrEmpty(dotnetCommand)) + { + dotnetCommand = "run"; + } var startReq = new RunInTerminalRequest(path, new List { - DAPEnv.DotnetPath, "run", "--project", _fileName, "-p:FadeBasicDebug=true" + DAPEnv.DotnetPath, dotnetCommand, "--project", _fileName, "-p:FadeBasicDebug=true" }); startReq.Kind = RunInTerminalArguments.KindValue.Integrated; @@ -203,6 +184,28 @@ protected override LaunchResponse HandleLaunchRequest(LaunchArguments arguments) hasSession = true; _session = new RemoteDebugSession(port, _logger.Log); + WireSessionEvents(); + + // as soon as this event is sent- debugger info will appear. + Protocol.SendEvent(new InitializedEvent()); + + + this.Protocol.SendClientRequest(startReq, x => + { + _logger.Log("Connecting to debug application"); + _session.Connect(); + _session.SayHello(); + + }, (args, err) => + { + + }); + var res = new LaunchResponse(); + return res; + } + + private void WireSessionEvents() + { _session.RestartCallback = () => { _logger?.Log("RESTART HANDLING: Re-applying breakpoints and resuming"); @@ -214,6 +217,7 @@ protected override LaunchResponse HandleLaunchRequest(LaunchArguments arguments) _session.SayHello(); }); }; + _session.HitBreakpointCallback = () => { Protocol.SendEvent(new StoppedEvent @@ -242,26 +246,8 @@ protected override LaunchResponse HandleLaunchRequest(LaunchArguments arguments) AllThreadsStopped = true, }); }; - - // as soon as this event is sent- debugger info will appear. - Protocol.SendEvent(new InitializedEvent()); - - - this.Protocol.SendClientRequest(startReq, x => - { - _logger.Log("Connecting to debug application"); - _session.Connect(); - _session.SayHello(); - - }, (args, err) => - { - - }); - var res = new LaunchResponse(); - return res; } - protected override ConfigurationDoneResponse HandleConfigurationDoneRequest(ConfigurationDoneArguments arguments) { return new ConfigurationDoneResponse(); diff --git a/FadeBasic/FadeBasic.Export.Web/DAP_AUDIT.md b/FadeBasic/FadeBasic.Export.Web/DAP_AUDIT.md new file mode 100644 index 0000000..32eb5a3 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/DAP_AUDIT.md @@ -0,0 +1,99 @@ +# Web DAP Adapter — Architecture Notes + +The Playground drives `FadeBasic.Launch.DebugSession` directly through the +WebRuntime bridge. The page acts as both DAP **client** and **adapter** — +there's no OmniSharp / VSCode layer in between. + +## Why this matters + +In a native VSCode run, traffic looks like: + +``` +VSCode (UI) ↔ DAP adapter ↔ DebugSession (in-proc or TCP) +``` + +The DAP adapter: +- forwards client requests (`next`, `continue`, `setBreakpoints`, …) to DebugSession, +- translates DebugSession's outputs back into DAP events VSCode expects + (most importantly: `Stopped` events when a step lands or a breakpoint hits). + +In the playground: + +``` +Page UI ↔ worker bridge ↔ WebDebugSession (in-proc, no TCP) +``` + +The page IS the adapter. So when DebugSession ACKs a step request with +`StepNextResponseMessage { status=1, reason="hit next" }`, the page has +to recognize that ACK as "stopped" — the same translation a native DAP +adapter does silently. Code in [main.ts](../Playground/src/main.ts) +`onDebugEvent` case `PROTO_ACK` parses the payload and refreshes the +call-stack + variables when it sees `status === 1`. We do **not** emit +synthetic protocol events on the bridge side — the protocol shape stays +identical to what a native client sees. + +## Protocol-level behavior the bridge does **not** touch + +- `REQUEST_BREAKPOINTS` — bridge forwards the `Breakpoint` payload as-is. + `instructionMap.TryFindClosestTokenAtLocation` does the resolution. +- `REQUEST_STEP_OVER / IN / OUT` — forwarded with no modifications. + Landings ACK with `StepNextResponseMessage` exactly as native sees. +- `REQUEST_PAUSE / PLAY` — forwarded as-is. +- `REQUEST_STACK_FRAMES`, `REQUEST_SCOPES`, `REQUEST_VARIABLE_EXPANSION`, + `REQUEST_EVAL`, `REQUEST_REPL`, `REQUEST_SET_VAR` — bridge calls the + matching DebugSession method (`GetFrames2`, `GetScopes`, `Expand`, + `Eval`, `ReplExec`) and serializes the result. No DTO reshaping. +- `REV_REQUEST_BREAKPOINT` / `REV_REQUEST_EXITED` / `REV_REQUEST_EXPLODE` + / `PROTO_ACK` — bridge forwards everything DebugSession enqueues to + `outboundMessages` straight to the page. + +## Where we deliberately diverge from native + +| Concern | Native | Web | Why | +|---|---|---|---| +| **Transport** | TCP via `DebugSession.StartServer()` | Subclass `WebDebugSession` exposing `Enqueue` / `DrainOutbound` directly | No TCP in workers; no real client to connect. | +| **Pre-connect wait** | DebugSession blocks until `PROTO_HELLO` | Subclass pre-sets `didClientConnect = true`, `hasConnectedDebugger = 1`, `debuggerSaidHello = 1`. `debugWaitForConnection = false` on options. | We're embedded in the same process; the handshake is meaningless. | +| **Initial state** | Program runs immediately under `StartDebugging()` | Bridge enqueues `REQUEST_PAUSE` right after construction so the worker tick loop holds at instruction 0 | The page needs a window to install breakpoints before the VM begins. Without this, the worker's tick would race ahead of the page's `setBreakpoints` call. | +| **`Environment.Exit(0)` inside `REQUEST_TERMINATE`** | Hard-kills the process — appropriate for a standalone debug target | Bridge **never sends `REQUEST_TERMINATE`** — `FadeBridge.DebugTerminate` simply nulls the session reference; the worker tick loop sees `session == null`, stops, and emits a host-level `complete` event to the page. | `Environment.Exit(0)` in WASM would tear down the entire runtime including the LSP + WebCommands. The protocol mechanism (`requestedExit`) isn't needed because the bridge owns the lifetime. | +| **`LaunchOptions` static cctor** | Reads env vars + grabs a free TCP port | Patched to swallow exceptions and keep safe defaults | `LaunchUtil.FreeTcpPort()` throws in WASM (no sockets), and a throw in a static constructor produces `TypeInitializationException` on every later access to any field of `LaunchOptions`. Single try/catch keeps the type usable everywhere. | + +## Bridge surface (FadeBridge.cs) + +| Method | DebugSession route | +|---|---| +| `DebugStart(source)` | `Fade.TryCreateFromString → new WebDebugSession → Enqueue(REQUEST_PAUSE)` | +| `DebugTick(ops)` | `session.StartDebugging(ops); DrainOutbound()` | +| `DebugSetBreakpoints(json)` | `Enqueue(REQUEST_BREAKPOINTS)` | +| `DebugStep(kind)` | `Enqueue(REQUEST_STEP_{OVER,IN,OUT})` | +| `DebugContinue()` | `Enqueue(REQUEST_PLAY)` | +| `DebugPause()` | `Enqueue(REQUEST_PAUSE)` | +| `DebugTerminate()` | Drops `_debugSession` reference (no DAP message) | +| `DebugStackFrames()` | `session.GetFrames2()` | +| `DebugScopes(frameId)` | `session.GetScopes(new DebugScopeRequest { frameIndex = frameId })` | +| `DebugVariableExpansion(varId)` | `session.variableDb.Expand(varId)` | +| `DebugEval(frameId, expr)` | `session.Eval(...)` | +| `DebugRepl(frameId, code)` | `session.ReplExec(...)` | +| `DebugSetVariable(frameId, varId, rhs)` | `session.Eval(frameId, rhs, varId)` | + +## Worker tick loop + +`worker.js` `pumpDebugTick` runs `DebugTick(500)` repeatedly: +- 500-op budget per call so the worker yields to its postMessage pump + often enough that step / pause / set-breakpoint messages from the page + land between batches. +- 50ms delay when the session reports `paused`; 0ms when running, so the + VM gets to execute at full speed until something stops it. +- Print buffer drained per tick and streamed through the existing `print` + message channel so program output shows up in the Output panel live. + +## Page-side "DAP adapter" behavior + +`runner.onDebugEvent` cases: +- `REV_REQUEST_BREAKPOINT` → "paused at breakpoint" → refresh frames/vars. +- `PROTO_ACK` with `status === 1` → step landed → refresh frames/vars. + Plain ACKs (for `setBreakpoints` / `continue` / etc.) treated as "resumed". +- `REV_REQUEST_EXITED` / `complete` → session done. +- `REV_REQUEST_EXPLODE` → runtime error. + +This is exactly the translation a native DAP adapter does internally +before talking to VSCode. diff --git a/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj b/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj new file mode 100644 index 0000000..31a7cfd --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj @@ -0,0 +1,90 @@ + + + + net8.0 + enable + enable + true + FadeBasic.Export.Web + FadeBasic.Export.Web + + + true + copy + + true + + + + + FadeBasic.Export.Web + FadeBasic Web Export + Adds dotnet publish support for exporting a FadeBasic project as a self-contained static web bundle (itch.io / static host). + true + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs b/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs new file mode 100644 index 0000000..a791e12 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs @@ -0,0 +1,1267 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.Loader; +using System.Runtime.InteropServices.JavaScript; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; +using Microsoft.JSInterop; +using FadeBasic; +using FadeBasic.Json; +using FadeBasic.Launch; +using FadeBasic.Lib.Standard; +using FadeBasic.Sdk; +using FadeBasic.Virtual; +using FadeSdk = FadeBasic.Sdk.Fade; +using FadeBasic.LSP.Core; +using FadeBasic.LSP.Core.Handlers; + +namespace FadeBasic.Export.Web; + +// FadeBridge is the browser-side adapter between the worker's postMessage +// surface and the cross-platform LSP logic in FadeBasic.LSP.Core. The native +// LSP server in FadeBasic/LSP/ will get the same Core handlers behind its +// OmniSharp transport once it's refactored. +[SupportedOSPlatform("browser")] +public static partial class FadeBridge +{ + // Dynamically-registered command sources. Cleared by ClearCommandAssemblies + // and rebuilt from fade.json's commandDlls on every project-type change. + // MUST be declared before _workspace — static field initializers run in + // declaration order and CreateWorkspace reads this list. + private static readonly List _registeredSources = new(); + + // Source-generated metadata blobs (`MetaData.COMMANDS_JSON`) + // pulled out of each registered assembly. Feeds the workspace's docs + // provider so hover/help can render rich markdown for commands from + // dynamically-loaded libraries, not just StandardCommands. + private static readonly List _registeredCommandJsonBlobs = new(); + + // Dynamically-loaded assemblies keyed by simple name. WASM's default + // AssemblyLoadContext doesn't fall back to "scan loaded assemblies by + // simple name" when binding type references the way desktop CLR does — + // so when the entry assembly's static cctor does `new SomeLib.Foo()`, + // resolution fails unless we hand the assembly back through Resolving. + private static readonly Dictionary _dynamicAssemblies = new(); + private static bool _resolverHooked; + + private static void EnsureResolverHooked() + { + if (_resolverHooked) return; + _resolverHooked = true; + AssemblyLoadContext.Default.Resolving += (_, name) => + _dynamicAssemblies.TryGetValue(name.Name ?? "", out var asm) ? asm : null; + } + + // Load `bytes` into the default ALC and register the loaded assembly so + // the Resolving handler can hand it back when the entry's type binder + // looks it up by simple name. Duplicates with _framework/ are harmless: + // default resolution finds those first, and Resolving only fires when + // default resolution fails — so the byte-loaded copy is reachable only + // for the assemblies that aren't already in _framework/. + private static Assembly LoadAndRegister(byte[] bytes) + { + EnsureResolverHooked(); + var asm = Assembly.Load(bytes); + var simpleName = asm.GetName().Name; + if (!string.IsNullOrEmpty(simpleName)) + _dynamicAssemblies[simpleName] = asm; + return asm; + } + + // Active workspace — rebuilt by SetProjectType and RegisterCommandAssembly. + private static FadeWorkspace _workspace = CreateWorkspace("web"); + private static string _activeProjectType = "web"; + + private static FadeWorkspace CreateWorkspace(string projectType) + { + var sources = new List(_registeredSources) { new StandardCommands() }; + var commands = new CommandCollection(sources.ToArray()); + + // Docs follow whatever's registered: StandardCommands is always + // there, plus one COMMANDS_JSON blob per dynamically-loaded + // assembly (collected in RegisterCommandAssembly). projectType + // doesn't pick the docs anymore — the assemblies themselves do. + var blobs = new List(_registeredCommandJsonBlobs.Count + 1) + { + StandardCommandsMetaData.COMMANDS_JSON, + }; + blobs.AddRange(_registeredCommandJsonBlobs); + ICommandDocsProvider docs = StandardCommandDocs.Build(blobs.ToArray()); + _ = projectType; + + var ws = new FadeWorkspace(commands); + ws.Docs = docs; + return ws; + } + + // Called by the worker (main.ts → worker.js) when the active fade.json + // type changes. Rebuilds the workspace with the right CommandCollection + // so the LSP picks up the new command surface. Returns the new type so + // the page can log/confirm. Idempotent. + [JSExport] + public static string SetProjectType(string projectType) + { + var t = (projectType ?? "web").ToLowerInvariant(); + if (t == _activeProjectType) return t; + _activeProjectType = t; + _workspace = CreateWorkspace(t); + return t; + } + + // Load a command DLL from raw bytes, instantiate the named class, and + // merge it into the workspace. Both workers (LSP + VM) receive this call + // so hover/completion and execution see the same command surface. + // dllBytes is a Uint8Array on the JS side; className is fully-qualified. + [JSExport] + public static string RegisterCommandAssembly(byte[] dllBytes, string className) + { + try + { + var asm = LoadAndRegister(dllBytes); + var type = asm.GetType(className) + ?? throw new Exception($"Type '{className}' not found in assembly"); + var instance = Activator.CreateInstance(type) as IMethodSource + ?? throw new Exception($"'{className}' does not implement IMethodSource"); + _registeredSources.Add(instance); + + // The command source generator emits a sibling `MetaData` + // type with a `public const string COMMANDS_JSON` carrying the + // XML doc strings for every command. Pull it out so hover/help + // can render rich markdown — without this, registered libraries + // show just the signature shape. + var metaType = asm.GetType(className + "MetaData"); + var jsonField = metaType?.GetField("COMMANDS_JSON", + BindingFlags.Public | BindingFlags.Static); + if (jsonField?.GetRawConstantValue() is string json && !string.IsNullOrEmpty(json)) + _registeredCommandJsonBlobs.Add(json); + + _workspace = CreateWorkspace(_activeProjectType); + return StatusOk(); + } + catch (Exception ex) + { + return StatusErr(ex); + } + } + + // Remove all dynamically-registered command sources and rebuild the workspace. + // Called by the page before re-registering whenever fade.json's commandDlls changes. + [JSExport] + public static string ClearCommandAssemblies() + { + _registeredSources.Clear(); + _registeredCommandJsonBlobs.Clear(); + _workspace = CreateWorkspace(_activeProjectType); + return "true"; + } + + // Load a side-by-side dependency DLL into the AppDomain without + // registering it as a command source. Used by the export loader to pull + // in the game's transitive deps (e.g. FadeBasic.Lib.Web.dll) BEFORE the + // entry assembly is loaded — otherwise resolving the entry's ILaunchable + // type would fail because referenced assemblies aren't yet present. + [JSExport] + public static string LoadAssembly(byte[] dllBytes) + { + try + { + LoadAndRegister(dllBytes); + return StatusOk(); + } + catch (Exception ex) + { + return StatusErr(ex); + } + } + + // ─── Cooperative pump model ─────────────────────────────────────── + // The web runtime can't run the VM to completion in one synchronous + // call: while C# is executing, the worker's JS event loop is blocked, + // so any postMessage from the page (prompt answers, pause/stop) sits + // undelivered in the queue. Instead, we hold the VM as static state + // and the worker pumps it in small budgeted batches, yielding to its + // event loop between batches. `prompt$` and `wait ms` cooperate by + // calling vm.Suspend() and stashing wake-up state in WebRuntimeBridge; + // the JS pump reads that state to decide how (and when) to schedule + // the next tick. See worker.js for the pump driver, WebCommands.cs + // for the prompt$/wait ms command implementations. + + // All cooperative-pump state + methods live in FadeBasic.Sdk.CooperativePump. + // FadeBridge delegates its JSExports to CooperativePump and + // keeps only the things that are genuinely Export.Web-specific: + // assembly loading (LoadAndRegister), workspace + LSP state, the + // debug session, and the JS-interop wiring (JSImport / JSExport). + + // Routes C# → JS for HostBridge.PostMessage. runtime.js binds the + // 'fade-runtime' module to a fan-out that posts `host-message` to + // the page; the page dispatches by `channel` and replies with a + // typed `host-reply` that flows back into DepositResultString etc. + [JSImport("postHostMessage", "fade-runtime")] + internal static partial void PostHostMessage(string channel, string payload); + + // Static wire-up: hook the cooperative pump into this host. Runs + // once on first touch of FadeBridge (at runtime boot, when JS + // resolves the assembly's exports). + // + // The pump itself lives in FadeBasic.Sdk.CooperativePump — + // we just wire its delegate slots so it can fetch our active + // command set, our WaitImpl override redirects to its cooperative + // path, and HostBridge.PostMessage / SuspendVm route through it. + // MonoGame will do an identical wire-up in its own startup. + static FadeBridge() + { + CooperativePump.CommandsAccessor = () => _workspace.Commands; + + StandardCommands.WaitImpl = ms => + { + // Three paths, picked by which driver is in flight: + // - Cooperative pump (Run / tests): RunVm non-null → set + // pending wait + suspend; JS pump schedules next tick. + // - Debug session: _debugSession non-null → same, but on + // the debug session's VM; pumpDebugTick handles the + // setTimeout cadence. + // - Fallback: Thread.Sleep. Should not happen in normal use. + if (CooperativePump.RunVm != null) + { + CooperativePump.OnCooperativeWait(ms); + } + else if (_debugSession != null) + { + // DebugTick reads _pendingWaitMs out of CooperativePump + // — sharing the field keeps the JS pump on one source + // of truth. Suspend the session's VM directly here + // since CooperativePump only knows about RunVm. + CooperativePump.OnCooperativeWait(ms); + _debugSession._vm?.Suspend(); + } + else + { + System.Threading.Thread.Sleep(ms); + } + }; + HostBridge.PostMessage = (channel, payload) => + PostHostMessage(channel, payload); + HostBridge.SuspendVm = () => CooperativePump.OnHostReplyWait(); + } + + // Begin a run from an entry assembly's bytes. Host-specific because + // it loads the consumer's DLL into our AssemblyLoadContext via + // LoadAndRegister; once we've got the ILaunchable, we hand the VM + // to the cooperative pump and the rest of the flow is identical to + // RunStartFromSource / RunStartFromBytecode. + [JSExport] + public static string RunStart(byte[] entryDllBytes) + { + try + { + var asm = LoadAndRegister(entryDllBytes); + Type launchableType = null; + foreach (var t in asm.GetTypes()) + { + if (!t.IsClass || t.IsAbstract) continue; + if (typeof(ILaunchable).IsAssignableFrom(t)) { launchableType = t; break; } + } + if (launchableType == null) + throw new Exception("No ILaunchable implementation found in entry assembly"); + var instance = (ILaunchable)Activator.CreateInstance(launchableType); + var vm = new VirtualMachine(instance.Bytecode) + { + hostMethods = HostMethodTable.FromCommandCollection(instance.CommandCollection), + }; + CooperativePump.RunStartWithVm(vm); + return StatusOk(); + } + catch (Exception ex) + { + return StatusErr(ex); + } + } + + // Compile-from-source / bytecode entry points and the compile-only + // helpers all delegate to CooperativePump. The JSExport wrappers are + // the only host-specific bit — they expose the pump's static methods + // through Mono WASM's [JSExport] surface. WebRuntime.MonoGame will + // expose the same pump via [JSInvokable] wrappers (different surface, + // same shared logic). + [JSExport] + public static string RunStartFromSource(string source) => + CooperativePump.RunStartFromSource(source); + + [JSExport] + public static byte[] CompileToBytecode(string source) => + CooperativePump.CompileToBytecode(source); + + [JSExport] + public static string CompileToBytecodeStatus(string source) => + CooperativePump.CompileToBytecodeStatus(source); + + [JSExport] + public static string RunStartFromBytecode(byte[] bytecode) => + CooperativePump.RunStartFromBytecode(bytecode); + + // RunTick + StopRun + all Deposit* methods are pump-internal — + // identical across hosts. Delegate to CooperativePump. + + [JSExport] + public static string RunTick(int budget) => CooperativePump.RunTick(budget); + + [JSExport] + public static string StopRun() => CooperativePump.StopRun(); + + [JSExport] + public static string DepositResultString(string value) => + CooperativePump.DepositResultString(value); + + [JSExport] + public static string DepositResultInt(int value) => + CooperativePump.DepositResultInt(value); + + [JSExport] + public static string DepositResultReal(float value) => + CooperativePump.DepositResultReal(value); + + [JSExport] + public static string DepositResultBool(bool value) => + CooperativePump.DepositResultBool(value); + + [JSExport] + public static string DepositResultByte(byte value) => + CooperativePump.DepositResultByte(value); + + [JSExport] + public static string DepositResultWord(int value) => + CooperativePump.DepositResultWord(value); + + [JSExport] + public static string DepositResultDword(int value) => + CooperativePump.DepositResultDword(value); + + // int64. The JSMarshalAs annotation tells the JS generator to use + // BigInt on the JS side — without it the generator refuses to + // marshal `long` (SYSLIB1072). The page handler should return + // `{ resultType: 'dint', value: BigInt(...) }` to preserve values + // past 2^53. + [JSExport] + public static string DepositResultDint( + [JSMarshalAs] long value) => + CooperativePump.DepositResultDint(value); + + [JSExport] + public static string DepositResultDfloat(double value) => + CooperativePump.DepositResultDfloat(value); + + [JSExport] + public static string DepositResultVoid() => + CooperativePump.DepositResultVoid(); + + // Unwrap TargetInvocationException / TypeInitializationException so the + // page sees the real cause instead of "Arg_TargetInvocationException". + // Resource-key messages are common under WASM trimming — strings like + // ArgumentNull_Generic land in the report; the chain plus type name + // usually narrows things down. + private static string DescribeException(Exception ex) + { + var sb = new StringBuilder(); + var current = ex; + var depth = 0; + while (current != null && depth < 6) + { + if (sb.Length > 0) sb.Append("\n → "); + sb.Append(current.GetType().FullName).Append(": ").Append(current.Message); + if (!string.IsNullOrEmpty(current.StackTrace) && depth == 0) + sb.Append('\n').Append(current.StackTrace); + current = current.InnerException; + depth++; + } + return sb.ToString(); + } + + // camelCase JSON to match LSP wire-protocol convention; TS interfaces in + // Playground use lowercase field names. IncludeFields is critical — Core + // DTOs use public FIELDS (not properties), which System.Text.Json ignores + // by default. Without this every diagnostic serializes as {} and the + // Playground throws "d.range is undefined". + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + IncludeFields = true, + }; + + // Hand-rolled JSON for the {ok, error?} status shape used by every + // assembly-loading JSExport. `JsonSerializer.Serialize(new { ok=true })` + // is anonymous-type-based; in this project's Release/trimmed publish, + // the trimmer strips parameter names from `<>f__AnonymousType*` even + // with TrimMode=copy, and System.Text.Json then throws "deserialization + // constructor ... contains parameters with null names". A literal JSON + // string sidesteps the metadata reflection entirely. + private static string StatusOk() => "{\"ok\":true}"; + private static string StatusErr(Exception ex) + { + var sb = new StringBuilder("{\"ok\":false,\"error\":"); + AppendJsonString(sb, DescribeException(ex)); + sb.Append('}'); + return sb.ToString(); + } + + // JSON-safe string emit: quotes the value and escapes the characters + // RFC 8259 requires (`"`, `\`, and U+0000–U+001F). FadeBasic.Json's + // built-in JsonWriteOp.AppendEscaped only handles `"` and `\`, which + // is fine for the short identifiers the rest of the project emits but + // produces invalid JSON when the value contains markdown newlines — + // exactly what ListCommandDocs hits when it serializes hover markdown. + private static void AppendJsonString(StringBuilder sb, string value) + { + sb.Append('"'); + if (value != null) + { + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) + sb.Append("\\u").Append(((int)c).ToString("x4")); + else + sb.Append(c); + break; + } + } + } + sb.Append('"'); + } + + // ─── Run ────────────────────────────────────────────────────────────── + [JSInvokable] + [JSExport] + // Returns a JSON envelope so the page can format different kinds of + // output (compile errors / runtime errors / printed stdout) with their + // own styling. Shape: { compileError, runtimeError, printed }. Any + // field may be null/empty. Print output also streams through `onPrint` + // during execution; we drain anything that wasn't flushed yet. + public static string CompileAndRun(string source) + { + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + compileError = errors.ToDisplay(), + runtimeError = (string)null, + printed = "", + }, _jsonOpts); + } + + string runtimeError = null; + try { ctx.Run(); } + catch (Exception ex) { runtimeError = ex.GetType().Name + ": " + ex.Message; } + + return JsonSerializer.Serialize(new + { + compileError = (string)null, + runtimeError, + printed = "", + }, _jsonOpts); + } + + // ─── LSP entry points — thin adapters over Core ─────────────────────── + + [JSExport] + public static string LspSetDocument(string uri, string text) + { + try + { + var doc = _workspace.SetDocument(uri, text); + return JsonSerializer.Serialize(DiagnosticsHandler.Compute(doc), _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new LspDiagnostic[] + { + new LspDiagnostic + { + Severity = LspDiagnosticSeverity.Error, + Range = new LspRange + { + Start = new LspPosition { Line = 0, Character = 0 }, + End = new LspPosition { Line = 0, Character = 1 }, + }, + Message = $"LSP internal error: {ex.GetType().Name}: {ex.Message}", + Code = "INT-001", + Source = "fade", + }, + }, _jsonOpts); + } + } + + [JSExport] + public static string LspGetSemanticTokens(string uri) + { + var doc = _workspace.Get(uri); + return JsonSerializer.Serialize(SemanticTokensHandler.Compute(doc), _jsonOpts); + } + + // Tokenize a free-floating snippet of Fade source — no workspace doc, + // no diagnostics published — and return a flat list of `{line, col, + // length, type}` entries. The Help tab uses this to syntax-highlight + // ```fade``` code blocks in command/language docs by piggybacking on + // the same lexer + ClassifyToken pass the LSP semantic-tokens handler + // uses for the editor. The type field is the legend index from + // SemanticTokensHandler.Legend (0=comment, 1=keyword, …). + [JSExport] + public static string LspTokenizeSnippet(string source) + { + if (string.IsNullOrEmpty(source)) return "[]"; + var commands = _workspace.Commands; + var lex = new FadeBasic.Lexer().TokenizeWithErrors(source, commands); + var doc = new FadeBasic.LSP.Core.FadeDocument + { + Uri = "fade://help-snippet", + Text = source, + LexResults = lex, + Commands = commands, + }; + var sb = new StringBuilder("["); + var first = true; + foreach (var ct in SemanticTokensHandler.Classify(doc)) + { + if (!first) sb.Append(','); + first = false; + sb.Append("{\"line\":").Append(ct.Token.lineNumber); + sb.Append(",\"col\":").Append(ct.Token.charNumber); + sb.Append(",\"length\":").Append(ct.Token.Length); + sb.Append(",\"type\":").Append(SemanticTokensHandler.LegendIndex(ct.Type)); + sb.Append('}'); + } + sb.Append(']'); + return sb.ToString(); + } + + [JSExport] + public static string LspHover(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var hover = HoverHandler.Compute(doc, line, character); + return hover == null ? "null" : JsonSerializer.Serialize(hover, _jsonOpts); + } + + [JSExport] + public static string LspCompletion(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var items = CompletionHandler.Compute(doc, line, character); + return JsonSerializer.Serialize(items, _jsonOpts); + } + + [JSExport] + public static string LspGetAllDiagnostics() + { + var all = new Dictionary>(); + foreach (var doc in _workspace.AllDocuments) + all[doc.Uri] = DiagnosticsHandler.Compute(doc); + return JsonSerializer.Serialize(all, _jsonOpts); + } + + [JSExport] + public static string LspSignatureHelp(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var sig = SignatureHelpHandler.Compute(doc, line, character); + return sig == null ? "null" : JsonSerializer.Serialize(sig, _jsonOpts); + } + + [JSExport] + public static string LspReferences(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var refs = ReferencesHandler.Compute(doc, line, character); + return JsonSerializer.Serialize(refs ?? new List(), _jsonOpts); + } + + [JSExport] + public static string LspDefinition(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var def = DefinitionHandler.Compute(doc, line, character); + return def == null ? "null" : JsonSerializer.Serialize(def, _jsonOpts); + } + + [JSExport] + public static string LspDocumentSymbols(string uri) + { + var doc = _workspace.Get(uri); + var syms = DocumentSymbolHandler.Compute(doc); + return JsonSerializer.Serialize(syms ?? new List(), _jsonOpts); + } + + [JSExport] + public static string LspFoldingRanges(string uri) + { + var doc = _workspace.Get(uri); + var ranges = FoldingRangeHandler.Compute(doc); + return JsonSerializer.Serialize(ranges ?? new List(), _jsonOpts); + } + + // optionsJson is an LspFormattingOptions in camelCase JSON. + [JSExport] + public static string LspFormat(string uri, string optionsJson) + { + var doc = _workspace.Get(uri); + var opts = string.IsNullOrEmpty(optionsJson) + ? new LspFormattingOptions() + : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); + var edits = FormattingHandler.Compute(doc, opts); + return JsonSerializer.Serialize(edits, _jsonOpts); + } + + [JSExport] + public static string LspFormatRange(string uri, string optionsJson, int startLine, int startCh, int endLine, int endCh) + { + var doc = _workspace.Get(uri); + var opts = string.IsNullOrEmpty(optionsJson) + ? new LspFormattingOptions() + : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); + var range = new LspRange + { + Start = new LspPosition { Line = startLine, Character = startCh }, + End = new LspPosition { Line = endLine, Character = endCh }, + }; + var edits = FormattingHandler.ComputeRange(doc, opts, range); + return JsonSerializer.Serialize(edits, _jsonOpts); + } + + [JSExport] + public static string LspFormatOnType(string uri, string optionsJson, int line, int character) + { + var doc = _workspace.Get(uri); + var opts = string.IsNullOrEmpty(optionsJson) + ? new LspFormattingOptions() + : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); + var edits = FormattingHandler.ComputeOnType(doc, opts, new LspPosition { Line = line, Character = character }); + return JsonSerializer.Serialize(edits, _jsonOpts); + } + + [JSExport] + public static string LspRename(string uri, int line, int character, string newName) + { + var doc = _workspace.Get(uri); + var edit = RenameHandler.Compute(doc, line, character, newName); + return edit == null ? "null" : JsonSerializer.Serialize(edit, _jsonOpts); + } + + // ─── Help / command docs ────────────────────────────────────────────── + // Returns a JSON array of every command currently loaded in the + // workspace's CommandCollection, with the same markdown the hover + // provider renders. Used by the page's Help tab to build a TOC + + // per-command reader. One row per UNIQUE command name (overloads + // collapse — the first signature wins). Sorted alphabetically. + [JSExport] + public static string ListCommandDocs() + { + try + { + var commands = _workspace.Commands?.Commands; + if (commands == null) return "[]"; + + // Map command name → owning class label, derived from each + // IMethodSource's CommandGroupName (e.g. + // "Fade.MonoGame.Lib.FadeMonoGameCommands"). FIRST source wins + // on name collisions, matching the dedupe ordering applied to + // workspace.Commands below — otherwise a command's body and + // group label could come from different sources. We shorten + // FQNs to "" so the TOC reads + // "Standard" / "FadeMonoGame" rather than the full namespace. + var nameToGroup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var sources = _workspace.Commands?.Sources; + if (sources != null) + { + foreach (var source in sources) + { + var groupLabel = ShortenGroupName(source.CommandGroupName); + foreach (var cmd in source.Commands) + { + if (string.IsNullOrEmpty(cmd.name)) continue; + if (!nameToGroup.ContainsKey(cmd.name)) nameToGroup[cmd.name] = groupLabel; + } + } + } + + // Dedupe by command.name. Overloads (e.g. `rgb` with 3 vs 4 + // args) share a name; we surface one row per name and use the + // first CommandInfo we find — BuildCommandMarkdown already + // describes all parameter slots from that signature. + var seen = new HashSet(); + var rows = new List<(string name, string sig, string group, string markdown)>(); + foreach (var c in commands) + { + if (string.IsNullOrEmpty(c.name)) continue; + if (!seen.Add(c.name)) continue; + string markdown; + try + { + markdown = FadeBasic.LSP.Core.Handlers.HoverHandler.BuildCommandMarkdown( + c, _workspace.Docs); + } + catch (Exception ex) + { + markdown = $"### {c.name}\n\n_Failed to render docs: {ex.Message}_"; + } + // group: the IMethodSource the command came from, so the + // TOC reflects actual library origin. GuessGroup is the + // backstop for commands that somehow have no source map + // entry (shouldn't happen — every Command was iterated + // off some Source above — but defensive). + var group = nameToGroup.TryGetValue(c.name, out var g) ? g : GuessGroup(c.name); + rows.Add((c.name, c.sig, group, markdown)); + } + // Stable alphabetical order so the TOC is deterministic. + rows.Sort((a, b) => string.Compare(a.name, b.name, StringComparison.OrdinalIgnoreCase)); + + // Hand-rolled output instead of `JsonSerializer.Serialize(new { ... })`: + // anonymous types are unreliable under the project's trimmed + // Release publish (see the note on StatusOk/StatusErr above). + var sb = new StringBuilder(); + sb.Append('['); + for (var i = 0; i < rows.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append("{\"name\":"); AppendJsonString(sb, rows[i].name); + sb.Append(",\"signature\":"); AppendJsonString(sb, rows[i].sig); + sb.Append(",\"group\":"); AppendJsonString(sb, rows[i].group); + sb.Append(",\"markdown\":"); AppendJsonString(sb, rows[i].markdown); + sb.Append('}'); + } + sb.Append(']'); + return sb.ToString(); + } + catch (Exception ex) + { + var sb = new StringBuilder("{\"error\":"); + AppendJsonString(sb, "Failed to enumerate command docs: " + ex.Message); + sb.Append('}'); + return sb.ToString(); + } + } + + // Cheap heuristic: cluster commands by their first word so the TOC + // gets meaningful section headings (e.g. "print", "string", "wait"). + // For multi-word commands ("wait ms", "wait key") this also yields a + // shared bucket. Single-word commands get their own bucket named + // after themselves only when no peers share the prefix — to avoid + // a 200-bucket TOC, single-words fall back to a generic "Core" group. + private static string GuessGroup(string name) + { + if (string.IsNullOrEmpty(name)) return "Core"; + var idx = name.IndexOf(' '); + return idx > 0 ? name.Substring(0, idx) : "Core"; + } + + // Turn an IMethodSource.CommandGroupName (a fully-qualified type name like + // "Fade.MonoGame.Lib.FadeMonoGameCommands") into a human-friendly TOC + // section label ("FadeMonoGame"). Strips the namespace and the + // conventional "Commands" suffix on the type name. + private static string ShortenGroupName(string fqn) + { + if (string.IsNullOrEmpty(fqn)) return "Core"; + var dot = fqn.LastIndexOf('.'); + var typeName = dot >= 0 ? fqn.Substring(dot + 1) : fqn; + const string suffix = "Commands"; + if (typeName.EndsWith(suffix, StringComparison.Ordinal) && typeName.Length > suffix.Length) + typeName = typeName.Substring(0, typeName.Length - suffix.Length); + return string.IsNullOrEmpty(typeName) ? "Core" : typeName; + } + + // ─── Tests ──────────────────────────────────────────────────────────── + // Compile the source and list the test entry points. Returns a JSON + // array of { name, isAbstract, fromParent, sourceLine }. On compile + // failure returns an empty array (errors surface via LspSetDocument). + [JSExport] + public static string ListTests(string source) + { + try + { + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out _)) + return "[]"; + var tests = new List(); + foreach (var t in ctx.Compiler.TestManifest) + { + tests.Add(new + { + name = t.name, + isAbstract = t.isAbstract, + fromParent = t.fromParent, + sourceLine = t.sourceLine, + sourceChar = t.sourceChar, + }); + } + return JsonSerializer.Serialize(tests, _jsonOpts); + } + catch + { + return "[]"; + } + } + + // Begin a cooperative test run — delegates to CooperativePump. + // The JS pump drives RunTick repeatedly and CooperativePump.RunTick + // handles the per-test transitions internally. + [JSExport] + public static string RunTestsStart(string source, string testName) => + CooperativePump.RunTestsStart(source, testName); + + // ─── Debug session (DAP) ──────────────────────────────────────────── + // One active session at a time. The worker calls DebugStart() to + // compile + boot a session, then DebugTick() in a loop to make + // forward progress, draining outbound messages between ticks. + + private static FadeRuntimeContext _debugContext; + private static WebDebugSession _debugSession; + private static int _debugMessageIdCounter; + // Tracks the pause state across ticks so we can emit a synthetic stop + // event on running→paused transitions (e.g. step landings, which the + // base DebugSession only signals via a PROTO_ACK on the step request). + private static bool _debugWasPaused; + // Set when DebugStartTest boots a session targeting a specific test. + // GetDebugTestResult uses this to know which test name to report, and + // (via _debugSession._vm.assertionFailure) whether it passed. Cleared + // by DebugStart (non-test) and DebugTerminate so subsequent debug + // queries return null instead of stale data. + private static FadeBasic.Virtual.TestManifestEntry _debugTestEntry; + + private static int NextDebugId() => ++_debugMessageIdCounter; + + // Compile + boot a debug session that targets a specific test entry + // point. Mirrors FadeTestExecutor.RunTest's setup — a fresh VM at the + // test's entry address with isTestExecution=true — but wraps it in a + // WebDebugSession so we can pause, step, and inspect normally. + [JSExport] + public static string DebugStartTest(string source, string testName) + { + try + { + DebugTerminate(); + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Compile failed:\n" + errors.ToDisplay(), + statementLines = Array.Empty(), + }, _jsonOpts); + } + FadeBasic.Virtual.TestManifestEntry foundEntry = null; + foreach (var t in ctx.Compiler.TestManifest) + { + if (string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)) + { + foundEntry = t; + break; + } + } + if (foundEntry == null || foundEntry.isAbstract) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = foundEntry == null + ? $"No test named '{testName}' found" + : $"Test '{testName}' is abstract and cannot be debugged", + }, _jsonOpts); + } + + // Fresh VM at the test's entry address (matches + // FadeTestExecutor.RunTest's bootstrap so the test runs the same + // way it would in normal test execution). + var vm = new FadeBasic.Virtual.VirtualMachine(ctx.Machine.program, foundEntry.entryPointAddress) + { + hostMethods = ctx.Compiler.methodTable, + isTestExecution = true, + }; + _debugContext = ctx; + _debugSession = new WebDebugSession(vm, ctx.Compiler.DebugData, commands); + _debugTestEntry = foundEntry; + _debugWasPaused = true; + EnqueueBasic(DebugMessageType.REQUEST_PAUSE); + + var lines = new SortedSet(); + foreach (var t in ctx.Compiler.DebugData.statementTokens) + if (t?.token != null) lines.Add(t.token.lineNumber); + return JsonSerializer.Serialize(new + { + ok = true, + statementLines = lines, + testName = foundEntry.name, + testLine = foundEntry.sourceLine, + }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Debug-test start failed: " + ex.Message, + }, _jsonOpts); + } + } + + // Compile + boot a debug session. Returns JSON with { ok, error?, + // statementTokens[] } so the page can render gutter glyphs at valid + // breakpoint lines. + [JSExport] + public static string DebugStart(string source) + { + try + { + DebugTerminate(); // reset any prior session. + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Compile failed:\n" + errors.ToDisplay(), + statementLines = Array.Empty(), + }, _jsonOpts); + } + _debugContext = ctx; + _debugSession = new WebDebugSession(ctx.Machine, ctx.Compiler.DebugData, commands); + _debugTestEntry = null; // non-test debug; clear any prior test marker + // Pre-mark as paused so the first tick's running→paused detection + // doesn't fire a synthetic stop event for our internal start- + // pause. Real pauses (breakpoints, steps) flip from false→true + // and emit normally. + _debugWasPaused = true; + + // Start the session in a paused state. The page must set its + // breakpoints and then call DebugContinue() to begin running. + // Without this, the tick loop in worker.js would race the page + // and execute past any breakpoints before they're installed. + EnqueueBasic(DebugMessageType.REQUEST_PAUSE); + + // Surface valid statement lines so the editor can show breakpoint + // hints. statementTokens have 1-based lineNumber. + var lines = new SortedSet(); + foreach (var t in ctx.Compiler.DebugData.statementTokens) + if (t?.token != null) lines.Add(t.token.lineNumber); + return JsonSerializer.Serialize(new + { + ok = true, + statementLines = lines, + }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Debug start failed: " + ex.Message, + }, _jsonOpts); + } + } + + // Run a budget of VM instructions. Returns drained outbound messages + // (as a JSON array of DebugMessage) + a small status object. The + // worker loops over this until either a stop message arrives, a + // terminate request comes in, or the program completes. + [JSExport] + public static string DebugTick(int ops) + { + if (_debugSession == null) + return JsonSerializer.Serialize(new { running = false, complete = true, messages = Array.Empty() }, _jsonOpts); + + // Per-tick reset for the cooperative-wait hint. WaitImpl writes + // this when `wait ms` fires inside the debug session; pumpDebugTick + // reads it from the response and uses it as the next setTimeout + // delay. Without the reset, a stale value from a previous tick + // would re-trigger the wait. The state lives in CooperativePump + // so RunTick and DebugTick share the same source of truth. + CooperativePump.PendingWaitMs = 0; + try { _debugSession.StartDebugging(ops); } + catch (Exception ex) { /* never fail the worker — surface as a message */ + _debugSession.Enqueue(new DebugMessage { id = NextDebugId(), type = DebugMessageType.NOOP }); + return JsonSerializer.Serialize(new + { + running = false, + complete = true, + error = "Runtime exception: " + ex.Message, + messages = Array.Empty(), + }, _jsonOpts); + } + // If WaitImpl flipped requestedExit to unwind early (kind=3 yield + // for breakpoint updates etc., or kind=2 terminate before the + // page's debug-terminate has landed), clear the flag now so the + // NEXT tick can resume normally. For genuine kind=2 terminate + // the debug-terminate message will null _debugSession on the + // next worker tick anyway, so the reset is harmless there. + _debugSession.ClearYieldRequest(); + + var drained = _debugSession.DrainOutbound(); + var msgs = new List(drained.Count); + foreach (var m in drained) + { + msgs.Add(new + { + id = m.id, + type = m.type.ToString(), + json = m.RawJson ?? m.Jsonify(), + }); + } + + // No synthetic events. The page acts as its own DAP adapter — it + // listens for PROTO_ACK with status=1 on its own step requests and + // treats those as "stopped after step", same way a real DAP adapter + // translates the ACK into a DAP Stopped event for VSCode. + + var printed = ""; + return JsonSerializer.Serialize(new + { + running = !_debugSession.IsPaused, + paused = _debugSession.IsPaused, + complete = _debugSession.ProgramComplete, + instructionPointer = _debugSession.InstructionPointer, + messages = msgs, + // Cooperative wait: when `wait ms` fired during this tick + // the JS pump should setTimeout for that duration before + // the next tick. Zero means "no wait pending" — pump uses + // its normal small interval. + waitMs = CooperativePump.PendingWaitMs, + printed, + }, _jsonOpts); + } + + // Replace the active breakpoint set. linesJson is a JSON array of + // { lineNumber, colNumber? } pairs in the source's coordinate space. + [JSExport] + public static string DebugSetBreakpoints(string linesJson) + { + if (_debugSession == null) return "false"; + var input = JsonSerializer.Deserialize>(linesJson, _jsonOpts) + ?? new List(); + var msg = new RequestBreakpointMessage + { + id = NextDebugId(), + type = DebugMessageType.REQUEST_BREAKPOINTS, + breakpoints = input.Select(b => new Breakpoint + { + lineNumber = b.Line, + colNumber = b.Column, + }).ToList(), + }; + // RawJson is what the session uses when re-parsing typed payloads. + msg.RawJson = msg.Jsonify(); + _debugSession.Enqueue(msg); + return "true"; + } + + [JSExport] + public static string DebugStep(string kind) + { + if (_debugSession == null) return "false"; + var type = kind switch + { + "over" => DebugMessageType.REQUEST_STEP_OVER, + "in" => DebugMessageType.REQUEST_STEP_IN, + "out" => DebugMessageType.REQUEST_STEP_OUT, + _ => DebugMessageType.NOOP, + }; + if (type == DebugMessageType.NOOP) return "false"; + EnqueueBasic(type); + return "true"; + } + + [JSExport] + public static string DebugContinue() + { + if (_debugSession == null) return "false"; + EnqueueBasic(DebugMessageType.REQUEST_PLAY); + return "true"; + } + + [JSExport] + public static string DebugPause() + { + if (_debugSession == null) return "false"; + EnqueueBasic(DebugMessageType.REQUEST_PAUSE); + return "true"; + } + + [JSExport] + public static string DebugTerminate() + { + // Do NOT enqueue REQUEST_TERMINATE — DebugSession's handler calls + // Environment.Exit(0) which would kill the entire WASM runtime. + // Just drop our references; the session is GC'd naturally and the + // tick loop sees `session == null` on its next call. + _debugSession = null; + _debugContext = null; + _debugTestEntry = null; + return "true"; + } + + // Extract a FadeTestResult from the currently-debugging test's VM. + // Returns "null" (JSON) when the session isn't a test debug or when + // there's no live session to inspect. + // + // Callable at any point during a debug-test session — the Playground + // typically calls it once the session emits 'complete' so it can + // flip the test row from 'running' to 'pass'/'fail'. Calling + // mid-execution returns a partial snapshot (assertionFailure may + // not be set yet); the result is most meaningful when the VM has + // run past program.Length, which is exactly the 'complete' signal + // the Playground listens for. + [JSExport] + public static string GetDebugTestResult() + { + if (_debugSession == null || _debugTestEntry == null) return "null"; + var vm = _debugSession._vm; + if (vm == null) return "null"; + var elapsed = System.TimeSpan.Zero; // debug sessions don't time tests + var result = FadeBasic.Sdk.FadeTestExecutor.BuildResultFromVm( + vm, + _debugTestEntry, + elapsed, + _debugContext?.Compiler.DebugData, + runtimeException: null); + return CooperativePump.SerializeTestResult(result); + } + + [JSExport] + public static string DebugStackFrames() + { + if (_debugSession == null) return "[]"; + var frames = _debugSession.GetFrames2(); + return JsonSerializer.Serialize(frames, _jsonOpts); + } + + // Resolve a VM instruction index to its originating source location in + // joined-source coordinates (0-based line + char). Used by the crash + // overlay on REV_REQUEST_EXPLODE: the runtime-error message embeds the + // failing `insIndex`, but the line/char only live in the DebugData this + // session built at compile time. Wraps IndexCollection's binary search; + // the caller translates joined coords to per-file via ProjectSourceMap. + // Returns "null" when no session is active or the index is past the + // last statement token. + [JSExport] + public static string DebugResolveInstruction(int insIndex) + { + if (_debugSession?.instructionMap == null) return "null"; + if (!_debugSession.instructionMap.TryFindClosestTokenBeforeIndex(insIndex, out var debugToken)) return "null"; + if (debugToken?.token == null) return "null"; + return JsonSerializer.Serialize(new + { + insIndex, + lineNumber = debugToken.token.lineNumber, + charNumber = debugToken.token.charNumber, + }, _jsonOpts); + } + + [JSExport] + public static string DebugScopes(int frameId) + { + if (_debugSession == null) return "{\"scopes\":[]}"; + var resp = _debugSession.GetScopes(new DebugScopeRequest { frameIndex = frameId }); + StripRuntimeRefs(resp); + return JsonSerializer.Serialize(resp, _jsonOpts); + } + + [JSExport] + public static string DebugVariableExpansion(int variableId) + { + if (_debugSession == null) return "{\"scopes\":[]}"; + var sub = _debugSession.variableDb.Expand(variableId); + var msg = new ScopesMessage { scopes = new List { sub } }; + StripRuntimeRefs(msg); + return JsonSerializer.Serialize(msg, _jsonOpts); + } + + // DebugVariable carries a `runtimeVariable` field that holds live VM + // internals (delegates, byref data) — System.Text.Json can't serialize + // them. The native LSP/DAP serializer skips this via IJsonable's + // ProcessJson, but our STJ-based path here doesn't honor that. Null + // the field before serializing so the response is clean. + private static void StripRuntimeRefs(ScopesMessage msg) + { + if (msg?.scopes == null) return; + foreach (var scope in msg.scopes) + { + if (scope?.variables == null) continue; + foreach (var v in scope.variables) v.runtimeVariable = null; + } + } + + [JSExport] + public static string DebugEval(int frameId, string expression) + { + if (_debugSession == null) return "null"; + var result = _debugSession.Eval(frameId, expression); + return JsonSerializer.Serialize(result, _jsonOpts); + } + + [JSExport] + public static string DebugRepl(int frameId, string code) + { + if (_debugSession == null) return "null"; + var result = _debugSession.ReplExec(frameId, code); + return JsonSerializer.Serialize(result, _jsonOpts); + } + + [JSExport] + public static string DebugSetVariable(int frameId, int variableId, string rhs) + { + if (_debugSession == null) return "null"; + var result = _debugSession.Eval(frameId, rhs, variableId); + // DebugVariableDatabase caches the local/global scope on first read + // and returns the cached object on subsequent calls. After a + // successful set the underlying VM memory is updated but the cached + // DebugVariable.value strings still show the old display value. + // Bust the cache so the next GetScopes call rebuilds with fresh + // values. (ClearLifetime resets variable IDs too — the page must + // re-request scopes; expandedVars by-id state on the client + // intentionally resets per pause anyway.) + if (result != null && result.id != -1) + { + try { _debugSession.variableDb.ClearLifetime(); } catch { /* best effort */ } + } + return JsonSerializer.Serialize(result, _jsonOpts); + } + + private static void EnqueueBasic(DebugMessageType type) + { + if (_debugSession == null) return; + var msg = new DebugMessage { id = NextDebugId(), type = type }; + msg.RawJson = msg.Jsonify(); + _debugSession.Enqueue(msg); + } + + private sealed class BreakpointRequestDto + { + public int Line { get; set; } + public int Column { get; set; } + } + + // Returns a JSON object with FadeBasic + .NET runtime version strings + // for display in the browser's Diagnostics panel. + [JSExport] + public static string GetVersionInfo() + { + var asm = typeof(FadeBasic.Virtual.VirtualMachine).Assembly; + var attrs = (System.Reflection.AssemblyInformationalVersionAttribute[]) + asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false); + var fadeVersion = attrs.Length > 0 ? attrs[0].InformationalVersion : asm.GetName().Version?.ToString() ?? "unknown"; + var dotnetVersion = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; + return JsonSerializer.Serialize(new { fadeBasic = fadeVersion, dotnet = dotnetVersion }); + } +} + diff --git a/FadeBasic/FadeBasic.Export.Web/Program.cs b/FadeBasic/FadeBasic.Export.Web/Program.cs new file mode 100644 index 0000000..b32dfba --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using System.Runtime.InteropServices.JavaScript; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +var host = builder.Build(); + +// Load the JS module that backs WebInterop's [JSImport] methods. +// Must complete before any command that uses location()/user_agent()/alert is called. +// Path is relative to where the .NET runtime loaded from (/_framework/), +// so "../web-commands.js" points at wwwroot/web-commands.js. +await JSHost.ImportAsync("web-commands", "../web-commands.js"); + +await host.RunAsync(); diff --git a/FadeBasic/FadeBasic.Export.Web/Properties/launchSettings.json b/FadeBasic/FadeBasic.Export.Web/Properties/launchSettings.json new file mode 100644 index 0000000..503695c --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "iisSettings": { + "iisExpress": { + "applicationUrl": "http://localhost:52783", + "sslPort": 44336 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5299", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7260;http://localhost:5299", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs b/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs new file mode 100644 index 0000000..64a9509 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs @@ -0,0 +1,61 @@ +// Builds an ICommandDocsProvider for Core's HoverHandler by reusing the +// existing ApplicationSupport parsing pipeline: +// +// CommandsMetaData.COMMANDS_JSON (raw XML doc strings, source-generator +// output) +// → CommandMetadata (System.Text.Json) +// → ProjectDocs (ProjectDocMethods.LoadDocs) +// → ICommandDocsProvider (ProjectDocsCommandDocsProvider) +// +// Callers pass every COMMANDS_JSON blob that's currently live — Standard +// plus whatever assemblies were dynamically registered. The pipeline +// merges them into one ProjectDocs map keyed by callName + sig. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using FadeBasic.ApplicationSupport.Project; +using FadeBasic.LSP.Core; + +namespace FadeBasic.Export.Web; + +internal static class StandardCommandDocs +{ + private static readonly JsonSerializerOptions _opts = new() + { + IncludeFields = true, + PropertyNameCaseInsensitive = true, + }; + + // System.Text.Json reflects ctors + fields off these types at deserialize + // time. In the Release/trimmed publish the trimmer drops the default + // ctors (so STJ throws "Deserialization of types without a parameterless + // constructor … is not supported") and the public fields (so even with + // ctors, every field deserializes to default). These dependencies pin + // both. Without them every command in the Help tab renders as just a + // signature header, with no summary, parameters, or examples. + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields, typeof(CommandMetadata))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields, typeof(ProjectCommandMetadata))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields, typeof(ProjectCommandParameterMetedata))] + public static ICommandDocsProvider Build(params string[] commandsJsonBlobs) + { + try + { + var metas = new List(commandsJsonBlobs.Length); + foreach (var json in commandsJsonBlobs) + { + if (string.IsNullOrEmpty(json)) continue; + var m = JsonSerializer.Deserialize(json, _opts); + if (m != null) metas.Add(m); + } + if (metas.Count == 0) return null!; + var docs = metas.LoadDocs(); + return new ProjectDocsCommandDocsProvider(docs); + } + catch + { + // Best-effort — hover/help fall back to the basic signature header. + return null!; + } + } +} diff --git a/FadeBasic/FadeBasic.Export.Web/WebDebugSession.cs b/FadeBasic/FadeBasic.Export.Web/WebDebugSession.cs new file mode 100644 index 0000000..e878932 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/WebDebugSession.cs @@ -0,0 +1,125 @@ +// In-process DebugSession for the browser runtime. Skips the TCP listener +// that DebugSession.StartServer() spins up and instead exposes the +// inbound / outbound message queues directly so the worker can drive +// the session by method call. +// +// Lifecycle in FadeBasic.Export.Web: +// 1. Bridge compiles source → VirtualMachine + Compiler.DebugData. +// 2. Bridge constructs WebDebugSession(vm, dbg, …) — no StartServer(). +// 3. Worker tick loop calls `session.StartDebugging(ops=200)` in batches +// between message-pump calls so we yield control back to JS often +// enough for inbound messages from the main thread. +// 4. Worker pushes inbound messages via `Enqueue(msg)` and drains +// outbound via `DrainOutbound()`. +// +// We pre-set `didClientConnect = true`, `hasConnectedDebugger = 1`, and +// `debuggerSaidHello = 1` so the session doesn't sit waiting for a +// PROTO_HELLO handshake from a TCP client that doesn't exist. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.Virtual; + +namespace FadeBasic.Export.Web; + +internal sealed class WebDebugSession : DebugSession +{ + public WebDebugSession(VirtualMachine vm, DebugData dbg, CommandCollection commands) + : base(vm, dbg, commands, new LaunchOptions + { + // Critical: skip the wait-for-client handshake. We're the only + // "client" and we're embedded in the same process. + debugWaitForConnection = false, + debugPort = 0, + debug = true, + }, label: "web") + { + // Tell StartDebugging we already have a connected debugger so the + // "auto-resume on client disconnect" path doesn't kick in if we + // hit a breakpoint and then pause briefly between ticks. + didClientConnect = true; + hasConnectedDebugger = 1; + debuggerSaidHello = 1; + } + + // Enqueue an inbound message (from the main thread) for the next tick + // to dispatch via DebugSession.ReadMessage's switch. + public void Enqueue(DebugMessage msg) + { + receivedMessages.Enqueue(msg); + } + + // Synthesize a "stopped" outbound event. The base session's + // SendStopMessage is protected and only fires when a breakpoint is + // hit — manual REQUEST_PAUSE acks with a plain PROTO_ACK that the + // page's adapter (see DAP_AUDIT.md) reads as "running". WaitImpl + // calls this after enqueuing REQUEST_PAUSE so the page transitions + // to its paused UI state. + public void EmitStop() + { + outboundMessages.Enqueue(new DebugMessage + { + id = GetNextMessageId(), + type = DebugMessageType.REV_REQUEST_BREAKPOINT, + }); + } + + // True after a kind=3 (yield) interrupt has flipped requestedExit. + // DebugTick reads this after StartDebugging returns and resets + // requestedExit so the next tick can resume normally. This is the + // hook that makes the worker yield between waits — see WaitImpl + // in FadeBridge.CreateWorkspace. + public bool WasYieldRequest { get; private set; } + + public void RequestYield() + { + WasYieldRequest = true; + requestedExit = true; + // VirtualMachine.Execute3 checks `!isSuspendRequested` per + // instruction in its inner for-loop. Flipping it short-circuits + // the current batch *immediately*, so the very next instruction + // doesn't run. Without this, Execute3 keeps going until its + // budget exhausts and requestedExit only takes effect at the + // outer loop boundary — after at least one more instruction + // (the one right after `wait ms`) has already executed. + if (_vm != null) _vm.isSuspendRequested = true; + // Enqueue a no-op too so the Execute3 lambda's `receivedMessages.Count > 0` + // check is an *additional* yield path (some Execute paths take + // the lambda route, some take the field-flag route). + receivedMessages.Enqueue(new DebugMessage + { + id = GetNextMessageId(), + type = DebugMessageType.NOOP, + }); + } + + public void ClearYieldRequest() + { + if (!WasYieldRequest) return; + WasYieldRequest = false; + requestedExit = false; + } + + // Drain everything the session has produced since the last call. The + // worker re-posts each as a typed `debug-event` message to the page. + public List DrainOutbound() + { + var result = new List(); + while (outboundMessages.TryDequeue(out var msg)) result.Add(msg); + return result; + } + + // True once the VM has run past the end of its program. Used by the + // worker tick loop to know when to stop pumping and emit an + // EXITED-equivalent message. + public bool ProgramComplete => _vm == null || _vm.program == null + || InstructionPointer >= _vm.program.Length; + + // Exposes whether any step request is currently in flight. The base + // DebugSession only signals a step landing via PROTO_ACK on the original + // step request — the bridge watches this to synthesize a "stopped" + // event the page-side debug loop can hook. + public bool HasStepInFlight => + stepNextMessage != null || stepIntoMessage != null || stepOutMessage != null; +} diff --git a/FadeBasic/FadeBasic.Export.Web/build/FadeBasic.Export.Web.targets b/FadeBasic/FadeBasic.Export.Web/build/FadeBasic.Export.Web.targets new file mode 100644 index 0000000..fa56701 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/build/FadeBasic.Export.Web.targets @@ -0,0 +1,59 @@ + + + + + $(MSBuildThisFileDirectory)wasm\ + $(PublishDir)web\ + + + + + + + + + + + <_FadeWasmFiles Include="$(FadeWebExportWasmDir)**\*" /> + + + + + + <_FadeGameDlls Include="$(OutDir)*.dll" /> + + + + + + <_FadeDepDlls Include="@(_FadeGameDlls)" Condition="'%(Filename)' != '$(TargetName)'" /> + + + <_FadeDepsJson>@(_FadeDepDlls->'"%(Filename)%(Extension)"', ',') + + + + + + + + diff --git a/FadeBasic/FadeBasic.Export.Web/wwwroot/css/app.css b/FadeBasic/FadeBasic.Export.Web/wwwroot/css/app.css new file mode 100644 index 0000000..c1a00f8 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/css/app.css @@ -0,0 +1,32 @@ +h1:focus { + outline: none; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } diff --git a/FadeBasic/FadeBasic.Export.Web/wwwroot/index.html b/FadeBasic/FadeBasic.Export.Web/wwwroot/index.html new file mode 100644 index 0000000..ab620b5 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/index.html @@ -0,0 +1,306 @@ + + + + + Fade Web Export + + + +
Loading runtime…
+
+ + + + diff --git a/FadeBasic/FadeBasic.Export.Web/wwwroot/runtime.js b/FadeBasic/FadeBasic.Export.Web/wwwroot/runtime.js new file mode 100644 index 0000000..b9c0197 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/runtime.js @@ -0,0 +1,534 @@ +// FadeBasic runtime — hostable from either a Web Worker or an +// iframe's main thread. +// +// Two hosts use this file: +// +// 1. The Playground's lspWorker: spawned as a Web Worker. +// worker.js (a thin shim) imports this module and wires +// its self.postMessage / self.onmessage to onMessage / +// dispatch. +// +// 2. The Export.Web preview iframe: index.html imports this +// module directly on the main thread and wires its +// window.parent.postMessage / window.message events to +// the same surface. +// +// Wire protocol (what the host sees) is identical in both +// cases — same message type names, same payload shapes. The +// only differences between Worker-host and iframe-host are: +// +// - Where `postMessage` lands (worker → page, vs. iframe → parent). +// - Whether print/host-message events are consumed by an iframe- +// local handler (iframe-host only). +// +// The runtime itself is host-agnostic. setRole only matters for +// diagnostic tagging on heartbeat / log events. + +import { dotnet } from './_framework/dotnet.js'; + +// ─── Host I/O ────────────────────────────────────────────────────────── +// Subscriber set for outgoing events. The host calls onMessage(fn) to +// hook itself in; emit() fans out to all current subscribers. Set-based +// so multiple subscribers can coexist (the iframe sometimes wants to +// observe events alongside the relay-to-parent logic). +const _listeners = new Set(); +function emit(msg) { + for (const fn of _listeners) fn(msg); +} + +export function onMessage(fn) { + _listeners.add(fn); + return () => _listeners.delete(fn); +} + +let exports = null; +const _queue = []; + +// Role tag carried on heartbeats / logs. Defaults to 'vm' so the +// iframe-host doesn't need to setRole before init. The Worker-host +// shim sets it to 'lsp' on the lspWorker side. +let role = 'vm'; +export function setRole(r) { role = r === 'lsp' ? 'lsp' : 'vm'; } + +function log(message) { + emit({ type: 'log', message, role }); +} + +// ─── Heartbeat ───────────────────────────────────────────────────────── +let heartbeatTick = 0; +setInterval(() => { + heartbeatTick = (heartbeatTick + 1) | 0; + emit({ type: 'heartbeat', tick: heartbeatTick, t: Date.now(), role }); +}, 500); + +// ─── Cooperative run-pump ────────────────────────────────────────────── +// The export bundle runs user programs via FadeBridge.RunStart + +// repeated FadeBridge.RunTick calls instead of one synchronous +// LoadAndRun. Between ticks we yield to the host's event loop +// (setTimeout 0) so messages from the page — prompt answers, stop, +// etc. — can land between batches. +// +// Tick status drives scheduling: +// complete=true → run is over; emit result, stop pumping. +// waitingForHostReply=true → VM suspended waiting for a host-reply. +// Stop pumping; a host-reply restarts. +// waitMs > 0 → setTimeout(waitMs) before next tick. +// suspended/otherwise → setTimeout(0): yield + continue. +let runPumpActive = false; +let runMsgId = null; +let pumpTimerId = null; +let runPumpTerminalType = 'run-tick-result'; + +const RUN_TICK_BUDGET = 50000; + +function pumpRunTick() { + pumpTimerId = null; + if (!runPumpActive) return; + let status; + try { + const json = exports.FadeBasic.Export.Web.FadeBridge.RunTick(RUN_TICK_BUDGET); + status = JSON.parse(json); + } catch (e) { + runPumpActive = false; + const err = String(e?.message ?? e); + const envelope = runPumpTerminalType === 'run-tests-result' + ? { passed: 0, failed: 0, duration: 0, results: [], error: err, printed: '' } + : { ok: false, error: err }; + emit({ type: runPumpTerminalType, id: runMsgId, result: JSON.stringify(envelope) }); + runMsgId = null; + runPumpTerminalType = 'run-tick-result'; + return; + } + if (status.testProgress) { + emit({ type: 'test-progress', result: status.testProgress }); + } + if (status.testStarting) { + emit({ type: 'test-starting', testName: status.testStarting.name }); + } + if (status.complete) { + runPumpActive = false; + let envelope; + if (runPumpTerminalType === 'run-tests-result' && status.testFinal) { + envelope = { + passed: status.testFinal.passed, + failed: status.testFinal.failed, + duration: status.testFinal.duration, + results: status.testFinal.results, + error: status.error ?? null, + printed: '', + }; + } else { + envelope = { ok: !status.error, error: status.error ?? null }; + } + emit({ type: runPumpTerminalType, id: runMsgId, result: JSON.stringify(envelope) }); + runMsgId = null; + runPumpTerminalType = 'run-tick-result'; + return; + } + if (status.waitingForHostReply) return; + const delay = status.waitMs > 0 ? status.waitMs : 0; + pumpTimerId = setTimeout(pumpRunTick, delay); +} + +// ─── Debug tick loop ─────────────────────────────────────────────────── +const DEBUG_TICK_BUDGET = 10000; +let debugTicking = false; +function startDebugTickLoop() { + debugTicking = true; + pumpDebugTick(); +} +function pumpDebugTick() { + if (!debugTicking) return; + let result; + try { + const json = exports.FadeBasic.Export.Web.FadeBridge.DebugTick(DEBUG_TICK_BUDGET); + result = JSON.parse(json); + } catch (e) { + debugTicking = false; + emit({ type: 'debug-event', event: { type: 'error', message: String(e?.message ?? e) } }); + return; + } + if (result.messages && result.messages.length) { + for (const m of result.messages) { + emit({ type: 'debug-event', event: m }); + } + } + if (result.complete) { + debugTicking = false; + emit({ type: 'debug-event', event: { type: 'complete' } }); + return; + } + let delay; + if (result.waitMs && result.waitMs > 0) delay = result.waitMs; + else if (result.paused) delay = 50; + else delay = 0; + setTimeout(pumpDebugTick, delay); +} + +// ─── Boot ────────────────────────────────────────────────────────────── +export async function init() { + log('creating .NET runtime...'); + const runtime = await dotnet.create(); + log('runtime created, registering JS imports...'); + + runtime.setModuleImports('web-commands', { + onPrint: (line) => emit({ type: 'print', line }), + getLocation: () => '(unavailable in worker context)', + getUserAgent: () => self.navigator?.userAgent ?? '(unavailable)', + alert: (msg) => emit({ type: 'alert', msg }), + }); + + runtime.setModuleImports('fade-runtime', { + postHostMessage: (channel, payload) => + emit({ type: 'host-message', channel, payload }), + }); + + log('registering assembly exports...'); + const config = runtime.getConfig(); + exports = await runtime.getAssemblyExports(config.mainAssemblyName); + log('exports loaded'); + + while (_queue.length) handle(_queue.shift()); + emit({ type: 'ready', role }); +} + +// ─── Inbound dispatch ────────────────────────────────────────────────── +// `dispatch(msg)` is the host's entry point — every incoming message +// from the host's environment (worker.postMessage or window.message) +// goes through here. Same semantics as worker.js's handle() used to +// have, minus the role-based misroute checks (those existed only +// because the lspWorker / vmWorker were both processes with parallel +// op surfaces; now the LSP worker is the only Worker context, and +// the iframe is always the VM target). +export async function dispatch(msg) { + if (!exports) { _queue.push(msg); return; } + handle(msg); +} + +function handle(msg) { + if (!msg) return; + // Cheap roundtrip for heartbeat probes. + if (msg.type === 'ping') { + emit({ type: 'pong', id: msg.id, t: Date.now() }); + return; + } + + const FB = exports.FadeBasic.Export.Web.FadeBridge; + + if (msg.type === 'run') { + let result; + try { + result = FB.CompileAndRun(msg.source); + } catch (e) { + result = 'Worker error: ' + (e?.message ?? e); + } + emit({ type: 'result', id: msg.id, result }); + } else if (msg.type === 'lsp-set') { + log('lsp-set: calling LspSetDocument'); + let diagnosticsJson = '[]'; + try { + diagnosticsJson = FB.LspSetDocument(msg.uri, msg.text); + log('lsp-set: returned, length=' + diagnosticsJson.length); + } catch (e) { + log('lsp-set failed: ' + (e?.message ?? e)); + } + emit({ + type: 'lsp-diagnostics', + uri: msg.uri, + version: msg.version, + diagnostics: diagnosticsJson, + }); + } else if (msg.type === 'lsp-tokens') { + let tokensJson = '[]'; + try { tokensJson = FB.LspGetSemanticTokens(msg.uri); } + catch (e) { log('lsp-tokens failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-tokens-result', id: msg.id, uri: msg.uri, tokens: tokensJson }); + } else if (msg.type === 'lsp-hover') { + let hoverJson = 'null'; + try { hoverJson = FB.LspHover(msg.uri, msg.line, msg.character); } + catch (e) { log('lsp-hover failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-hover-result', id: msg.id, hover: hoverJson }); + } else if (msg.type === 'lsp-completion') { + let json = '[]'; + try { json = FB.LspCompletion(msg.uri, msg.line, msg.character); } + catch (e) { log('lsp-completion failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-completion-result', id: msg.id, items: json }); + } else if (msg.type === 'lsp-all-diagnostics') { + let json = '{}'; + try { json = FB.LspGetAllDiagnostics(); } + catch (e) { log('lsp-all-diagnostics failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-all-diagnostics-result', id: msg.id, all: json }); + } else if (msg.type === 'lsp-signature-help') { + let json = 'null'; + try { json = FB.LspSignatureHelp(msg.uri, msg.line, msg.character); } + catch (e) { log('lsp-signature-help failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-signature-help-result', id: msg.id, sig: json }); + } else if (msg.type === 'lsp-references') { + let json = '[]'; + try { json = FB.LspReferences(msg.uri, msg.line, msg.character); } + catch (e) { log('lsp-references failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-references-result', id: msg.id, refs: json }); + } else if (msg.type === 'lsp-definition') { + let json = 'null'; + try { json = FB.LspDefinition(msg.uri, msg.line, msg.character); } + catch (e) { log('lsp-definition failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-definition-result', id: msg.id, def: json }); + } else if (msg.type === 'lsp-document-symbols') { + let json = '[]'; + try { json = FB.LspDocumentSymbols(msg.uri); } + catch (e) { log('lsp-document-symbols failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-document-symbols-result', id: msg.id, symbols: json }); + } else if (msg.type === 'lsp-folding-ranges') { + let json = '[]'; + try { json = FB.LspFoldingRanges(msg.uri); } + catch (e) { log('lsp-folding-ranges failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-folding-ranges-result', id: msg.id, ranges: json }); + } else if (msg.type === 'lsp-format') { + let json = '[]'; + try { json = FB.LspFormat(msg.uri, msg.options || ''); } + catch (e) { log('lsp-format failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-format-result', id: msg.id, edits: json }); + } else if (msg.type === 'lsp-format-range') { + let json = '[]'; + try { + json = FB.LspFormatRange( + msg.uri, msg.options || '', + msg.startLine, msg.startCh, msg.endLine, msg.endCh); + } catch (e) { log('lsp-format-range failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-format-range-result', id: msg.id, edits: json }); + } else if (msg.type === 'lsp-format-on-type') { + let json = '[]'; + try { json = FB.LspFormatOnType(msg.uri, msg.options || '', msg.line, msg.character); } + catch (e) { log('lsp-format-on-type failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-format-on-type-result', id: msg.id, edits: json }); + } else if (msg.type === 'lsp-rename') { + let json = 'null'; + try { json = FB.LspRename(msg.uri, msg.line, msg.character, msg.newName); } + catch (e) { log('lsp-rename failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-rename-result', id: msg.id, edit: json }); + } else if (msg.type === 'load-assembly') { + let json = '{"ok":false,"error":"unknown"}'; + try { + const bytes = msg.dllBytes instanceof Uint8Array ? msg.dllBytes : new Uint8Array(msg.dllBytes); + json = FB.LoadAssembly(bytes); + } catch (e) { + json = JSON.stringify({ ok: false, error: String(e?.message ?? e) }); + log('load-assembly failed: ' + (e?.message ?? e)); + } + emit({ type: 'load-assembly-result', id: msg.id, result: json }); + } else if (msg.type === 'run-start' + || msg.type === 'run-start-source' + || msg.type === 'run-start-bytecode') { + let startJson = '{"ok":false,"error":"unknown"}'; + try { + if (msg.type === 'run-start') { + const bytes = msg.dllBytes instanceof Uint8Array ? msg.dllBytes : new Uint8Array(msg.dllBytes); + startJson = FB.RunStart(bytes); + } else if (msg.type === 'run-start-source') { + startJson = FB.RunStartFromSource(msg.source || ''); + } else { + const bytes = msg.bytecode instanceof Uint8Array ? msg.bytecode : new Uint8Array(msg.bytecode); + startJson = FB.RunStartFromBytecode(bytes); + } + } catch (e) { + startJson = JSON.stringify({ ok: false, error: String(e?.message ?? e) }); + log(msg.type + ' failed: ' + (e?.message ?? e)); + } + try { + const parsed = JSON.parse(startJson); + if (!parsed.ok) { + emit({ + type: 'run-tick-result', id: msg.id, + result: JSON.stringify({ + ok: false, + error: parsed.error ?? null, + compileError: parsed.compileError ?? null, + }), + }); + return; + } + } catch { /* keep going; pump will report */ } + runPumpActive = true; + runMsgId = msg.id; + runPumpTerminalType = 'run-tick-result'; + pumpTimerId = setTimeout(pumpRunTick, 0); + } else if (msg.type === 'stop-run') { + if (pumpTimerId != null) { clearTimeout(pumpTimerId); pumpTimerId = null; } + try { FB.StopRun(); } + catch (e) { log('stop-run failed: ' + (e?.message ?? e)); } + if (runPumpActive) pumpTimerId = setTimeout(pumpRunTick, 0); + } else if (msg.type === 'compile-to-bytecode') { + let bytecode = null, statusJson = '{"ok":false}'; + try { + statusJson = FB.CompileToBytecodeStatus(msg.source || ''); + const status = JSON.parse(statusJson); + if (status.ok) bytecode = FB.CompileToBytecode(msg.source || ''); + } catch (e) { + statusJson = JSON.stringify({ ok: false, error: String(e?.message ?? e) }); + log('compile-to-bytecode failed: ' + (e?.message ?? e)); + } + const bytecodeBuf = bytecode ? bytecode.buffer : null; + emit({ type: 'compile-to-bytecode-result', id: msg.id, status: statusJson, bytecode: bytecodeBuf }); + } else if (msg.type === 'host-reply') { + try { + switch (msg.resultType) { + case 'string': FB.DepositResultString(msg.value ?? ''); break; + case 'int': FB.DepositResultInt((msg.value | 0)); break; + case 'real': FB.DepositResultReal(+msg.value); break; + case 'bool': FB.DepositResultBool(!!msg.value); break; + case 'byte': FB.DepositResultByte((msg.value | 0) & 0xff); break; + case 'word': FB.DepositResultWord((msg.value | 0) & 0xffff); break; + case 'dword': FB.DepositResultDword((msg.value >>> 0) | 0); break; + case 'dint': FB.DepositResultDint(BigInt(msg.value ?? 0)); break; + case 'dfloat': FB.DepositResultDfloat(+msg.value); break; + case 'void': FB.DepositResultVoid(); break; + default: + log('host-reply: unknown resultType=' + msg.resultType); + FB.DepositResultVoid(); + break; + } + } catch (e) { + log('host-reply failed: ' + (e?.message ?? e)); + } + if (runPumpActive) pumpTimerId = setTimeout(pumpRunTick, 0); + } else if (msg.type === 'register-command-assembly') { + let json = '{"ok":false,"error":"unknown"}'; + try { + const bytes = msg.dllBytes instanceof Uint8Array ? msg.dllBytes : new Uint8Array(msg.dllBytes); + json = FB.RegisterCommandAssembly(bytes, msg.className); + } catch (e) { + json = JSON.stringify({ ok: false, error: String(e?.message ?? e) }); + log('register-command-assembly failed: ' + (e?.message ?? e)); + } + emit({ type: 'register-command-assembly-result', id: msg.id, result: json }); + } else if (msg.type === 'clear-command-assemblies') { + try { FB.ClearCommandAssemblies(); } + catch (e) { log('clear-command-assemblies failed: ' + (e?.message ?? e)); } + emit({ type: 'clear-command-assemblies-result', id: msg.id }); + } else if (msg.type === 'set-project-type') { + let resolved = msg.projectType; + try { resolved = FB.SetProjectType(msg.projectType); } + catch (e) { log('set-project-type failed: ' + (e?.message ?? e)); } + emit({ type: 'set-project-type-result', id: msg.id, projectType: resolved }); + } else if (msg.type === 'debug-start' || msg.type === 'debug-start-test') { + let json = '{}'; + try { + json = msg.type === 'debug-start-test' + ? FB.DebugStartTest(msg.source, msg.testName || '') + : FB.DebugStart(msg.source); + } catch (e) { + log(msg.type + ' failed: ' + (e?.message ?? e)); + } + emit({ type: 'debug-start-result', id: msg.id, result: json }); + try { + const parsed = JSON.parse(json); + if (parsed?.ok && !debugTicking) startDebugTickLoop(); + } catch { /* ignore */ } + } else if (msg.type === 'get-debug-test-result') { + let json = 'null'; + try { json = FB.GetDebugTestResult(); } + catch (e) { log('get-debug-test-result failed: ' + (e?.message ?? e)); } + emit({ type: 'get-debug-test-result-result', id: msg.id, result: json }); + } else if (msg.type === 'debug-terminate') { + debugTicking = false; + try { FB.DebugTerminate(); } catch (e) { log('terminate failed: ' + e); } + emit({ type: 'debug-terminate-result', id: msg.id }); + } else if (msg.type === 'debug-set-breakpoints') { + try { FB.DebugSetBreakpoints(msg.linesJson); } + catch (e) { log('set-bp failed: ' + e); } + emit({ type: 'debug-set-breakpoints-result', id: msg.id }); + } else if (msg.type === 'debug-step') { + try { FB.DebugStep(msg.kind); } + catch (e) { log('step failed: ' + e); } + emit({ type: 'debug-step-result', id: msg.id }); + } else if (msg.type === 'debug-continue') { + try { FB.DebugContinue(); } + catch (e) { log('continue failed: ' + e); } + emit({ type: 'debug-continue-result', id: msg.id }); + } else if (msg.type === 'debug-pause') { + try { FB.DebugPause(); } + catch (e) { log('pause failed: ' + e); } + emit({ type: 'debug-pause-result', id: msg.id }); + } else if (msg.type === 'debug-stack-frames') { + let json = '[]'; + try { json = FB.DebugStackFrames(); } + catch (e) { log('stack-frames failed: ' + e); } + emit({ type: 'debug-stack-frames-result', id: msg.id, frames: json }); + } else if (msg.type === 'debug-resolve-instruction') { + let json = 'null'; + try { json = FB.DebugResolveInstruction(msg.insIndex | 0); } + catch (e) { log('resolve-instruction failed: ' + e); } + emit({ type: 'debug-resolve-instruction-result', id: msg.id, result: json }); + } else if (msg.type === 'debug-scopes') { + let json = '{}'; + try { json = FB.DebugScopes(msg.frameId); } + catch (e) { log('scopes failed: ' + e); } + emit({ type: 'debug-scopes-result', id: msg.id, scopes: json }); + } else if (msg.type === 'debug-variable-expansion') { + let json = '{}'; + try { json = FB.DebugVariableExpansion(msg.variableId); } + catch (e) { log('var-expand failed: ' + e); } + emit({ type: 'debug-variable-expansion-result', id: msg.id, scopes: json }); + } else if (msg.type === 'debug-eval') { + let json = 'null'; + try { json = FB.DebugEval(msg.frameId, msg.expression); } + catch (e) { log('eval failed: ' + e); } + emit({ type: 'debug-eval-result', id: msg.id, result: json }); + } else if (msg.type === 'debug-repl') { + let json = 'null'; + try { json = FB.DebugRepl(msg.frameId, msg.code); } + catch (e) { log('repl failed: ' + e); } + emit({ type: 'debug-repl-result', id: msg.id, result: json }); + } else if (msg.type === 'debug-set-variable') { + let json = 'null'; + try { json = FB.DebugSetVariable(msg.frameId, msg.variableId, msg.rhs); } + catch (e) { log('set-var failed: ' + e); } + emit({ type: 'debug-set-variable-result', id: msg.id, result: json }); + } else if (msg.type === 'list-tests') { + let json = '[]'; + try { json = FB.ListTests(msg.source); } + catch (e) { log('list-tests failed: ' + (e?.message ?? e)); } + emit({ type: 'list-tests-result', id: msg.id, tests: json }); + } else if (msg.type === 'list-command-docs') { + let json = '[]'; + try { json = FB.ListCommandDocs(); } + catch (e) { log('list-command-docs failed: ' + (e?.message ?? e)); } + emit({ type: 'list-command-docs-result', id: msg.id, docs: json }); + } else if (msg.type === 'lsp-tokenize-snippet') { + let json = '[]'; + try { json = FB.LspTokenizeSnippet(msg.source ?? ''); } + catch (e) { log('lsp-tokenize-snippet failed: ' + (e?.message ?? e)); } + emit({ type: 'lsp-tokenize-snippet-result', id: msg.id, tokens: json }); + } else if (msg.type === 'get-version-info') { + let json = '{}'; + try { json = FB.GetVersionInfo(); } + catch (e) { log('get-version-info failed: ' + (e?.message ?? e)); } + emit({ type: 'get-version-info-result', id: msg.id, info: json }); + } else if (msg.type === 'run-tests') { + let startJson = '{"ok":false}'; + try { + startJson = FB.RunTestsStart(msg.source || '', msg.testName || ''); + } catch (e) { + startJson = JSON.stringify({ ok: false, error: String(e?.message ?? e) }); + log('run-tests-start failed: ' + (e?.message ?? e)); + } + try { + const parsed = JSON.parse(startJson); + if (!parsed.ok) { + emit({ + type: 'run-tests-result', id: msg.id, + result: JSON.stringify({ + passed: 0, failed: 0, duration: 0, results: [], + error: parsed.compileError ?? parsed.error ?? 'unknown', + printed: '', + }), + }); + return; + } + } catch { /* fall through to pump */ } + runPumpActive = true; + runMsgId = msg.id; + runPumpTerminalType = 'run-tests-result'; + pumpTimerId = setTimeout(pumpRunTick, 0); + } +} diff --git a/FadeBasic/FadeBasic.Export.Web/wwwroot/web-commands.js b/FadeBasic/FadeBasic.Export.Web/wwwroot/web-commands.js new file mode 100644 index 0000000..7b344c2 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/web-commands.js @@ -0,0 +1,17 @@ +export function getLocation() { + return window.location.href; +} + +export function getUserAgent() { + return navigator.userAgent; +} + +export function alert(msg) { + window.alert(msg); +} + +// Main-thread no-op: the page already renders the full buffered result at end-of-run. +// Worker mode overrides this via setModuleImports to stream print lines back to the page. +export function onPrint(line) { + // no-op +} diff --git a/FadeBasic/FadeBasic.Export.Web/wwwroot/worker.html b/FadeBasic/FadeBasic.Export.Web/wwwroot/worker.html new file mode 100644 index 0000000..cbf43c1 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/worker.html @@ -0,0 +1,111 @@ + + + + + + Fade Web Runtime (worker mode) + + + + + + +

Fade Web Runtime worker mode

+

Booting .NET runtime in a Web Worker...

+ + +
+ + heartbeat: 0 + +

Output

+
(not yet run)
+ +

+ The "heartbeat" number ticks every 100ms via setInterval on the main thread. + Notice it keeps ticking even while a Fade program is running (including wait ms). + On the main-thread version, the same program freezes the heartbeat for the duration. +

+ + + + + diff --git a/FadeBasic/FadeBasic.Export.Web/wwwroot/worker.js b/FadeBasic/FadeBasic.Export.Web/wwwroot/worker.js new file mode 100644 index 0000000..d470b85 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/worker.js @@ -0,0 +1,25 @@ +// Worker-context shim. Imports the host-agnostic runtime module and +// wires the Web Worker's self.postMessage / self.onmessage to the +// runtime's onMessage / dispatch surface. All actual logic lives in +// runtime.js — this file just hooks the I/O. +// +// Used by the Playground's lspWorker. Export.Web's iframe loads +// runtime.js directly on its main thread (no Worker indirection) — +// see wwwroot/index.html. + +import { init, dispatch, onMessage, setRole } from './runtime.js'; + +onMessage((m) => self.postMessage(m)); + +self.onmessage = (e) => { + // First-message contract: parent sends `{type: 'configure', role}` + // immediately after construction. Default role is 'vm'; the LSP + // worker sets 'lsp' so heartbeat / log events carry the right tag. + if (e.data?.type === 'configure') { + setRole(e.data.role); + return; + } + dispatch(e.data); +}; + +init(); diff --git a/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/Colors.cs b/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/Colors.cs index ec7a6f7..aaec1b1 100644 --- a/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/Colors.cs +++ b/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/Colors.cs @@ -36,10 +36,16 @@ public static int Rgb(byte r, byte g, byte b, byte a=255) return color; } + // Hosts that need an interruptible wait — most importantly the + // WebRuntime, which has to let the user click "pause" mid-debug + // without waiting for Thread.Sleep to unblock — can swap this + // delegate. The default keeps existing behavior verbatim. + public static System.Action WaitImpl = ms => Thread.Sleep(ms); + [FadeBasicCommand("wait ms")] public static void Wait(int milliseconds) { - Thread.Sleep(milliseconds); + WaitImpl(milliseconds); } } diff --git a/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/StringCommands.cs b/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/StringCommands.cs index e9ef366..4756980 100644 --- a/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/StringCommands.cs +++ b/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/StringCommands.cs @@ -6,12 +6,22 @@ namespace FadeBasic.Lib.Standard { public partial class StandardCommands { + /// + /// Converts an input string into an upper case string + /// + /// the string to convert + /// an upper cased version of in the input string [FadeBasicCommand("upper$", FadeBasicCommandUsage.Both)] public static string Upper(string str) { return str.ToUpperInvariant(); } + /// + /// Converts an input string into a lower case string + /// + /// + /// [FadeBasicCommand("lower$", FadeBasicCommandUsage.Both)] public static string Lower(string str) { @@ -74,11 +84,6 @@ public static float StringValue(string data) return val; } - [FadeBasicCommand("len", FadeBasicCommandUsage.Both)] - public static int StringLen(string str) - { - return str.Length; - } [FadeBasicCommand("asc", FadeBasicCommandUsage.Both)] public static int StringAsc(string str) diff --git a/FadeBasic/FadeBasic.Lib.Web/FadeBasic.Lib.Web.csproj b/FadeBasic/FadeBasic.Lib.Web/FadeBasic.Lib.Web.csproj new file mode 100644 index 0000000..42f44f4 --- /dev/null +++ b/FadeBasic/FadeBasic.Lib.Web/FadeBasic.Lib.Web.csproj @@ -0,0 +1,22 @@ + + + + + + net8.0 + FadeBasic.Lib.Web + FadeBasic.Lib.Web + FadeBasic Web Command Library + Browser-specific FadeBasic commands (print, prompt$, alert, etc.) for web export builds. + true + true + + + + + + + + diff --git a/FadeBasic/FadeBasic.Lib.Web/WebCommands.cs b/FadeBasic/FadeBasic.Lib.Web/WebCommands.cs new file mode 100644 index 0000000..669f9d3 --- /dev/null +++ b/FadeBasic/FadeBasic.Lib.Web/WebCommands.cs @@ -0,0 +1,94 @@ +using System; +using System.Runtime.InteropServices.JavaScript; +using System.Runtime.Versioning; +using System.Text; +using FadeBasic.SourceGenerators; +using FadeBasic.Virtual; + +namespace FadeBasic.Lib.Web; + +public partial class WebCommands +{ + private static readonly StringBuilder _printBuffer = new(); + + public static string DrainPrintBuffer() + { + var s = _printBuffer.ToString(); + _printBuffer.Clear(); + return s; + } + + [FadeBasicCommand("print", FadeBasicCommandUsage.Runtime)] + public static void Print(params object[] elements) + { + foreach (var el in elements) + { + var line = el?.ToString() ?? ""; + _printBuffer.AppendLine(line); + Console.WriteLine(line); + try { WebInterop.OnPrint(line); } catch { /* module not yet registered */ } + } + } + + [FadeBasicCommand("location")] + public static string Location() => WebInterop.GetLocation(); + + [FadeBasicCommand("user agent")] + public static string UserAgent() => WebInterop.GetUserAgent(); + + [FadeBasicCommand("time ms")] + public static int TimeMs() => + (int)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() & 0x7FFFFFFF); + + /// Displays a browser alert. + [FadeBasicCommand("alert")] + public static void Alert(string msg) => WebInterop.Alert(msg); + + /// Synchronous user-input prompt. Returns the entered string, or empty if cancelled. + // ─── Cooperative suspend model ──────────────────────────────────── + // The C# call doesn't actually wait — it asks the host runtime to do + // two things and then returns an empty placeholder: + // + // 1. PostMessage("fade-web/prompt", message) — the host forwards + // this to whatever UI is consuming runtime events. In the WASM + // bundle that's a postMessage from worker → page; the page's + // hostHandlers map dispatches by channel name. + // + // 2. SuspendVm() — pauses the current VM. The host knows which VM + // is currently being pumped. + // + // The placeholder pushed by the source-generated executor stays on + // the operand stack until the page replies with a `host-reply`; the + // runtime's DepositResultString JSExport swaps it for the real answer + // before the next opcode runs. From Fade source's POV `y$ = prompt$("?")` + // is still one synchronous expression. + // + // Lib.Web does not know who the host is and does not need to. Any + // other plugin library can use the same primitives with a different + // channel name and the consumer wires up the page-side handler in + // their own index.html — no changes to FadeBasic, FadeBasic.Export.Web, + // or worker.js required. + [FadeBasicCommand("prompt$")] + public static string Prompt(string message) + { + HostBridge.PostMessage?.Invoke("fade-web/prompt", message); + HostBridge.SuspendVm?.Invoke(); + return ""; + } +} + +[SupportedOSPlatform("browser")] +internal static partial class WebInterop +{ + [JSImport("getLocation", "web-commands")] + internal static partial string GetLocation(); + + [JSImport("getUserAgent", "web-commands")] + internal static partial string GetUserAgent(); + + [JSImport("alert", "web-commands")] + internal static partial void Alert(string msg); + + [JSImport("onPrint", "web-commands")] + internal static partial void OnPrint(string line); +} diff --git a/FadeBasic/FadeBasic.TestAdapter/FadeBasic.TestAdapter.csproj b/FadeBasic/FadeBasic.TestAdapter/FadeBasic.TestAdapter.csproj new file mode 100644 index 0000000..eb8d2c7 --- /dev/null +++ b/FadeBasic/FadeBasic.TestAdapter/FadeBasic.TestAdapter.csproj @@ -0,0 +1,42 @@ + + + + FadeBasic.TestAdapter + + net8.0 + latest + FadeBasic.TestAdapter + enable + + + false + + + + + + + + + + + + + + + + + + diff --git a/FadeBasic/FadeBasic.TestAdapter/FadeTestCaseProperties.cs b/FadeBasic/FadeBasic.TestAdapter/FadeTestCaseProperties.cs new file mode 100644 index 0000000..d338649 --- /dev/null +++ b/FadeBasic/FadeBasic.TestAdapter/FadeTestCaseProperties.cs @@ -0,0 +1,52 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; + +namespace FadeBasic.TestAdapter +{ + /// + /// Custom registrations carried on each emitted + /// . These survive the round-trip from discoverer to + /// executor, letting the executor look up the matching + /// TestManifestEntry by stable ID rather than by display name (which + /// can collide across abstract parents and concrete children). + /// + internal static class FadeTestCaseProperties + { + public static readonly TestProperty EntryPointAddress = TestProperty.Register( + id: "FadeBasic.EntryPointAddress", + label: "Fade Entry Point Address", + valueType: typeof(int), + owner: typeof(FadeTestDiscoverer)); + + public static readonly TestProperty FromParent = TestProperty.Register( + id: "FadeBasic.FromParent", + label: "Fade From-Parent", + valueType: typeof(string), + owner: typeof(FadeTestDiscoverer)); + + public static readonly TestProperty FbasicSourceFile = TestProperty.Register( + id: "FadeBasic.SourceFile", + label: "Fade Source File", + valueType: typeof(string), + owner: typeof(FadeTestDiscoverer)); + + // ManagedType / ManagedMethod are how IDE Test Explorers (Rider, + // VS Code C# Dev Kit, Visual Studio) split a TestCase into its + // namespace.class.method tree path. ObjectModel registers these as + // PRIVATE static fields on TestCase, so we can't reference them + // directly. TestProperty.Register is idempotent on the `id` — + // calling it with the same canonical IDs returns the framework's + // own internal instance, so SetPropertyValue against ours is the + // same write the framework would do internally. + public static readonly TestProperty ManagedType = TestProperty.Register( + id: "TestCase.ManagedType", + label: "ManagedType", + valueType: typeof(string), + owner: typeof(TestCase)); + + public static readonly TestProperty ManagedMethod = TestProperty.Register( + id: "TestCase.ManagedMethod", + label: "ManagedMethod", + valueType: typeof(string), + owner: typeof(TestCase)); + } +} diff --git a/FadeBasic/FadeBasic.TestAdapter/FadeTestConstants.cs b/FadeBasic/FadeBasic.TestAdapter/FadeTestConstants.cs new file mode 100644 index 0000000..e25201c --- /dev/null +++ b/FadeBasic/FadeBasic.TestAdapter/FadeTestConstants.cs @@ -0,0 +1,19 @@ +using System; + +namespace FadeBasic.TestAdapter +{ + /// + /// Shared identifiers between and + /// . The + /// is what binds a discovered TestCase to the executor that runs + /// it; both classes' attributes reference this constant. The /v1 + /// suffix gives a graceful version-bump path if the adapter contract ever + /// needs to break. + /// + internal static class FadeTestConstants + { + public const string ExecutorUriString = "executor://fadebasic/v1"; + + public static readonly Uri ExecutorUri = new Uri(ExecutorUriString); + } +} diff --git a/FadeBasic/FadeBasic.TestAdapter/FadeTestDiscoverer.cs b/FadeBasic/FadeBasic.TestAdapter/FadeTestDiscoverer.cs new file mode 100644 index 0000000..09f115a --- /dev/null +++ b/FadeBasic/FadeBasic.TestAdapter/FadeTestDiscoverer.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.IO; +using FadeBasic.Launch; +using FadeBasic.Testing; +using FadeBasic.Virtual; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; + +namespace FadeBasic.TestAdapter +{ + /// + /// VSTest TPv2 discoverer that surfaces every concrete + /// in a built test assembly as a + /// . The [FileExtension] attributes tell + /// VSTest "scan these file types"; the [DefaultExecutorUri] + /// binds emitted cases to . + /// + [FileExtension(".dll")] + [FileExtension(".exe")] + [DefaultExecutorUri(FadeTestConstants.ExecutorUriString)] + public sealed class FadeTestDiscoverer : ITestDiscoverer + { + public void DiscoverTests( + IEnumerable sources, + IDiscoveryContext discoveryContext, + IMessageLogger logger, + ITestCaseDiscoverySink discoverySink) + { + foreach (var source in sources) + { + if (!FadeTestLaunchableLoader.TryLoad(source, logger, out var launchable)) + continue; // not a Fade test project, or load failed (logged) + + foreach (var testCase in EnumerateTestCases(source, launchable)) + { + discoverySink.SendTestCase(testCase); + } + } + } + + /// + /// Build the objects without touching the sink. + /// Exposed internally so unit tests can verify discovery output without + /// stubbing the VSTest infrastructure. The originating .fbasic + /// file path comes from each + /// — the compile-time pipeline stamps it via . + /// + internal static IEnumerable EnumerateTestCases( + string assemblyPath, + ITestLaunchable launchable) + { + var asmName = Path.GetFileNameWithoutExtension(assemblyPath); + foreach (var entry in launchable.TestManifest) + { + if (entry.isAbstract) continue; + yield return BuildTestCase(entry, assemblyPath, asmName); + } + } + + private static TestCase BuildTestCase( + TestManifestEntry entry, + string assemblyPath, + string assemblyName) + { + var fbasicFilePath = entry.sourceFilePath ?? string.Empty; + + // ManagedType + ManagedMethod are how modern IDE Test Explorers + // (VS Code C# Dev Kit, Visual Studio, the `dotnet test` CLI's + // structured output) build their test tree. Without these, + // tooling falls back to parsing FullyQualifiedName which often + // yields the "test appears under a dot" symptom. + // + // Format: "Fade." with the test + // name as ManagedMethod. Identifiers are sanitized to + // [A-Za-z0-9_] so consumers see syntactically-valid C# names. + var typeSegment = FadeManagedIdentifier.ToManagedIdentifier( + !string.IsNullOrEmpty(fbasicFilePath) + ? Path.GetFileNameWithoutExtension(fbasicFilePath) + : assemblyName); + var managedType = "Fade." + typeSegment; + var managedMethod = entry.name; + + // Keep FQN aligned with ManagedType.ManagedMethod — IDEs that fall + // back to FQN-parsing then produce the same grouping as IDEs that + // read ManagedType/ManagedMethod directly. + var fqn = managedType + "." + managedMethod; + + var tc = new TestCase(fqn, FadeTestConstants.ExecutorUri, assemblyPath) + { + DisplayName = entry.name + }; + // ManagedType / ManagedMethod tell IDE Test Explorers how to + // split this case into a tree. The framework's own registrations + // for these properties are private; we register our own via the + // canonical IDs (TestProperty.Register is idempotent by id, so + // we get back the framework's instance). + tc.SetPropertyValue(FadeTestCaseProperties.ManagedType, managedType); + tc.SetPropertyValue(FadeTestCaseProperties.ManagedMethod, managedMethod); + + // CodeFilePath + LineNumber drive the Test Explorer "double-click + // jumps to source" behavior. Only set when we actually have the + // source path — guessing a wrong file is worse than omitting. + if (!string.IsNullOrEmpty(fbasicFilePath)) + { + tc.CodeFilePath = fbasicFilePath; + } + if (entry.sourceLine > 0) + { + tc.LineNumber = entry.sourceLine; + } + + // Filterable category. Both Rider and VS Code surface this as a + // trait/tag the user can group/filter by ("show only Fade tests"). + tc.Traits.Add(new Trait("Category", "Fade")); + if (!string.IsNullOrEmpty(entry.fromParent)) + { + tc.Traits.Add(new Trait("FromParent", entry.fromParent)); + tc.SetPropertyValue(FadeTestCaseProperties.FromParent, entry.fromParent); + } + + // Carry the entry-point address forward; the executor uses this + // to look up the matching manifest entry, since DisplayName can + // collide between abstract parents and concrete children. + tc.SetPropertyValue(FadeTestCaseProperties.EntryPointAddress, entry.entryPointAddress); + if (!string.IsNullOrEmpty(fbasicFilePath)) + { + tc.SetPropertyValue(FadeTestCaseProperties.FbasicSourceFile, fbasicFilePath); + } + + return tc; + } + + /// + /// Coerce an arbitrary string (file basename, assembly name) into a + /// C#-shaped identifier so IDEs that parse + /// as a dotted-identifier path (Rider, in particular) accept it. + /// Delegates to + /// so the LSP-based discovery path produces the same tree shape. + /// + internal static string ToManagedIdentifier(string raw) + => FadeManagedIdentifier.ToManagedIdentifier(raw); + } +} diff --git a/FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs b/FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs new file mode 100644 index 0000000..d82c0c1 --- /dev/null +++ b/FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Testing; +using FadeBasic.Virtual; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using VsTestResultMessage = Microsoft.VisualStudio.TestPlatform.ObjectModel.TestResultMessage; + +namespace FadeBasic.TestAdapter +{ + /// + /// VSTest TPv2 executor that runs Fade tests via the same + /// the MTP framework uses. Both adapters + /// share so a project-defined + /// [FadeTestHost] class drives both dotnet test and IDE + /// runs identically. + /// + [ExtensionUri(FadeTestConstants.ExecutorUriString)] + public sealed class FadeTestExecutorAdapter : ITestExecutor + { + private CancellationTokenSource? _cts; + + /// + /// Source-level run path. The IDE invokes this when the user hits + /// "Run all tests in <assembly>." We rediscover then delegate + /// to the -level overload so both code paths + /// share execution logic. + /// + public void RunTests( + IEnumerable? sources, + IRunContext? runContext, + IFrameworkHandle? frameworkHandle) + { + if (sources == null || frameworkHandle == null) return; + + var collected = new List(); + var sink = new ListDiscoverySink(collected); + new FadeTestDiscoverer().DiscoverTests(sources, runContext!, frameworkHandle, sink); + RunTests(collected, runContext, frameworkHandle); + } + + public void RunTests( + IEnumerable? tests, + IRunContext? runContext, + IFrameworkHandle? frameworkHandle) + { + if (tests == null || frameworkHandle == null) return; + + _cts = new CancellationTokenSource(); + var ct = _cts.Token; + + // Group by source assembly so we initialize the host exactly once + // per assembly, mirroring the MTP framework's session lifecycle. + foreach (var group in tests.GroupBy(t => t.Source)) + { + if (ct.IsCancellationRequested) break; + + if (!FadeTestLaunchableLoader.TryLoad(group.Key, frameworkHandle, + out var launchable)) + { + foreach (var skipped in group) + { + frameworkHandle.SendMessage(TestMessageLevel.Warning, + $"FadeBasic.TestAdapter: skipping {skipped.DisplayName} — could not load launchable from {Path.GetFileName(group.Key)}"); + } + continue; + } + + RunGroup(group, launchable, frameworkHandle, ct); + } + } + + public void Cancel() => _cts?.Cancel(); + + // -- internals ----------------------------------------------------- + + private static void RunGroup( + IEnumerable tests, + ITestLaunchable launchable, + IFrameworkHandle handle, + CancellationToken ct) + { + var host = FadeTestHostResolver.Resolve(explicitHost: null); + var sessionContext = new FadeTestSessionContext(launchable, services: null); + var hostMethods = HostMethodTable.FromCommandCollection(launchable.CommandCollection); + + // VSTest's executor contract is sync; the host APIs are async. + // .GetAwaiter().GetResult() is safe here because: + // - We're on a vstest.console worker thread, never the IDE UI thread. + // - The tasks we await don't post back to a SynchronizationContext. + try + { + host.InitializeAsync(sessionContext, ct).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + handle.SendMessage(TestMessageLevel.Error, + $"FadeBasic.TestAdapter: host.InitializeAsync threw: {ex.Message}"); + return; + } + + try + { + try + { + host.BeforeAllTestsAsync(sessionContext, ct).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + handle.SendMessage(TestMessageLevel.Warning, + $"FadeBasic.TestAdapter: host.BeforeAllTestsAsync threw: {ex.Message}"); + } + + foreach (var tc in tests) + { + if (ct.IsCancellationRequested) break; + RunOne(tc, launchable, host, hostMethods, handle, ct); + } + + try + { + host.AfterAllTestsAsync(sessionContext, ct).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + handle.SendMessage(TestMessageLevel.Warning, + $"FadeBasic.TestAdapter: host.AfterAllTestsAsync threw: {ex.Message}"); + } + } + finally + { + try { host.DisposeAsync().AsTask().GetAwaiter().GetResult(); } + catch { /* swallow — disposal failure shouldn't fail the run */ } + } + } + + private static void RunOne( + TestCase tc, + ITestLaunchable launchable, + IFadeTestHost host, + HostMethodTable hostMethods, + IFrameworkHandle handle, + CancellationToken ct) + { + handle.RecordStart(tc); + var entry = ResolveEntry(tc, launchable); + if (entry == null) + { + var notFound = new TestResult(tc) + { + Outcome = TestOutcome.NotFound, + ErrorMessage = "FadeBasic.TestAdapter: no matching test entry in launchable manifest" + }; + handle.RecordResult(notFound); + handle.RecordEnd(tc, TestOutcome.NotFound); + return; + } + + var runCtx = new FadeTestRunContext(launchable, entry, hostMethods); + + FadeTestResult result; + var sw = Stopwatch.StartNew(); + // Redirect Console.Out/Error around the run so anything the test + // prints (the standard library's `print` lands on Console.WriteLine, + // see FadeBasicCommands.cs / FadeBasic.Lib.Standard.Console) ends up + // as TestResultMessage.StandardOut/Error on the VSTest result — + // which is what Rider's Unit Tests window renders in its Output pane. + // Tests run sequentially in this adapter (per RunGroup), so a process- + // wide redirect is safe; we still save/restore in case the test host + // injects its own writers. + var capturedOut = new StringWriter(); + var capturedErr = new StringWriter(); + var prevOut = Console.Out; + var prevErr = Console.Error; + Console.SetOut(capturedOut); + Console.SetError(capturedErr); + try + { + try + { + result = host.RunTestAsync(runCtx, ct).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + result = new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "test cancelled" + }; + } + catch (Exception ex) + { + result = new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "test host threw: " + ex.Message + }; + } + } + finally + { + Console.SetOut(prevOut); + Console.SetError(prevErr); + } + sw.Stop(); + + var sourceFile = ResolveSourceFile(tc, entry); + + var vsResult = new TestResult(tc) + { + Outcome = result.passed ? TestOutcome.Passed : TestOutcome.Failed, + Duration = sw.Elapsed, + }; + + var stdout = capturedOut.ToString(); + var stderr = capturedErr.ToString(); + if (stdout.Length > 0) + vsResult.Messages.Add(new VsTestResultMessage(VsTestResultMessage.StandardOutCategory, stdout)); + if (stderr.Length > 0) + vsResult.Messages.Add(new VsTestResultMessage(VsTestResultMessage.StandardErrorCategory, stderr)); + + if (!result.passed) + { + vsResult.ErrorMessage = BuildErrorMessage(result, sourceFile, entry); + vsResult.ErrorStackTrace = BuildErrorStackTrace(result, sourceFile, entry); + } + + handle.RecordResult(vsResult); + handle.RecordEnd(tc, vsResult.Outcome); + } + + /// + /// Look up the originating for a + /// . Prefers the entry-point address (stable + /// across abstract/concrete name collisions) and falls back to + /// display-name match. + /// + internal static TestManifestEntry? ResolveEntry(TestCase tc, ITestLaunchable launchable) + { + var addr = tc.GetPropertyValue(FadeTestCaseProperties.EntryPointAddress, defaultValue: -1); + if (addr >= 0) + { + foreach (var e in launchable.TestManifest) + { + if (e.entryPointAddress == addr && !e.isAbstract) return e; + } + } + // Fallback by display name (last-resort; the address path should + // always succeed for cases produced by our discoverer). + foreach (var e in launchable.TestManifest) + { + if (!e.isAbstract && string.Equals(e.name, tc.DisplayName, StringComparison.Ordinal)) + return e; + } + return null; + } + + /// + /// Pick the best .fbasic path to surface in failure messages + /// and stack frames. Preference order: + /// (1) the property the discoverer stamped on the , + /// (2) (set by the discoverer when + /// the path is known), + /// (3) the manifest entry's + /// (when the executor was reached without going through our discoverer + /// — e.g., a synthetic TestCase filtered by a runsettings query). + /// + private static string ResolveSourceFile(TestCase tc, TestManifestEntry entry) + { + var stamped = tc.GetPropertyValue(FadeTestCaseProperties.FbasicSourceFile, defaultValue: null!); + if (!string.IsNullOrEmpty(stamped)) return stamped; + if (!string.IsNullOrEmpty(tc.CodeFilePath)) return tc.CodeFilePath; + return entry.sourceFilePath ?? string.Empty; + } + + /// + /// Format the failure message in a Fade-flavored shape. Surfaces the + /// captured assertion source text and a source-located stack chain + /// (when DebugData was available). Falls back to + /// when no frames could be resolved, so old builds still get a usable + /// (if coarse) location. + /// + internal static string BuildErrorMessage(FadeTestResult r, string fbasicPath, TestManifestEntry entry) + { + var sb = new StringBuilder(); + sb.Append(string.IsNullOrEmpty(r.failureMessage) ? "test failed" : r.failureMessage); + if (!string.IsNullOrEmpty(r.failureSourceText)) + { + sb.Append("\n source: ").Append(r.failureSourceText); + } + // Prefer the resolved frames (innermost first). If absent, fall + // back to the test entry's line so the message isn't blank. + if (r.failureFrames != null && r.failureFrames.Count > 0 && !string.IsNullOrEmpty(fbasicPath)) + { + var file = Path.GetFileName(fbasicPath); + foreach (var frame in r.failureFrames) + { + sb.Append("\n at "); + if (!string.IsNullOrEmpty(frame.functionName)) + { + sb.Append(frame.functionName).Append(' '); + } + sb.Append(file).Append(':').Append(frame.lineNumber); + } + } + else if (entry.sourceLine > 0 && !string.IsNullOrEmpty(fbasicPath)) + { + sb.Append("\n at ") + .Append(Path.GetFileName(fbasicPath)) + .Append(':') + .Append(entry.sourceLine); + } + return sb.ToString(); + } + + /// + /// Build the stack-trace string the IDE Test Explorer renders. Each + /// line follows at <name> in <file>:line N; both + /// VS Code and Rider parse that regex and turn it into a clickable + /// source link. Innermost frame first; the outermost frame is labeled + /// with the test name. + /// + internal static string BuildErrorStackTrace(FadeTestResult r, string fbasicPath, TestManifestEntry entry) + { + if (string.IsNullOrEmpty(fbasicPath)) return string.Empty; + + if (r.failureFrames != null && r.failureFrames.Count > 0) + { + var sb = new StringBuilder(); + for (var i = 0; i < r.failureFrames.Count; i++) + { + var frame = r.failureFrames[i]; + // Outermost frame (last) gets the test name as its label + // so the user sees "at ..." at the bottom. + var name = !string.IsNullOrEmpty(frame.functionName) + ? frame.functionName + : entry.name; + if (i > 0) sb.Append('\n'); + sb.Append(" at ").Append(name) + .Append(" in ").Append(fbasicPath) + .Append(":line ").Append(frame.lineNumber); + } + return sb.ToString(); + } + + // Legacy fallback: single frame at the test entry's line. + if (entry.sourceLine <= 0) return string.Empty; + return $" at {entry.name} in {fbasicPath}:line {entry.sourceLine}"; + } + + // Tiny sink that captures discovered cases into a list for the + // sources-overload of RunTests. Defined here (not as a separate file) + // because it's purely an implementation detail of this executor. + private sealed class ListDiscoverySink : ITestCaseDiscoverySink + { + private readonly List _list; + public ListDiscoverySink(List list) { _list = list; } + public void SendTestCase(TestCase discoveredTest) => _list.Add(discoveredTest); + } + } +} diff --git a/FadeBasic/FadeBasic.TestAdapter/FadeTestLaunchableLoader.cs b/FadeBasic/FadeBasic.TestAdapter/FadeTestLaunchableLoader.cs new file mode 100644 index 0000000..ecf8bc4 --- /dev/null +++ b/FadeBasic/FadeBasic.TestAdapter/FadeTestLaunchableLoader.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using FadeBasic.Launch; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; + +namespace FadeBasic.TestAdapter +{ + /// + /// Reflection-based loader that turns a built test assembly path into an + /// instance. Shared between the discoverer + /// (which walks the manifest to emit TestCases) and the executor + /// (which re-loads the launchable to dispatch a run). + /// + /// + /// The launchable is a class generated by LaunchableGenerator with + /// a parameterless constructor. The convention this loader enforces is: + /// "exactly one non-abstract with a + /// parameterless ctor per assembly." Zero (a non-Fade C# project) is + /// silent; more than one is a build-time bug we surface as a logger + /// error rather than failing the whole IDE discovery pass. + /// + /// + /// Each load goes into its own collectible + /// so a rebuild of the consumer's test DLL can be picked up without + /// restarting vstest.console. We track the assembly's last-write + /// timestamp on cache; when a TryLoad observes a fresher mtime, the old + /// context is unloaded and the assembly is reloaded fresh. This is what + /// makes "edit a .fbasic, hit Run in the Test Explorer" work without a + /// full solution rebuild — the IDE's incremental build of the test + /// project regenerates GeneratedFade.g.cs, MSBuild rebuilds the + /// DLL, and the next discovery/execution call observes the new mtime + /// and reloads. + /// + /// + /// + /// The custom overrides + /// to return null for + /// every dependency, which delegates resolution to the default ALC. + /// That keeps as the SAME runtime type + /// across the adapter and the freshly-loaded consumer assembly — without + /// it, the typeof(ITestLaunchable).IsAssignableFrom(t) check + /// would fail because the interface would be a distinct type per ALC. + /// + /// + internal static class FadeTestLaunchableLoader + { + // Per-path cache. Stores the last-loaded launchable, the assembly + // mtime at the time of load, and the collectible ALC we own (so we + // can unload it when the file changes). A null launchable means + // "we inspected this DLL, it's not a Fade project" — preserved as + // a negative cache to avoid re-scanning every keystroke. + private static readonly Dictionary _cache = + new(StringComparer.OrdinalIgnoreCase); + + // Test-only direct overrides, checked before the file-backed cache. + // Bypasses ALC + mtime entirely so tests can drive the executor with + // an in-memory launchable. + private static readonly Dictionary _testOverrides = + new(StringComparer.OrdinalIgnoreCase); + + private static readonly object _lock = new object(); + + public static bool TryLoad( + string source, + IMessageLogger? logger, + out ITestLaunchable launchable) + { + launchable = null!; + + string fullPath; + try + { + fullPath = Path.GetFullPath(source); + } + catch + { + return false; + } + + // Test override path first — never touches the filesystem cache. + lock (_lock) + { + if (_testOverrides.TryGetValue(fullPath, out var stub)) + { + launchable = stub; + return true; + } + } + + long currentMtime = TryGetMtime(fullPath); + + // Cache hit when fresh; invalidate (and unload) on stale mtime. + lock (_lock) + { + if (_cache.TryGetValue(fullPath, out var cached)) + { + if (cached.AssemblyMtimeTicks == currentMtime) + { + if (cached.Launchable == null) return false; + launchable = cached.Launchable; + return true; + } + // Stale — unload the old ALC so the GC can collect it, + // and drop the cache entry so we reload below. + UnloadQuietly(cached.Context); + _cache.Remove(fullPath); + } + } + + FadeLaunchableLoadContext? newCtx = null; + ITestLaunchable? instance = null; + try + { + newCtx = new FadeLaunchableLoadContext( + "FadeLaunchable:" + Path.GetFileNameWithoutExtension(fullPath)); + instance = LoadCore(fullPath, logger, newCtx); + } + catch (Exception ex) + { + logger?.SendMessage(TestMessageLevel.Warning, + $"FadeBasic.TestAdapter: failed to inspect {Path.GetFileName(fullPath)}: {ex.Message}"); + UnloadQuietly(newCtx); + lock (_lock) + { + // Cache the failure with the current mtime — same DLL won't + // be re-inspected until it's modified. + _cache[fullPath] = new CachedEntry(launchable: null, currentMtime, context: null); + } + return false; + } + + if (instance == null) + { + // Not a Fade project, or had >1 launchable type (already logged). + UnloadQuietly(newCtx); + lock (_lock) + { + _cache[fullPath] = new CachedEntry(launchable: null, currentMtime, context: null); + } + return false; + } + + lock (_lock) + { + _cache[fullPath] = new CachedEntry(instance, currentMtime, newCtx); + } + + launchable = instance; + return true; + } + + private static ITestLaunchable? LoadCore( + string fullPath, + IMessageLogger? logger, + FadeLaunchableLoadContext ctx) + { + // LoadFromAssemblyPath inside our context loads the consumer's DLL + // into THIS ALC. Its dependencies (FadeBasic.dll → ITestLaunchable) + // are resolved via Load() returning null, which falls back to the + // default ALC where our own copy already lives — keeping type + // identity intact. + var asm = ctx.LoadFromAssemblyPath(fullPath); + + Type[] types; + try + { + types = asm.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + // Some types in the assembly couldn't load. Tolerable as long + // as our launchable type itself is intact. + types = ex.Types.Where(t => t != null).ToArray()!; + } + + var candidates = types + .Where(t => t != null + && !t.IsAbstract + && !t.IsInterface + && typeof(ITestLaunchable).IsAssignableFrom(t) + && t.GetConstructor(Type.EmptyTypes) != null) + .ToList(); + + if (candidates.Count == 0) return null; + + if (candidates.Count > 1) + { + var names = string.Join(", ", candidates.Select(c => c.FullName)); + logger?.SendMessage(TestMessageLevel.Error, + $"FadeBasic.TestAdapter: {Path.GetFileName(fullPath)} contains {candidates.Count} ITestLaunchable types ({names}); expected exactly one."); + return null; + } + + return (ITestLaunchable?)Activator.CreateInstance(candidates[0]); + } + + private static long TryGetMtime(string fullPath) + { + try + { + return File.GetLastWriteTimeUtc(fullPath).Ticks; + } + catch + { + // File missing or inaccessible — return 0 so any later valid + // mtime invalidates the cache entry naturally. + return 0; + } + } + + private static void UnloadQuietly(AssemblyLoadContext? ctx) + { + if (ctx == null) return; + try { ctx.Unload(); } + catch { /* best-effort; the GC may collect later */ } + } + + /// + /// Test-only cache reset. The IDE process holds adapters across runs, + /// so caching is correct in production; tests that swap assembly + /// contents need to invalidate. + /// + internal static void ResetCacheForTests() + { + lock (_lock) + { + foreach (var entry in _cache.Values) + { + UnloadQuietly(entry.Context); + } + _cache.Clear(); + _testOverrides.Clear(); + } + } + + /// + /// Test-only seam — pre-register an in-memory launchable for a path + /// so the executor's RunGroup can be exercised without + /// writing a real .dll to disk. Returns an + /// that clears the entry on dispose. + /// + internal static System.IDisposable RegisterForTests(string assemblyPath, ITestLaunchable launchable) + { + var fullPath = Path.GetFullPath(assemblyPath); + lock (_lock) + { + _testOverrides[fullPath] = launchable; + } + return new RegistrationScope(fullPath); + } + + private sealed class CachedEntry + { + public ITestLaunchable? Launchable { get; } + public long AssemblyMtimeTicks { get; } + public AssemblyLoadContext? Context { get; } + + public CachedEntry(ITestLaunchable? launchable, long ticks, AssemblyLoadContext? context) + { + Launchable = launchable; + AssemblyMtimeTicks = ticks; + Context = context; + } + } + + private sealed class FadeLaunchableLoadContext : AssemblyLoadContext + { + public FadeLaunchableLoadContext(string name) : base(name, isCollectible: true) { } + + // Returning null delegates dependency resolution to the default + // ALC. We only ever call LoadFromAssemblyPath on the consumer's + // own DLL; everything it references resolves elsewhere — most + // importantly, FadeBasic.dll which carries ITestLaunchable. + protected override Assembly? Load(AssemblyName assemblyName) => null; + } + + private sealed class RegistrationScope : System.IDisposable + { + private readonly string _path; + public RegistrationScope(string path) { _path = path; } + public void Dispose() + { + lock (_lock) _testOverrides.Remove(_path); + } + } + } +} diff --git a/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.csproj b/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.csproj new file mode 100644 index 0000000..6db9330 --- /dev/null +++ b/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.csproj @@ -0,0 +1,143 @@ + + + + + + FadeBasic.Testing + net8.0 + latest + FadeBasic.Testing + + FadeBasic.Testing + FadeBasic Testing Adapter + A Microsoft.Testing.Platform adapter that surfaces FadeBasic `test ... endtest` blocks to `dotnet test` and IDE Test Explorer. Drop-in: a single FadeEnableTesting MSBuild property turns a Fade console-app project into a `dotnet test` target without disturbing `dotnet run`. + enable + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.props b/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.props new file mode 100644 index 0000000..be715b1 --- /dev/null +++ b/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.props @@ -0,0 +1,115 @@ + + + + + + + True + + + true + false + + + False + true + true + false + + + <_FadeNUnitFlagDeprecated Condition="'$(FadeGenerateNUnitFixture)'=='True'">true + + + + + + FadeBasic.TestAdapter.dll + PreserveNewest + false + false + + + FadeBasic.TestAdapter.pdb + PreserveNewest + false + false + + + + + + + + + + + diff --git a/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.targets b/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.targets new file mode 100644 index 0000000..8d2a766 --- /dev/null +++ b/FadeBasic/FadeBasic.Testing/FadeBasic.Testing.targets @@ -0,0 +1,87 @@ + + + + + + + + + + + + + <_FadeStaleMtpJsonPath>$(MSBuildProjectDirectory)/global.json + + + + + + + + <_FadeGlobalJsonPath>$(MSBuildProjectDirectory)/global.json + <_FadeGlobalJsonContent>{ + "_comment": "Generated by FadeBasic.Testing because the package is referenced. Required so `dotnet test` opts into Microsoft.Testing.Platform. Safe to edit; the build only writes this file when no global.json exists.", + "test": { + "runner": "Microsoft.Testing.Platform" + } +} + + + + + + + + + + + diff --git a/FadeBasic/FadeBasic.Testing/FadeTestApplicationBuilder.cs b/FadeBasic/FadeBasic.Testing/FadeTestApplicationBuilder.cs new file mode 100644 index 0000000..c96420d --- /dev/null +++ b/FadeBasic/FadeBasic.Testing/FadeTestApplicationBuilder.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Virtual; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace FadeBasic.Testing +{ + /// + /// Single entry point used by the generated Main when a Fade + /// project opts in to dotnet test. Detects MTP-flavored args, + /// resolves an , and delegates to the + /// Microsoft.Testing.Platform builder. + /// + public static class FadeTestApplicationBuilder + { + // Anything starting with one of these prefixes (or matching one of the + // bare flags) is a MTP / dotnet-test invocation. We intentionally + // dispatch into MTP for *unknown* `--`-args too so future MTP + // additions don't fall through to Launcher.Main and break with + // "unrecognized argument" errors. + private static readonly string[] _mtpExactArgs = new[] + { + "--list-tests", + "--server", + "--diagnostic", + "--no-banner", + "--info", + "--help", + "--retry-failed-tests" + }; + + private static readonly string[] _mtpPrefixArgs = new[] + { + "--filter", "--filter-uid", "--filter-trait", + "--results-directory", "--report-trx", "--report-trx-filename", + "--minimum-expected-tests", "--timeout", "--treenode-filter" + }; + + // Environment variables MTP / vstest set when launching a test app. + // Their presence is a strong signal that we should route through MTP + // even when no recognized flag is on the command line. + private static readonly string[] _mtpEnvVarPrefixes = new[] + { + "TESTINGPLATFORM_", + "DOTNET_TEST_" + }; + + /// + /// True when the args (or surrounding environment) indicate a + /// dotnet test / IDE Test Explorer invocation. Used by the + /// generated Main to decide between MTP and the existing + /// path. + /// + public static bool IsTestInvocation(string[] args) + { + if (args != null) + { + foreach (var raw in args) + { + if (string.IsNullOrEmpty(raw)) continue; + foreach (var exact in _mtpExactArgs) + { + if (string.Equals(raw, exact, StringComparison.OrdinalIgnoreCase)) + return true; + } + foreach (var prefix in _mtpPrefixArgs) + { + if (raw.Equals(prefix, StringComparison.OrdinalIgnoreCase) || + raw.StartsWith(prefix + "=", StringComparison.OrdinalIgnoreCase) || + raw.StartsWith(prefix + ":", StringComparison.OrdinalIgnoreCase)) + return true; + } + } + } + + foreach (var envKey in System.Environment.GetEnvironmentVariables().Keys) + { + if (envKey is string s) + { + foreach (var prefix in _mtpEnvVarPrefixes) + { + if (s.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return true; + } + } + } + return false; + } + + // MTP args that print info and exit without ever running a test session. + // Hosts that do extra setup before MTP takes over (e.g., spinning up a + // game window) can use IsInfoOnlyInvocation to skip that work, since + // the framework's RunAsync session callback is never invoked for these. + private static readonly string[] _mtpInfoOnlyArgs = new[] + { + "--help", "-h", "-?", + "--info", + "--list-tests", + "--version" + }; + + /// + /// True when the args indicate MTP will just print information and + /// exit (help, version, --list-tests, etc.) without running any + /// tests. Use this in your Main to short-circuit any + /// expensive host-side setup (graphics device, content loading, + /// game-loop spin-up) and just await RunAsync directly. + /// + public static bool IsInfoOnlyInvocation(string[] args) + { + if (args == null) return false; + foreach (var raw in args) + { + if (string.IsNullOrEmpty(raw)) continue; + foreach (var info in _mtpInfoOnlyArgs) + { + if (string.Equals(raw, info, StringComparison.OrdinalIgnoreCase)) + return true; + } + } + return false; + } + + /// + /// Boot the MTP test application against . + /// Pass to inject a custom ; + /// otherwise the resolver discovers a -tagged + /// class or falls back to . + /// + public static async Task RunAsync(ITestLaunchable launchable, string[] args, IFadeTestHost? host = null) + { + var resolvedHost = FadeTestHostResolver.Resolve(host); + + var builder = await TestApplication.CreateBuilderAsync(args).ConfigureAwait(false); + builder.RegisterTestFramework( + _ => new FadeTestFrameworkCapabilities(), + (_, services) => new FadeTestFramework(launchable, resolvedHost, services)); + + using var app = await builder.BuildAsync().ConfigureAwait(false); + return await app.RunAsync().ConfigureAwait(false); + } + + /// + /// All concrete (non-abstract) tests in the launchable's manifest. + /// This is just a filtered view of ; + /// abstract entries are fixtures that exist for inheritance and aren't + /// runnable on their own. + /// + public static IEnumerable GetConcreteTests(ITestLaunchable launchable) + { + if (launchable == null) throw new ArgumentNullException(nameof(launchable)); + foreach (var entry in launchable.TestManifest) + { + if (entry.isAbstract) continue; + yield return entry; + } + } + + /// + /// The subset of that would actually + /// be executed under the supplied . Honors the + /// same filter shapes does at run time: + /// --filter-uid <fade::name>, --filter <path-glob>, + /// and the no-filter case (returns all concrete tests). + /// + /// Intended for hosts that want to skip expensive setup (e.g., booting + /// a graphics-device-backed game) when a run will execute zero tests. + /// + public static List SelectTests(ITestLaunchable launchable, string[] args) + { + if (launchable == null) throw new ArgumentNullException(nameof(launchable)); + + var filter = ParseFilterArgs(args); + var asmName = launchable.GetType().Assembly.GetName().Name ?? "Fade"; + var result = new List(); + foreach (var entry in launchable.TestManifest) + { + if (entry.isAbstract) continue; + if (filter.Matches(asmName, entry)) result.Add(entry); + } + return result; + } + + // Mirrors FadeTestFramework.BuildNodePath. Kept here so consumers can + // pre-compute the same path the framework would emit for a given + // entry, which is what TreeNodeFilter matches against. + internal static string BuildNodePath(string asmName, TestManifestEntry entry) + { + var typeName = "Tests"; + if (!string.IsNullOrEmpty(entry.sourceFilePath)) + { + typeName = System.IO.Path.GetFileNameWithoutExtension(entry.sourceFilePath); + } + return "/" + asmName + "/Fade/" + typeName + "/" + entry.name; + } + + private readonly struct ParsedFilter + { + public readonly HashSet? RequestedUids; + public readonly string? PathGlob; + + public ParsedFilter(HashSet? uids, string? path) + { + RequestedUids = uids; + PathGlob = path; + } + + public bool IsEmpty => RequestedUids == null && PathGlob == null; + + public bool Matches(string asmName, TestManifestEntry entry) + { + if (IsEmpty) return true; + if (RequestedUids != null && RequestedUids.Contains("fade::" + entry.name)) return true; + if (PathGlob != null) + { + var path = BuildNodePath(asmName, entry); + if (TreeNodeFilterMatches(PathGlob, path)) return true; + } + return false; + } + } + + private static ParsedFilter ParseFilterArgs(string[] args) + { + HashSet? uids = null; + string? pathGlob = null; + if (args == null) return new ParsedFilter(uids, pathGlob); + + for (var i = 0; i < args.Length; i++) + { + var raw = args[i]; + if (string.IsNullOrEmpty(raw)) continue; + + if (TryReadValue(args, ref i, "--filter-uid", out var uid)) + { + uids ??= new HashSet(StringComparer.Ordinal); + uids.Add(uid!); + } + // dotnet test sometimes forwards the user's `--filter ` + // unchanged, but on .NET 10 it can also rewrite to the + // explicit `--treenode-filter `. Accept both spellings. + else if (TryReadValue(args, ref i, "--filter", out var glob) + || TryReadValue(args, ref i, "--treenode-filter", out glob)) + { + pathGlob = glob; + } + } + return new ParsedFilter(uids, pathGlob); + } + + private static bool TryReadValue(string[] args, ref int i, string flag, out string? value) + { + var raw = args[i]; + if (string.Equals(raw, flag, StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 < args.Length) + { + value = args[++i]; + return true; + } + value = null; + return false; + } + var prefix = flag + "="; + if (raw.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + value = raw.Substring(prefix.Length); + return true; + } + // Some MTP variants accept `--filter:value`. + prefix = flag + ":"; + if (raw.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + value = raw.Substring(prefix.Length); + return true; + } + value = null; + return false; + } + + // MTP's TreeNodeFilter is internal-ctor and marked TPEXP, so we use + // reflection rather than `new TreeNodeFilter(...)`. If the API moves + // we degrade to "match anything" so a host doesn't accidentally skip + // booting and miss real tests. + private static MethodInfo? _treeNodeMatchMethod; + private static ConstructorInfo? _treeNodeCtor; + private static bool _treeNodeReflectionFailed; + + private static bool TreeNodeFilterMatches(string glob, string path) + { + if (_treeNodeReflectionFailed) return true; + + try + { + if (_treeNodeCtor == null) + { + var t = Type.GetType("Microsoft.Testing.Platform.Requests.TreeNodeFilter, Microsoft.Testing.Platform"); + if (t == null) { _treeNodeReflectionFailed = true; return true; } + _treeNodeCtor = t.GetConstructor( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, types: new[] { typeof(string) }, modifiers: null); + _treeNodeMatchMethod = t.GetMethod("MatchesFilter"); + if (_treeNodeCtor == null || _treeNodeMatchMethod == null) + { + _treeNodeReflectionFailed = true; + return true; + } + } + + var instance = _treeNodeCtor!.Invoke(new object[] { glob }); + var bag = new PropertyBag(); + return (bool)_treeNodeMatchMethod!.Invoke(instance, new object[] { path, bag })!; + } + catch + { + // Invalid filter expression (e.g., `**/x` — `**` not in final + // segment) is treated as a non-match. Same outcome as MTP at + // runtime, just without crashing the host. + return false; + } + } + } +} diff --git a/FadeBasic/FadeBasic.Testing/FadeTestFramework.cs b/FadeBasic/FadeBasic.Testing/FadeTestFramework.cs new file mode 100644 index 0000000..3c3bf1e --- /dev/null +++ b/FadeBasic/FadeBasic.Testing/FadeTestFramework.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Virtual; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.TestFramework; +using Microsoft.Testing.Platform.Messages; +using Microsoft.Testing.Platform.Requests; +using Microsoft.Testing.Platform.TestHost; + +namespace FadeBasic.Testing +{ + /// + /// Microsoft.Testing.Platform that surfaces + /// every concrete TestManifestEntry as an MTP . + /// Discovery and execution both flow through the configured + /// ; the default host calls + /// directly. + /// + internal sealed class FadeTestFramework : ITestFramework, IDataProducer + { + public const string FrameworkUid = "FadeBasic.Testing"; + + private readonly ITestLaunchable _launchable; + private readonly IFadeTestHost _host; + private readonly IServiceProvider _services; + private readonly HostMethodTable _hostMethods; + private FadeTestSessionContext? _sessionContext; + private bool _initialized; + // Guards against double-firing AfterAllTestsAsync. We invoke it from + // RunAsync's finally (so it pairs with BeforeAllTestsAsync and fires + // even when the filter matches zero tests), and again defensively from + // CloseTestSessionAsync — only the first call wins. Without the + // RunAsync-side call, a "0 tests matched" run leaves the host blocked + // because MTP, in some configurations, never calls CloseTestSession + // after a run with no produced TestNode updates. + private bool _afterAllInvoked; + + public FadeTestFramework(ITestLaunchable launchable, IFadeTestHost host, IServiceProvider services) + { + _launchable = launchable; + _host = host; + _services = services; + _hostMethods = HostMethodTable.FromCommandCollection(launchable.CommandCollection); + } + + public string Uid => FrameworkUid; + public string Version => typeof(FadeTestFramework).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + public string DisplayName => "Fade"; + public string Description => "Surfaces FadeBasic `test ... endtest` blocks to dotnet test."; + + public Type[] DataTypesProduced => new[] { typeof(TestNodeUpdateMessage) }; + + public Task IsEnabledAsync() => Task.FromResult(true); + + public async Task CreateTestSessionAsync(CreateTestSessionContext context) + { + _sessionContext = new FadeTestSessionContext(_launchable, _services); + try + { + await _host.InitializeAsync(_sessionContext, context.CancellationToken).ConfigureAwait(false); + _initialized = true; + return new CreateTestSessionResult { IsSuccess = true }; + } + catch (Exception ex) + { + return new CreateTestSessionResult { IsSuccess = false, ErrorMessage = "Fade test host init failed: " + ex.Message }; + } + } + + public async Task CloseTestSessionAsync(CloseTestSessionContext context) + { + if (_initialized && _sessionContext != null) + { + await InvokeAfterAllOnceAsync(context.CancellationToken).ConfigureAwait(false); + try + { + await _host.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Suppress — the session should still close cleanly even + // if the host's DisposeAsync throws. + } + } + return new CloseTestSessionResult { IsSuccess = true }; + } + + // Single-shot AfterAll invocation. Safe to call from both RunAsync's + // finally and CloseTestSessionAsync without the host seeing the call + // twice. + private async Task InvokeAfterAllOnceAsync(CancellationToken ct) + { + if (_sessionContext == null) return; + if (_afterAllInvoked) return; + _afterAllInvoked = true; + try + { + await _host.AfterAllTestsAsync(_sessionContext, ct).ConfigureAwait(false); + } + catch + { + // Suppress — the session should still close cleanly even if + // the host's AfterAll throws. The error is surfaced in the + // failed test that triggered it (if any). + } + } + + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + try + { + switch (context.Request) + { + case DiscoverTestExecutionRequest discoverRequest: + await DiscoverAsync(context, discoverRequest).ConfigureAwait(false); + break; + case RunTestExecutionRequest runRequest: + await RunAsync(context, runRequest).ConfigureAwait(false); + break; + default: + // Unknown request type — complete and let MTP move on. + break; + } + } + finally + { + context.Complete(); + } + } + + private async Task DiscoverAsync(ExecuteRequestContext context, DiscoverTestExecutionRequest request) + { + foreach (var entry in EnumerateConcrete(_launchable.TestManifest)) + { + var node = BuildTestNode(entry); + node.Properties.Add(DiscoveredTestNodeStateProperty.CachedInstance); + await PublishAsync(context, request.Session.SessionUid, node).ConfigureAwait(false); + } + } + + private async Task RunAsync(ExecuteRequestContext context, RunTestExecutionRequest request) + { + if (_sessionContext == null) return; + + var ct = context.CancellationToken; + + await _host.BeforeAllTestsAsync(_sessionContext, ct).ConfigureAwait(false); + + try + { + foreach (var entry in EnumerateConcrete(_launchable.TestManifest)) + { + ct.ThrowIfCancellationRequested(); + + var node = BuildTestNode(entry); + if (!ShouldRun(entry, node, request.Filter)) continue; + + node.Properties.Add(InProgressTestNodeStateProperty.CachedInstance); + await PublishAsync(context, request.Session.SessionUid, node).ConfigureAwait(false); + + FadeTestResult result; + try + { + var runCtx = new FadeTestRunContext(_launchable, entry, _hostMethods); + result = await _host.RunTestAsync(runCtx, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The runner itself cancelled — treat as a failure attached + // to this test so MTP doesn't drop the in-progress state. + result = new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "test cancelled" + }; + } + catch (Exception ex) + { + result = new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "test host threw: " + ex.Message + }; + } + + var finalNode = BuildTestNode(entry); + if (result.passed) + { + finalNode.Properties.Add(PassedTestNodeStateProperty.CachedInstance); + } + else + { + var ex = new FadeTestException(result); + finalNode.Properties.Add(new FailedTestNodeStateProperty(ex, result.failureMessage)); + } + await PublishAsync(context, request.Session.SessionUid, finalNode).ConfigureAwait(false); + } + } + finally + { + // Pair AfterAll with the BeforeAll above. If the filter matched + // zero tests (foreach never enters the body), or if MTP doesn't + // call CloseTestSessionAsync after a no-op run, the host still + // gets the "all tests done" signal it uses to wind down — e.g., + // a hosted Game shutting down its window. + await InvokeAfterAllOnceAsync(ct).ConfigureAwait(false); + } + } + + private TestNode BuildTestNode(TestManifestEntry entry) + { + var node = new TestNode + { + Uid = "fade::" + entry.name, + DisplayName = entry.name + }; + + var (asmName, nsName, typeName, methodName) = SplitIdentity(entry); + + // TestMethodIdentifierProperty lets MTP's TreeNodeFilter resolve + // path-style filters like `dotnet test --filter "*singleFrame*"` + // or `/*/*//*`. Without it the filter has no structured + // properties to match against and silently selects zero tests. + // Positional args here because the record's parameter names in + // the shipping NuGet metadata don't match the property names — + // named arguments fail to bind. + node.Properties.Add(new TestMethodIdentifierProperty( + asmName, + nsName, + typeName, + methodName, + /*arity:*/ 0, + Array.Empty(), + "void")); + + // File-location property gives Test Explorer the gutter source + // link. The compile-time post-pass (LaunchUtil) populates + // sourceFilePath via the project's SourceMap; older launchables + // may leave it empty, in which case the IDE will fall back to + // the test name instead of a clickable file link. + if (entry.sourceLine > 0) + { + node.Properties.Add(new TestFileLocationProperty( + entry.sourceFilePath ?? string.Empty, + new LinePositionSpan( + new LinePosition(entry.sourceLine, entry.sourceChar), + new LinePosition(entry.sourceLine, entry.sourceChar)))); + } + return node; + } + + // MTP tree-node paths are `/Asm/Namespace/Type/Method`. Fade tests + // don't have a true CLR class hierarchy, so we synthesize: + // asm → the launchable's owning assembly + // ns → constant "Fade" (avoid an empty segment — MTP's path parser + // collapses consecutive slashes, which would silently turn + // our 4-segment path into 3 and break `/*/*/*/`- + // style filters) + // type → the .fbasic file's basename (so all tests in fish.fbasic + // share `/.../fish/...`, which makes per-file filters natural) + // method → the test name + private (string asm, string ns, string type, string method) SplitIdentity(TestManifestEntry entry) + { + var asm = _launchable.GetType().Assembly.GetName().Name ?? "Fade"; + var ns = "Fade"; + var type = "Tests"; + if (!string.IsNullOrEmpty(entry.sourceFilePath)) + { + type = System.IO.Path.GetFileNameWithoutExtension(entry.sourceFilePath); + } + return (asm, ns, type, entry.name); + } + + private string BuildNodePath(TestManifestEntry entry) + { + var (asm, ns, type, method) = SplitIdentity(entry); + return $"/{asm}/{ns}/{type}/{method}"; + } + + private static IEnumerable EnumerateConcrete(IReadOnlyList manifest) + { + foreach (var entry in manifest) + { + if (entry.isAbstract) continue; + yield return entry; + } + } + + // MTP exposes three filter shapes: + // NopFilter — always match (the default). + // TestNodeUidListFilter — exact UID matches; produced by selections + // coming from --filter-uid or IDE test-panel + // "run selected" actions. + // TreeNodeFilter — path/glob expression on `/Asm/Ns/Type/Method`; + // produced by `dotnet test --filter "..."`. + // Anything else: be permissive (run the test) so a future MTP filter + // type doesn't silently drop tests. + private bool ShouldRun(TestManifestEntry entry, TestNode node, ITestExecutionFilter? filter) + { + if (filter == null || filter is NopFilter) return true; + + if (filter is TestNodeUidListFilter uidList && uidList.TestNodeUids != null) + { + foreach (var u in uidList.TestNodeUids) + { + if (u.Value == node.Uid) return true; + } + return false; + } + + // TreeNodeFilter is currently flagged TPEXP ("evaluation only") + // by MTP. Suppressed here because path-style `dotnet test --filter` + // is the de-facto way users select tests; the API has been stable + // across recent MTP versions and the diagnostic just signals that + // the type may move to a non-preview namespace in the future. +#pragma warning disable TPEXP + if (filter is TreeNodeFilter tree) + { + return tree.MatchesFilter(BuildNodePath(entry), node.Properties); + } +#pragma warning restore TPEXP + + return true; + } + + private async Task PublishAsync(ExecuteRequestContext context, SessionUid sessionUid, TestNode node) + { + await context.MessageBus + .PublishAsync(this, new TestNodeUpdateMessage(sessionUid, node)) + .ConfigureAwait(false); + } + } + + /// + /// Surfaces a Fade-specific failure to MTP. The framework reports failure + /// messages with their original `.fbasic` source text and the offending + /// instruction index; the IDE renders the stack from . + /// + internal sealed class FadeTestException : Exception + { + public FadeTestException(FadeTestResult result) + : base(BuildMessage(result)) + { + } + + private static string BuildMessage(FadeTestResult r) + { + var msg = string.IsNullOrEmpty(r.failureMessage) ? "test failed" : r.failureMessage; + if (!string.IsNullOrEmpty(r.failureSourceText)) + { + msg += $"\n source: {r.failureSourceText}"; + } + if (r.failureFrames != null && r.failureFrames.Count > 0) + { + foreach (var frame in r.failureFrames) + { + var label = string.IsNullOrEmpty(frame.functionName) ? "" : frame.functionName + " "; + msg += $"\n at {label}line {frame.lineNumber}"; + } + } + else if (r.failureInstructionIndex >= 0) + { + msg += $"\n ip: {r.failureInstructionIndex}"; + } + return msg; + } + } + + internal sealed class FadeTestFrameworkCapabilities : ITestFrameworkCapabilities + { + public IReadOnlyCollection Capabilities { get; } + = Array.Empty(); + } +} diff --git a/FadeBasic/FadeBasic.sln b/FadeBasic/FadeBasic.sln index 447a99c..565e01d 100644 --- a/FadeBasic/FadeBasic.sln +++ b/FadeBasic/FadeBasic.sln @@ -1,5 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic", "FadeBasic\FadeBasic.csproj", "{57007F64-F4ED-4979-BC09-1F58502953A2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{F08EFE79-1EF3-440C-BB3E-50840E774E60}" @@ -36,6 +37,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "usesProject", "Tests\Fixtur EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeCommandsViaNuget", "FadeCommandsViaNuget\FadeCommandsViaNuget.csproj", "{66EE8425-A1A9-4019-B1DC-0E0F8C7E2793}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.Testing", "FadeBasic.Testing\FadeBasic.Testing.csproj", "{E86D1BC3-23A7-476A-98AF-9AC25BB70EC0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.TestAdapter", "FadeBasic.TestAdapter\FadeBasic.TestAdapter.csproj", "{42AF7CA1-A86A-42C4-9E91-3748020EAFB9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.Lib.Web", "FadeBasic.Lib.Web\FadeBasic.Lib.Web.csproj", "{CF59F563-2ADB-43F4-BF5C-BCA23495D127}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.Export.Web", "FadeBasic.Export.Web\FadeBasic.Export.Web.csproj", "{4C67156C-4CA0-42FA-AB28-8F8A90A60057}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +120,22 @@ Global {66EE8425-A1A9-4019-B1DC-0E0F8C7E2793}.Debug|Any CPU.Build.0 = Debug|Any CPU {66EE8425-A1A9-4019-B1DC-0E0F8C7E2793}.Release|Any CPU.ActiveCfg = Release|Any CPU {66EE8425-A1A9-4019-B1DC-0E0F8C7E2793}.Release|Any CPU.Build.0 = Release|Any CPU + {E86D1BC3-23A7-476A-98AF-9AC25BB70EC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E86D1BC3-23A7-476A-98AF-9AC25BB70EC0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E86D1BC3-23A7-476A-98AF-9AC25BB70EC0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E86D1BC3-23A7-476A-98AF-9AC25BB70EC0}.Release|Any CPU.Build.0 = Release|Any CPU + {42AF7CA1-A86A-42C4-9E91-3748020EAFB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42AF7CA1-A86A-42C4-9E91-3748020EAFB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42AF7CA1-A86A-42C4-9E91-3748020EAFB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42AF7CA1-A86A-42C4-9E91-3748020EAFB9}.Release|Any CPU.Build.0 = Release|Any CPU + {CF59F563-2ADB-43F4-BF5C-BCA23495D127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF59F563-2ADB-43F4-BF5C-BCA23495D127}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF59F563-2ADB-43F4-BF5C-BCA23495D127}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF59F563-2ADB-43F4-BF5C-BCA23495D127}.Release|Any CPU.Build.0 = Release|Any CPU + {4C67156C-4CA0-42FA-AB28-8F8A90A60057}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C67156C-4CA0-42FA-AB28-8F8A90A60057}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C67156C-4CA0-42FA-AB28-8F8A90A60057}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C67156C-4CA0-42FA-AB28-8F8A90A60057}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {BFF3475D-F580-4C2C-B025-B80BAD2EB915} = {7A2CFDCE-7E00-4B3A-82DE-E5CE48A4F13D} diff --git a/FadeBasic/FadeBasic/Ast/AstNode.cs b/FadeBasic/FadeBasic/Ast/AstNode.cs index d2afa69..848f29d 100644 --- a/FadeBasic/FadeBasic/Ast/AstNode.cs +++ b/FadeBasic/FadeBasic/Ast/AstNode.cs @@ -163,7 +163,13 @@ public abstract class AstNode : IAstNode, IAstVisitable public Token StartToken => startToken; public Token EndToken => endToken; - public List Errors { get; set; } = new List(); + private List _errors; + public List Errors + { + get => _errors ??= new List(); + set => _errors = value; + } + public bool HasErrors => _errors != null && _errors.Count > 0; public Symbol DeclaredFromSymbol { get; set; } public TypeInfo ParsedType { get; set; } = TypeInfo.Unset; @@ -200,61 +206,34 @@ public override string ToString() protected abstract string GetString(); - public abstract IEnumerable IterateChildNodes(); + protected abstract void VisitChildren(Action onVisit, Action onExit); public void Where(Func predicate, List buffer) { - if (predicate(this)) - { - buffer.Add(this); - } - var nodes = IterateChildNodes(); - foreach (var node in nodes) - { - if (node == null) continue; - var found = node.Where(predicate); - if (found != null) - { - buffer.AddRange(found); - } - } - + Visit(node => { if (predicate(node)) buffer.Add(node); }); } + public List Where(Func predicate) { var output = new List(); Where(predicate, output); return output; } + public IAstVisitable FindFirst(Func predicate) { - if (predicate(this)) - { - return this; - } - var nodes = IterateChildNodes(); - foreach (var node in nodes) - { - if (node == null) continue; - var found = node.FindFirst(predicate); - if (found != null) - { - return found; - } - } - - return null; + if (predicate(this)) return this; + IAstVisitable found = null; + VisitChildren( + node => { if (found == null && predicate(node)) found = node; }, + null); + return found; } - - public void Visit(Action onVisit, Action onExit=null) + + public void Visit(Action onVisit, Action onExit = null) { onVisit(this); - var nodes = IterateChildNodes(); - foreach (var node in nodes) - { - if (node == null) continue; - node.Visit(onVisit, onExit); - } + VisitChildren(onVisit, onExit); onExit?.Invoke(this); } } @@ -262,21 +241,10 @@ public void Visit(Action onVisit, Action onExit=nu public interface IAstVisitable : IAstNode { - IEnumerable IterateChildNodes(); - - public void Visit(Action onVisit, Action onExit=null); + public void Visit(Action onVisit, Action onExit = null); public IAstVisitable FindFirst(Func predicate); public List Where(Func predicate); public void Where(Func predicate, List buffer); - // { - // onVisit(this); - // var nodes = IterateChildNodes(); - // foreach (var node in nodes) - // { - // if (node == null) continue; - // node.Visit(onVisit); - // } - // } } public static class ErrorVisitorExtensions @@ -287,7 +255,7 @@ public static List GetAllErrors(this IAstVisitable visitable) visitable.Visit(child => { - if (child.Errors != null && child.Errors.Count > 0) + if (child.HasErrors) errors.AddRange(child.Errors); }); return errors; diff --git a/FadeBasic/FadeBasic/Ast/DeclerationNode.cs b/FadeBasic/FadeBasic/Ast/DeclerationNode.cs index c1bd3cd..c5ea2c6 100644 --- a/FadeBasic/FadeBasic/Ast/DeclerationNode.cs +++ b/FadeBasic/FadeBasic/Ast/DeclerationNode.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -21,10 +22,10 @@ protected override string GetString() return $"redim {variable},({string.Join(",", ranks.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return variable; - if (ranks != null) foreach (var rank in ranks) yield return rank; + variable?.Visit(onVisit, onExit); + if (ranks != null) foreach (var rank in ranks) rank?.Visit(onVisit, onExit); } public string Trivia { get; set; } } @@ -145,11 +146,11 @@ protected override string GetString() return $"dim {scopeType.ToString().ToLowerInvariant()},{variable},{type},({string.Join(",", ranks.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return type; - if (ranks != null) foreach (var rank in ranks) yield return rank; - if (initializerExpression != null) yield return initializerExpression; + type?.Visit(onVisit, onExit); + if (ranks != null) foreach (var rank in ranks) rank?.Visit(onVisit, onExit); + initializerExpression?.Visit(onVisit, onExit); } public string Trivia { get; set; } diff --git a/FadeBasic/FadeBasic/Ast/ExpressionNode.cs b/FadeBasic/FadeBasic/Ast/ExpressionNode.cs index c171865..ada267b 100644 --- a/FadeBasic/FadeBasic/Ast/ExpressionNode.cs +++ b/FadeBasic/FadeBasic/Ast/ExpressionNode.cs @@ -19,6 +19,7 @@ public interface ILiteralNode : IExpressionNode public interface ICanHaveErrors { List Errors { get; } + bool HasErrors { get; } } public enum UnaryOperationType @@ -174,10 +175,9 @@ protected override string GetString() return $"xcall {command.name}{argString}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var arg in args) yield return arg; - + foreach (var arg in args) arg?.Visit(onVisit, onExit); } } @@ -197,9 +197,9 @@ protected override string GetString() return $"{OperationUtil.ToString(operationType)} {rhs}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return rhs; + rhs?.Visit(onVisit, onExit); } } @@ -226,10 +226,10 @@ protected override string GetString() return $"{OperationUtil.ToString(operationType)} {lhs},{rhs}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return lhs; - yield return rhs; + lhs?.Visit(onVisit, onExit); + rhs?.Visit(onVisit, onExit); } } @@ -250,9 +250,9 @@ protected override string GetString() return $"derefExpr {expression}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return expression; + expression?.Visit(onVisit, onExit); } } @@ -272,9 +272,9 @@ protected override string GetString() return $"addr {variableNode}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return variableNode; + variableNode?.Visit(onVisit, onExit); } } @@ -285,10 +285,7 @@ protected override string GetString() return "default"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } public class LiteralIntExpression : AstNode, ILiteralNode @@ -336,10 +333,7 @@ protected override string GetString() return value.ToString(); } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } @@ -361,10 +355,7 @@ protected override string GetString() return startToken.caseInsensitiveRaw; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } public class LiteralStringExpression : AstNode, ILiteralNode @@ -386,9 +377,62 @@ protected override string GetString() return startToken.raw; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { } + } + + /// + /// `len()` — integer expression returning the element count of + /// an array or the character count of a string. The inner expression + /// must be array- or string-typed; the visitor enforces that. Element + /// size is determined at compile time and emitted as an inline byte + /// after the LENGTH opcode. + /// + public class LenExpression : AstNode, IExpressionNode + { + public IExpressionNode inner; + + public LenExpression(Token startToken, Token endToken, IExpressionNode inner) : base(startToken, endToken) + { + this.inner = inner; + } + + protected override string GetString() + { + return $"len {inner}"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) { - yield break; + inner?.Visit(onVisit, onExit); } } + + /// + /// `call count ` — integer expression returning the number of + /// times the host command was invoked during the current VM execution. + /// Counts every CALL_HOST (whether mocked or not) so the user can write + /// `assert call count save_file = 1` without having to install a mock + /// first. Legal inside a test block. + /// + public class CallCountExpression : AstNode, IExpressionNode + { + // Full command name, lowercased (matches the lexer's + // CommandNameTree-normalized form, like MockStatement.commandName). + public string commandName; + public Token commandNameToken; + + public CallCountExpression(Token startToken, Token endToken, Token nameToken) : base(startToken, endToken) + { + commandNameToken = nameToken; + commandName = nameToken?.caseInsensitiveRaw; + } + + protected override string GetString() + { + return $"call count {commandName}"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) { } + } + } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Ast/FunctionStatement.cs b/FadeBasic/FadeBasic/Ast/FunctionStatement.cs index 45d07c5..c8ac27f 100644 --- a/FadeBasic/FadeBasic/Ast/FunctionStatement.cs +++ b/FadeBasic/FadeBasic/Ast/FunctionStatement.cs @@ -28,10 +28,10 @@ protected override string GetString() return $"arg {variable} as {type}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return variable; - yield return type; + variable?.Visit(onVisit, onExit); + type?.Visit(onVisit, onExit); } } @@ -51,19 +51,19 @@ protected override string GetString() return $"retfunc {returnExpression}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - if (returnExpression != null) - { - yield return returnExpression; - } + returnExpression?.Visit(onVisit, onExit); } } public class FunctionStatement : AstNode, IStatementNode, IHasTriviaNode { + public const string REGION_TOP_LEVEL = null; // a top level function. + public string name; public Token nameToken; + public string region = REGION_TOP_LEVEL; // a null public List parameters = new List(); public List statements = new List(); public List labels = new List(); @@ -79,11 +79,10 @@ protected override string GetString() return $"func {name} ({string.Join(",", parameters.Select(x => x.ToString()))}),({string.Join(",", statements.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var parameter in parameters) yield return parameter; - foreach (var statement in statements) yield return statement; - + foreach (var parameter in parameters) parameter?.Visit(onVisit, onExit); + foreach (var statement in statements) statement?.Visit(onVisit, onExit); } public string Trivia { get; set; } diff --git a/FadeBasic/FadeBasic/Ast/InitializerExpression.cs b/FadeBasic/FadeBasic/Ast/InitializerExpression.cs index 99b1cb1..0b2c0f1 100644 --- a/FadeBasic/FadeBasic/Ast/InitializerExpression.cs +++ b/FadeBasic/FadeBasic/Ast/InitializerExpression.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -13,10 +14,9 @@ protected override string GetString() return $"init ({string.Join(",", assignments.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var x in assignments) - yield return x; + foreach (var x in assignments) x?.Visit(onVisit, onExit); } } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Ast/LabelDeclarationNode.cs b/FadeBasic/FadeBasic/Ast/LabelDeclarationNode.cs index 33db0d2..d877afb 100644 --- a/FadeBasic/FadeBasic/Ast/LabelDeclarationNode.cs +++ b/FadeBasic/FadeBasic/Ast/LabelDeclarationNode.cs @@ -1,12 +1,8 @@ +using System; using System.Collections.Generic; namespace FadeBasic.Ast { - public class LabelDefinition - { - public LabelDeclarationNode node; - public int statementIndex; - } public class LabelDeclarationNode : AstNode, IStatementNode, IHasTriviaNode { @@ -28,10 +24,7 @@ protected override string GetString() return $"label {label}"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } public string Trivia { get; set; } } diff --git a/FadeBasic/FadeBasic/Ast/ProgramNode.cs b/FadeBasic/FadeBasic/Ast/ProgramNode.cs index 76573d0..92617da 100644 --- a/FadeBasic/FadeBasic/Ast/ProgramNode.cs +++ b/FadeBasic/FadeBasic/Ast/ProgramNode.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -14,7 +15,12 @@ public ProgramNode(Token start) : base(start) public List statements = new List(); public List typeDefinitions = new List(); public List functions = new List(); - public List labels = new List(); + public List labels = new List(); + public List tests = new List(); + // CommandCollection the parser used to resolve command names. Stashed + // here so post-parse visitors (e.g., mock-body type validation) can + // look up command metadata without taking it as a parameter. + public CommandCollection commands; protected override string GetString() { List allStatements = new List(); @@ -22,23 +28,16 @@ protected override string GetString() allStatements.AddRange(typeDefinitions); allStatements.AddRange(statements); allStatements.AddRange(functions); + allStatements.AddRange(tests); return $"{string.Join(",", allStatements.Select(x => x.ToString()))}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var statement in statements) - { - yield return statement; - } - foreach (var function in functions) - { - yield return function; - } - foreach (var type in typeDefinitions) - { - yield return type; - } + foreach (var statement in statements) statement?.Visit(onVisit, onExit); + foreach (var function in functions) function?.Visit(onVisit, onExit); + foreach (var type in typeDefinitions) type?.Visit(onVisit, onExit); + foreach (var test in tests) test?.Visit(onVisit, onExit); } } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Ast/StatementNode.cs b/FadeBasic/FadeBasic/Ast/StatementNode.cs index 2ede9c9..3c5d467 100644 --- a/FadeBasic/FadeBasic/Ast/StatementNode.cs +++ b/FadeBasic/FadeBasic/Ast/StatementNode.cs @@ -18,10 +18,7 @@ protected override string GetString() return "noop"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } public class TypeDefinitionMember : AstNode, IAstVisitable, IHasTriviaNode @@ -41,10 +38,10 @@ protected override string GetString() return $"{name} as {type}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return name; - yield return type; + name?.Visit(onVisit, onExit); + type?.Visit(onVisit, onExit); } public string Trivia { get; set; } @@ -66,11 +63,10 @@ protected override string GetString() return $"type {name.variableName} {string.Join(",", declarations.Select(x => x.ToString()))}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return name; - foreach (var decl in declarations) - yield return decl; + name?.Visit(onVisit, onExit); + foreach (var decl in declarations) decl?.Visit(onVisit, onExit); } } @@ -82,10 +78,7 @@ protected override string GetString() return "end"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } public class GotoStatement : AstNode, IStatementNode @@ -101,12 +94,186 @@ protected override string GetString() return $"goto {label}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { } + } + + public class AssertStatement : AstNode, IStatementNode + { + public IExpressionNode condition; + // Source-text snapshot of the asserted expression at the time of parsing. + // For macro-expanded sites this is the post-substitution text. The runtime + // uses this to format failure messages. + public string sourceText; + + // Optional second arg: a string expression giving a human-readable reason + // surfaced in the failure report. Null when not supplied. + public IExpressionNode reason; + + public AssertStatement(Token startToken, Token endToken, IExpressionNode condition, string sourceText) + : base(startToken, endToken) + { + this.condition = condition; + this.sourceText = sourceText; + } + + protected override string GetString() + { + if (reason != null) return $"assert {condition}, {reason}"; + return $"assert {condition}"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) + { + condition?.Visit(onVisit, onExit); + reason?.Visit(onVisit, onExit); + } + } + + public class RuntoStatement : AstNode, IStatementNode + { + public string targetLabel; + public Token targetLabelToken; + + // Optional clauses parsed from the block form (`runto :name ... endrunto`). + // Recorded for forward-compatibility; not yet wired into the runtime. + public IExpressionNode maxCyclesExpression; + + public RuntoStatement(Token startToken, Token endToken, Token labelToken) + : base(startToken, endToken) + { + targetLabelToken = labelToken; + targetLabel = labelToken.caseInsensitiveRaw; + } + + protected override string GetString() + { + if (maxCyclesExpression != null) + { + return $"runto {targetLabel} max-cycles {maxCyclesExpression}"; + } + return $"runto {targetLabel}"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) + { + maxCyclesExpression?.Visit(onVisit, onExit); + } + } + + /// + /// `returns ` inside a mock body. Sets the return value the mocked + /// command produces when called. Only valid inside a mock block; the + /// scope-error visitor enforces that. + /// + public class MockExitMockStatement : AstNode, IStatementNode + { + public IExpressionNode expression; + + public MockExitMockStatement(Token startToken, Token endToken) : base(startToken, endToken) + { + } + + protected override string GetString() + { + return $"returns {expression}"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) { - yield break; + expression?.Visit(onVisit, onExit); } } + /// + /// `forbid []` inside a mock body. Causes the test to fail when + /// the mocked command is called. The optional reason string surfaces in + /// the failure report (mirrors `assert , "reason"`). + /// + public class MockForbidStatement : AstNode, IStatementNode + { + public IExpressionNode reason; // null when no reason was supplied + + public MockForbidStatement(Token startToken, Token endToken) : base(startToken, endToken) + { + } + + protected override string GetString() + { + return reason != null ? $"forbid {reason}" : "forbid"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) + { + reason?.Visit(onVisit, onExit); + } + } + + public class MockStatement : AstNode, IStatementNode + { + // The full command name, e.g. "screen width". Stored as the source text + // of the command-name token (already normalized by the lexer's + // CommandNameTree pass). + public string commandName; + public Token commandNameToken; + // Optional parameter names — `mock find pattern, list` binds the + // command's args to locals named `pattern` and `list` inside the body. + // Empty means anonymous (args are popped off the stack but not + // accessible). The count must match the command's non-VmArg arg count + // when names are given; the visitor enforces that. + public List parameters = new List(); + // Optional fall-through return expression on `endmock ` — the + // value the body produces when execution reaches the closing + // `endmock` without an earlier `exitmock`. Mirrors `endfunction + // ` for functions. Null when the user wrote bare `endmock`. + public IExpressionNode endmockExpression; + // Body of the mock block. Compiled as a mini-function the VM + // dispatches to at CALL_HOST time: a scope is pushed, parameters + // bound from the call's args, then body statements run. `returns` + // (MockExitMockStatement) sets the return value; `forbid` + // (MockForbidStatement) fails the test. Other test-block statements + // (static print, local, if/then, assert) are legal here too. + // An empty body on a void command means "suppress the call." + public List body = new List(); + + public MockStatement(Token startToken, Token endToken) : base(startToken, endToken) + { + } + + protected override string GetString() + { + var paramStr = parameters.Count > 0 + ? " " + string.Join(",", parameters.Select(p => p.variableName)) + : ""; + return $"mock {commandName}{paramStr} ({string.Join(",", body.Select(s => s.ToString()))})"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) + { + foreach (var p in parameters) p?.Visit(onVisit, onExit); + foreach (var stmt in body) stmt?.Visit(onVisit, onExit); + endmockExpression?.Visit(onVisit, onExit); + } + } + + public class ClearMockStatement : AstNode, IStatementNode + { + // Null means "clear all mocks" (`clear mocks`). + // Non-null is a specific command name (`clear mock screen width`). + public string commandName; + public Token commandNameToken; + + public ClearMockStatement(Token startToken, Token endToken) : base(startToken, endToken) + { + } + + protected override string GetString() + { + return commandName == null ? "clear mocks" : $"clear mock {commandName}"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) { } + } + public class MacroSubstitutionExpression : AstNode, IExpressionNode { public IExpressionNode innerExpression; @@ -118,10 +285,9 @@ protected override string GetString() return $"subst ({innerExpression})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - if (innerExpression != null) - yield return innerExpression; + innerExpression?.Visit(onVisit, onExit); } } @@ -153,9 +319,9 @@ protected override string GetString() return $"tokenize ({string.Join(",", substitutions.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var statement in substitutions) yield return statement; + foreach (var statement in substitutions) statement?.Visit(onVisit, onExit); } } @@ -171,9 +337,9 @@ protected override string GetString() return $"expr {expression}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return expression; + expression?.Visit(onVisit, onExit); } } @@ -189,10 +355,7 @@ protected override string GetString() return $"ret"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } @@ -209,10 +372,7 @@ protected override string GetString() return $"gosub {label}"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } @@ -237,9 +397,9 @@ protected override string GetString() return $"call {command.name}{argString}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var arg in args) yield return arg; + foreach (var arg in args) arg?.Visit(onVisit, onExit); } } @@ -258,10 +418,10 @@ protected override string GetString() return $"= {variable},{expression}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return variable; - yield return expression; + variable?.Visit(onVisit, onExit); + expression?.Visit(onVisit, onExit); } public string Trivia { get; set; } @@ -278,10 +438,7 @@ protected override string GetString() return "break"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } public class SkipLoopStatement : AstNode, IStatementNode @@ -295,10 +452,7 @@ protected override string GetString() return "skip"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } public class DoLoopStatement : AstNode, IStatementNode @@ -315,9 +469,9 @@ protected override string GetString() return $"do ({string.Join(",", statements.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var statement in statements) yield return statement; + foreach (var statement in statements) statement?.Visit(onVisit, onExit); } } @@ -348,14 +502,13 @@ protected override string GetString() return $"for {variableNode},{startValueExpression},{endValueExpression},{stepValueExpression},({string.Join(",", statements.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return variableNode; - yield return startValueExpression; - yield return endValueExpression; - yield return stepValueExpression; - foreach (var statement in statements) yield return statement; - + variableNode?.Visit(onVisit, onExit); + startValueExpression?.Visit(onVisit, onExit); + endValueExpression?.Visit(onVisit, onExit); + stepValueExpression?.Visit(onVisit, onExit); + foreach (var statement in statements) statement?.Visit(onVisit, onExit); } } @@ -369,10 +522,10 @@ protected override string GetString() return $"while {condition} {string.Join(",", statements.Select(x => x.ToString()))}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return condition; - foreach (var statement in statements) yield return statement; + condition?.Visit(onVisit, onExit); + foreach (var statement in statements) statement?.Visit(onVisit, onExit); } } @@ -386,10 +539,10 @@ protected override string GetString() return $"repeat {condition} {string.Join(",", statements.Select(x => x.ToString()))}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return condition; - foreach (var statement in statements) yield return statement; + condition?.Visit(onVisit, onExit); + foreach (var statement in statements) statement?.Visit(onVisit, onExit); } } @@ -410,12 +563,11 @@ protected override string GetString() return $"switch {expression} ({string.Join(",", statements.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return expression; - if (cases != null) foreach (var caseInstance in cases) - yield return caseInstance; - yield return defaultCase; + expression?.Visit(onVisit, onExit); + if (cases != null) foreach (var caseInstance in cases) caseInstance?.Visit(onVisit, onExit); + defaultCase?.Visit(onVisit, onExit); } } @@ -428,12 +580,10 @@ protected override string GetString() return $"case {string.Join(",", values.Select(x => x.ToString()))} ({string.Join(",", statements.Select((x => x.ToString())))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var value in values) - yield return value; - foreach (var statement in statements) - yield return statement; + foreach (var value in values) value?.Visit(onVisit, onExit); + foreach (var statement in statements) statement?.Visit(onVisit, onExit); } } @@ -445,10 +595,9 @@ protected override string GetString() return $"case default ({string.Join(",", statements.Select((x => x.ToString())))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var statement in statements) - yield return statement; + foreach (var statement in statements) statement?.Visit(onVisit, onExit); } } @@ -482,13 +631,11 @@ protected override string GetString() return $"if {condition} ({string.Join(",", positiveStatements)}){negativeStr}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return condition; - foreach (var statement in positiveStatements) - yield return statement; - foreach (var statement in negativeStatements) - yield return statement; + condition?.Visit(onVisit, onExit); + foreach (var statement in positiveStatements) statement?.Visit(onVisit, onExit); + foreach (var statement in negativeStatements) statement?.Visit(onVisit, onExit); } } @@ -505,10 +652,7 @@ protected override string GetString() return $"rem{comment}"; } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } /// @@ -537,10 +681,9 @@ protected override string GetString() return $"defer ({string.Join(",", statements.Select(x => x.ToString()))})"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var statement in statements) - yield return statement; + foreach (var statement in statements) statement?.Visit(onVisit, onExit); } } diff --git a/FadeBasic/FadeBasic/Ast/TestNode.cs b/FadeBasic/FadeBasic/Ast/TestNode.cs new file mode 100644 index 0000000..26daec9 --- /dev/null +++ b/FadeBasic/FadeBasic/Ast/TestNode.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FadeBasic.Ast +{ + public class TestNode : AstNode, IStatementNode, IHasTriviaNode + { + public string name; + public Token nameToken; + public bool isAbstract; + public string fromParent; + public Token fromParentToken; + // public List statements = new List(); + // public List labels = new List(); + // public List functions = new List(); + + public ProgramNode testProgram; + + public TestNode() + { + } + + protected override string GetString() + { + var prefix = isAbstract ? "abstract test" : "test"; + var fromClause = fromParent != null ? $" from {fromParent}" : ""; + return $"{prefix} {name}{fromClause} {testProgram.ToString()}"; + } + + protected override void VisitChildren(Action onVisit, Action onExit) + { + foreach (var s in testProgram.statements) s?.Visit(onVisit, onExit); + foreach (var f in testProgram.functions) f?.Visit(onVisit, onExit); + foreach (var t in testProgram.typeDefinitions) t?.Visit(onVisit, onExit); + foreach (var t in testProgram.tests) t?.Visit(onVisit, onExit); + } + + public string Trivia { get; set; } + } +} diff --git a/FadeBasic/FadeBasic/Ast/TypeReferenceNode.cs b/FadeBasic/FadeBasic/Ast/TypeReferenceNode.cs index 2cfae7c..69cc392 100644 --- a/FadeBasic/FadeBasic/Ast/TypeReferenceNode.cs +++ b/FadeBasic/FadeBasic/Ast/TypeReferenceNode.cs @@ -26,9 +26,9 @@ protected override string GetString() } public VariableType variableType => VariableType.Struct; - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return variableNode; + variableNode?.Visit(onVisit, onExit); } public string Trivia { get; set; } @@ -79,9 +79,6 @@ protected override string GetString() return variableType.ToString().ToLowerInvariant(); } - public override IEnumerable IterateChildNodes() - { - yield break; - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Ast/VariableRefNode.cs b/FadeBasic/FadeBasic/Ast/VariableRefNode.cs index e370dcf..6bab75e 100644 --- a/FadeBasic/FadeBasic/Ast/VariableRefNode.cs +++ b/FadeBasic/FadeBasic/Ast/VariableRefNode.cs @@ -24,9 +24,9 @@ protected override string GetString() return $"deref {ptrExpression}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return ptrExpression; + ptrExpression?.Visit(onVisit, onExit); } } @@ -41,10 +41,10 @@ protected override string GetString() return $"{left}.{right}"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - yield return left; - yield return right; + left?.Visit(onVisit, onExit); + right?.Visit(onVisit, onExit); } } @@ -62,10 +62,9 @@ protected override string GetString() return $"ref {variableName}[{string.Join(",", rankExpressions.Select(x => x.ToString()))}]"; } - public override IEnumerable IterateChildNodes() + protected override void VisitChildren(Action onVisit, Action onExit) { - foreach (var rankExpr in rankExpressions) yield return rankExpr; - + foreach (var rankExpr in rankExpressions) rankExpr?.Visit(onVisit, onExit); } } @@ -107,10 +106,7 @@ protected override string GetString() return $"ref {variableName}"; } - public override IEnumerable IterateChildNodes() - { - yield break; // no children. - } + protected override void VisitChildren(Action onVisit, Action onExit) { } } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Ast/Visitors/HauntingVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/HauntingVisitor.cs index 3c7daa8..111146e 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/HauntingVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/HauntingVisitor.cs @@ -20,14 +20,16 @@ public static bool HasAnyGeneratedHauntedTokens(this IAstVisitable node) public static void AddHaunting(this ProgramNode program, ParseOptions options) { - CheckStatements(program.statements); + // TODO: do we need to check function statements? + foreach (var test in program.tests) + { + test.testProgram.AddHaunting(options); + } } public static void CheckStatements(IList statements) { - - for (var i = 0; i < statements.Count; i++) { var statement = statements[i]; diff --git a/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs index d1088c7..e85c107 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs @@ -43,6 +43,13 @@ static void ApplyStatements(List statements) } ApplyStatements(switchStatement.defaultCase?.statements); break; + case TestNode testStatement: + foreach (var func in testStatement.testProgram.functions) + { + ApplyStatements(func.statements); + } + ApplyStatements(testStatement.testProgram.statements); + break; } } @@ -138,6 +145,11 @@ public static void AddInitializerSugar(this ProgramNode node) { ApplyStatements(function.statements); } + + foreach (var test in node.tests) + { + test.testProgram.AddInitializerSugar(); + } } public static void FixNoopStatements(this ProgramNode node) diff --git a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs index 69189fc..ebd3205 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs @@ -8,7 +8,7 @@ namespace FadeBasic.Ast.Visitors public static partial class ErrorVisitors { - public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions options, Dictionary knownFunctionTypes=null) + public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions options, Dictionary knownFunctionTypes=null, ProgramNode parentProgram=null) { if (options?.ignoreChecks ?? false) { @@ -18,14 +18,117 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions return; } + var scope = program.scope = new Scope(); + // Plumb the program's CommandCollection onto the scope so visitors + // can look up command metadata (return type, args) without taking + // it as a parameter. Test sub-programs inherit from the parent. + scope.commands = program.commands ?? parentProgram?.commands; + + // Region name used to tag this program's top-level labels and to seed + // GetCurrentFunctionName() so EnsureLabel can detect cross-scope gotos + // between a test and its parent. Null for the outermost program (matches + // the existing "top-level = null" convention). + string topLevelRegion = parentProgram != null ? program.startToken?.raw : null; + if (parentProgram != null) + { + // We're scoping a test's sub-program. Mark the scope so test-only + // statements (assert/runto/mock/clear-mock) don't false-fire as + // "outside test" while we recurse. + scope.IsInsideTest = true; + + // Push the test's name as the current "function" context so + // GetCurrentFunctionName() returns the test region (not null) for + // the test's top-level statements. This makes EnsureLabel emit + // TraverseLabelBetweenScopes when test code does `goto mainLabel` + // (parent's top-level labels carry a null funcName tag). + scope.currentFunctionName.Push(topLevelRegion); + + // Layer in the parent program's scope as a baseline. Per the design, + // tests can read into parent (globals, types, functions, labels) but + // parent never reads into a test. We copy dictionary state here; the + // test's own pass below adds its locals on top. Stack-based state + // (currentFunctionName/Region, localVariables frames) intentionally + // stays separate — those are runtime-walk state, not symbol tables. + var parentScope = parentProgram.scope; + + foreach (var kvp in parentScope.labelTable) + scope.labelTable[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.labelDeclTable) + scope.labelDeclTable[kvp.Key] = kvp.Value; + + foreach (var kvp in parentScope.typeNameToTypeMembers) + scope.typeNameToTypeMembers[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.typeNameToDecl) + scope.typeNameToDecl[kvp.Key] = kvp.Value; + + // Globals (`global X`) — always visible to tests. + foreach (var kvp in parentScope.globalVariables) + scope.globalVariables[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.allGlobalVariables) + scope.allGlobalVariables[kvp.Key] = kvp.Value; + + // Parent top-level locals: variables declared at the program's main + // scope (including implicit-locals from bare assignments). The + // strict-scope visitor decides per-runto which of these the test + // can actually *see*; here we just make them resolvable so the + // basic scope check doesn't flag them as unknown. + if (parentScope.localVariables.Count > 0) + { + var parentTopLocals = parentScope.localVariables.Peek(); + var testTopLocals = scope.localVariables.Peek(); + foreach (var kvp in parentTopLocals) + { + testTopLocals[kvp.Key] = kvp.Value; + scope.borrowedFromParent.Add(kvp.Key); + } + } + + // Parent function-internal locals + parameters. Same rationale + // as above: without this, `runto :insideFn; print y` blows up + // with [0200] "unknown symbol y" before the strict visitor can + // rule on per-runto visibility. The strict visitor's + // ComputeFunctionInternalScopeAts already snapshots these + // names so it can enforce reachability per runto target. + // + // Name collisions across functions are resolved + // first-source-wins via the ContainsKey guard; type info on + // those rare cases may resolve to the "wrong" function, but + // visibility (the immediate goal) is unaffected. + { + var testTopLocals = scope.localVariables.Peek(); + foreach (var entry in parentScope.positionedVariables.entries) + { + var (fnTable, fnName) = entry.value; + if (fnName == null) continue; // top-level program, already copied + foreach (var kvp in fnTable) + { + if (!testTopLocals.ContainsKey(kvp.Key)) + { + testTopLocals[kvp.Key] = kvp.Value; + scope.borrowedFromParent.Add(kvp.Key); + } + } + } + } + + foreach (var kvp in parentScope.functionSymbolTable) + scope.functionSymbolTable[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.functionTable) + scope.functionTable[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.functionReturnTypeTable) + scope.functionReturnTypeTable[kvp.Key] = kvp.Value; + } // add the main program variables. scope.positionedVariables.Add(new TokenTable<(SymbolTable, string)>.Entry(program, (scope.localVariables.Peek(), null))); foreach (var label in program.labels) { - scope.AddLabel(null, label.node); + // Inside a test scope, tag this program's top-level labels with the + // test's region name (not null) so cross-scope gotos to/from main + // get caught by EnsureLabel's funcName comparison. + scope.AddLabel(topLevelRegion, label); } foreach (var type in program.typeDefinitions) @@ -46,8 +149,20 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } }); + var allFunctions = new List(); + allFunctions.AddRange(program.functions); + /* + * the general rule here is that a TEST can call into global parent scope. + * but parent scope can never call into TEST scope. + * - this is so that we can always safely remove tests from a production build + * - and so that the presence of the test never changes how the main code runs. + * + * In that sense- the scope error visiting is not so much about having specific + * support for test scoping; + * it is more about merging the parent scope as a baseline when starting to parse the test scope. + */ - foreach (var function in program.functions) + foreach (var function in allFunctions) { foreach (var label in function.labels) { @@ -55,7 +170,7 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } scope.DeclareFunction(function); } - + // CheckTypeInfo2(scope); CheckTypesForUnknownReferences(scope); CheckTypesForRecursiveReferences(scope, out var typeRefCounter); @@ -74,13 +189,20 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions { foreach (var kvp in knownFunctionTypes) { - scope.functionReturnTypeTable.Add(kvp.Key, new List{kvp.Value}); + // indexer rather than Add: parent-merged entries (in test + // sub-scopes) may already contain keys from knownFunctionTypes. + scope.functionReturnTypeTable[kvp.Key] = new List{kvp.Value}; } } + + // Inside a test sub-scope, push the test's region name so calls to + // test-internal functions (whose region equals the test's name) don't + // trip the "test function called from top-level" check at line ~904. + scope.currentRegionName.Push(parentProgram != null ? topLevelRegion : FunctionStatement.REGION_TOP_LEVEL); CheckStatements(program.statements, scope, globalCtx); - foreach (var function in program.functions) + foreach (var function in allFunctions) { if (scope.functionReturnTypeTable.ContainsKey(function.name)) { @@ -105,13 +227,14 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } - foreach (var function in program.functions) + foreach (var function in allFunctions) { if (scope.functionReturnTypeTable.ContainsKey(function.name)) continue; // already parsed. function.Errors.Add(new ParseError(function.startToken, ErrorCodes.UnknowableFunctionReturnType)); } + foreach (var def in scope.defaultValueExpressions) { if (def.ParsedType.type == VariableType.Void) @@ -121,8 +244,162 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } scope.DoDelayedTypeChecks(); + + // as the very last part of verifying the scope, + // we need to verify the child scopes, which at this point, are just tests + scope.currentRegionName.Pop(); // remove the top level region. + + // Flag duplicate test names (case-insensitive — matches the + // lookup semantics used by FindTestByName + the runner's + // manifest lookup). The first occurrence keeps the name; every + // later sibling with the same name gets an error pinned on its + // own name token. We don't drop them from validation — the + // user might want to fix one at a time, and downstream checks + // still produce useful errors for both bodies. + { + var seenTestNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var t in program.tests) + { + if (t.name == null) continue; + if (!seenTestNames.Add(t.name)) + { + Token dupTok = t.nameToken ?? t.StartToken; + t.Errors.Add(new ParseError(dupTok, + ErrorCodes.TestDuplicateName, t.name)); + } + } + } + // + // A test with `from ` must validate AFTER its parent so we can + // pass the parent's testProgram as the scope baseline — the same + // program→test copy logic above then folds parent's locals/functions + // into the child's fresh scope. Order tests Kahn-style by from-chain; + // anything still unordered after a full pass is in a cycle (the strict + // visitor will flag it) and falls back to the program baseline so the + // child still validates against globals and doesn't lose unrelated + // errors. + var orderedTests = OrderTestsByFromChain(program.tests); + foreach (var test in orderedTests) + { + // Tests cannot be nested inside another test. parentProgram != null + // means *we* are already a test sub-program, so any tests we contain + // are an invalid nesting. + if (parentProgram != null) + { + Token nestingTok = test.nameToken ?? test.StartToken; + test.Errors.Add(new ParseError(nestingTok, ErrorCodes.TestNestingNotAllowed)); + continue; + } + + // Default baseline = the outer program (program-level globals, + // labels, types, functions). If this test has a resolvable, + // already-validated parent test, use the parent's testProgram + // instead so child picks up parent's locals/functions on top + // of the program baseline (parent's own validation already + // folded the program-level state into its scope, so the + // baselines compose transitively). + ProgramNode baseline = program; + if (test.fromParent != null) + { + var parentTest = FindTestByName(program.tests, test.fromParent); + if (parentTest != null + && parentTest != test + && parentTest.testProgram.scope != null) + { + baseline = parentTest.testProgram; + } + } + test.testProgram.AddScopeRelatedErrors(options, knownFunctionTypes, baseline); + } + + // Strict scope_at(:L) enforcement runs after all test sub-scopes are built, + // and only on the outermost program — a test's own ProgramNode has no further + // tests to validate (nested tests already errored above). + if (parentProgram == null) + { + program.EnforceStrictTestScopes(); + } + } + + + // Locate a sibling test by name (case-insensitive). Used by the + // test-iteration loop to resolve `from ` references. Returns + // null when the parent name doesn't match any test — the strict + // visitor handles that with a clean TestFromParentUnknown error; + // here we just fall back to using the outer program as the baseline. + static TestNode FindTestByName(List tests, string name) + { + if (name == null || tests == null) return null; + foreach (var t in tests) + { + if (t.name != null + && string.Equals(t.name, name, StringComparison.OrdinalIgnoreCase)) + { + return t; + } + } + return null; } + // Order tests so each child appears after its `from`-parent. Anything + // unreachable (cycle members, tests whose chain hits an unknown name) + // appends at the end and gets validated against the outer program + // baseline — preserves error coverage without infinite-recursing. + // Kahn-style: repeatedly emit any test whose parent has been emitted + // (or whose parent is missing/null), until no progress is possible. + static List OrderTestsByFromChain(List tests) + { + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var t in tests) + { + if (t.name != null) byName[t.name] = t; + } + + var emitted = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(tests.Count); + var pending = new List(tests); + + bool TryEmit(TestNode t) + { + if (t.name == null) return false; + if (emitted.Contains(t.name)) return true; + // Tests with no parent, an unknown parent, or a parent that + // resolves to themselves can emit immediately — there's + // nothing to wait for in the inheritance chain. + if (t.fromParent == null + || !byName.TryGetValue(t.fromParent, out var parent) + || parent == t) + { + result.Add(t); + emitted.Add(t.name); + return true; + } + if (!emitted.Contains(parent.name)) return false; + result.Add(t); + emitted.Add(t.name); + return true; + } + + var madeProgress = true; + while (pending.Count > 0 && madeProgress) + { + madeProgress = false; + for (var i = pending.Count - 1; i >= 0; i--) + { + if (TryEmit(pending[i])) + { + pending.RemoveAt(i); + madeProgress = true; + } + } + } + // Anything still pending is in a cycle. Append them in source + // order with no parent-state inheritance available — they'll + // validate against the program baseline. The strict visitor + // has already flagged the cycle. + foreach (var t in pending) result.Add(t); + return result; + } static void CheckTypesForUnknownReferences(Scope scope) { @@ -473,6 +750,58 @@ static void CheckStatements(this List statements, Scope scope, E case TypeDefinitionStatement invalidTypeStatement: invalidTypeStatement.Errors.Add(new ParseError(invalidTypeStatement.name, ErrorCodes.TypeMustBeTopLevel)); break; + case AssertStatement assertStatement: + // `assert` is legal anywhere. Inside a test, strict-scope + // enforcement is handled by TestScopeStrictnessVisitor. + // Here we resolve symbols in the condition + reason so + // general "unknown symbol" errors still surface. + if (assertStatement.condition != null) + { + assertStatement.condition.EnsureVariablesAreDefined(scope, ctx); + } + if (assertStatement.reason != null) + { + assertStatement.reason.EnsureVariablesAreDefined(scope, ctx); + if (assertStatement.reason.ParsedType.type != VariableType.String + && !assertStatement.reason.ParsedType.unset) + { + assertStatement.Errors.Add(new ParseError(assertStatement.reason, ErrorCodes.AssertReasonMustBeString)); + } + } + break; + case RuntoStatement runtoStatement: + // Runto target validation happens in the TestScopeStrictnessVisitor. + // Here we just resolve the target label's symbol so the + // LSP can offer go-to-definition + find-references on + // `runto labelName` sites. + if (!scope.IsInsideTest) + { + runtoStatement.Errors.Add(new ParseError(runtoStatement.StartToken, ErrorCodes.RuntoOutsideTest)); + } + if (runtoStatement.targetLabel != null + && scope.TryGetLabel(runtoStatement.targetLabel, out var runtoLabelSymbol)) + { + runtoStatement.DeclaredFromSymbol = runtoLabelSymbol; + } + if (runtoStatement.maxCyclesExpression != null) + { + runtoStatement.maxCyclesExpression.EnsureVariablesAreDefined(scope, ctx); + } + break; + case MockStatement mockStatement: + if (!scope.IsInsideTest) + { + mockStatement.Errors.Add(new ParseError(mockStatement.StartToken, ErrorCodes.MockOutsideTest)); + } + ValidateMockStatement(mockStatement, scope, ctx); + break; + case ClearMockStatement clearMockStatement: + if (!scope.IsInsideTest) + { + clearMockStatement.Errors.Add(new ParseError(clearMockStatement.StartToken, ErrorCodes.ClearMockOutsideTest)); + } + ValidateClearMockStatement(clearMockStatement); + break; default: throw new NotImplementedException($"cannot check statement for scope errors - {statement.GetType().Name} {statement}"); // break; @@ -480,6 +809,538 @@ static void CheckStatements(this List statements, Scope scope, E } } + // mock and clear-mock validation. Command-existence is enforced by the + // lexer's CommandNameTree pass (an unknown command name doesn't + // tokenize as CommandWord, so the parser already errors). Here we: + // - Walk body expressions for unknown-symbol errors. + // - Enforce body structure: at most one `returns`, at most one + // `forbid`, never both. + // - Type-check the `forbid` reason expression (string). + // - Validate the `returns` expression against the command's + // declared return type. Multi-overload commands must accept the + // same expression for every overload, so we intersect: if any + // overload would reject the expression, error. + static void ValidateMockStatement(MockStatement mock, Scope scope, EnsureTypeContext ctx) + { + MockExitMockStatement seenReturns = null; + MockForbidStatement seenForbid = null; + + // Collect structure-validation findings up front (multiple returns, + // multiple forbids, returns+forbid). We still need to walk the + // body with the full visitor so locals/ifs/etc. type-check, but + // we can detect these duplicates by scanning the body shallowly. + foreach (var stmt in mock.body) + { + switch (stmt) + { + case MockExitMockStatement rs: + if (seenReturns != null) + { + rs.Errors.Add(new ParseError(rs.StartToken, ErrorCodes.MockMultipleReturns)); + } + seenReturns = rs; + break; + + case MockForbidStatement fs: + if (seenForbid != null) + { + fs.Errors.Add(new ParseError(fs.StartToken, ErrorCodes.MockMultipleForbid)); + } + seenForbid = fs; + if (fs.reason != null + && fs.reason.ParsedType.type != VariableType.String + && !fs.reason.ParsedType.unset) + { + // Type check happens after we visit the body + // (so the reason expression's ParsedType is set). + // We re-check after CheckStatements below. + } + break; + } + } + + // Push a body scope. Parameters become locals with types derived + // from the command's arg metadata. This mirrors BeginFunction: + // the body's local symbol table is independent of the test's, + // and `local` declarations inside the body add to it. + // + // Pick the overload that MATCHES the user's named param count. + // Falling back to overloads[0] would mis-type params and skip + // the ref-assignment check when overloads have different arg + // counts (e.g. `input(ref string)` vs `input(string, ref string)`). + CommandInfo? bodyOverload = null; + if (scope.commands != null + && mock.commandName != null + && scope.commands.Lookup.TryGetValue(mock.commandName, out var bodyOverloads) + && bodyOverloads.Count > 0) + { + if (mock.parameters.Count == 0) + { + bodyOverload = bodyOverloads[0]; + } + else + { + foreach (var ov in bodyOverloads) + { + var ovArgs = ov.args ?? System.Array.Empty(); + var realCount = 0; + foreach (var a in ovArgs) if (!a.isVmArg) realCount++; + if (realCount == mock.parameters.Count) + { + bodyOverload = ov; + break; + } + } + } + } + + var bodyTable = new SymbolTable(); + scope.localVariables.Push(bodyTable); + if (bodyOverload.HasValue && mock.parameters.Count > 0) + { + var args = bodyOverload.Value.args ?? System.Array.Empty(); + var realArgIndices = new List(); + for (var ai = 0; ai < args.Length; ai++) + { + if (!args[ai].isVmArg) realArgIndices.Add(ai); + } + for (var pi = 0; pi < mock.parameters.Count && pi < realArgIndices.Count; pi++) + { + var p = mock.parameters[pi]; + var argDesc = args[realArgIndices[pi]]; + var typeCode = argDesc.typeCode; + if (argDesc.isParams && typeCode == TypeCodes.ANY) + { + // `params object[]` — TypeCodes.ANY has no Fade + // variable-type mapping, and the body's gathered + // array would need per-element type storage that + // the current array model doesn't support. Surface + // a clean error here instead of letting the compiler + // crash on SIZE_TABLE[ANY]. The user can still mock + // the command; they just can't reference the args. + var cmdName = mock.commandName ?? ""; + var detail = + $"`{p.variableName}` is bound to the `params object[]` parameter of `{cmdName}`. " + + $"That parameter accepts a mix of element types at runtime, so there's no single Fade " + + $"element type the body's array could have. Rewrite as `mock {cmdName}` (no parameter name) " + + $"to install the mock without naming the args."; + p.Errors.Add(new ParseError(p, + ErrorCodes.MockParamsObjectArrayUnnamable, detail)); + } + else if (VmUtil.TryGetVariableType(typeCode, out var varType)) + { + // A params arg is bound as a rank-1 array of the + // element type so the body can `len(p)` and `p(i)`. + var paramTypeInfo = argDesc.isParams + ? TypeInfo.FromVariableType(varType, new IExpressionNode[1]) + : TypeInfo.FromVariableType(varType); + bodyTable.Add(p.variableName, new Symbol + { + text = p.variableName, + typeInfo = paramTypeInfo, + source = p + }); + } + } + } + + // Set active-mock context so any PassthroughExpression we + // encounter in the body knows what return type to wear and + // doesn't trip its outside-mock-body error. + var prevInsideMock = ctx.insideMockBody; + var prevMockReturnTc = ctx.activeMockReturnTypeCode; + var prevMockReturnInfo = ctx.activeMockReturnTypeInfo; + var prevMockArgInfos = ctx.activeMockArgInfos; + var prevMockBoundRefs = ctx.activeMockBoundRefParamNames; + ctx.insideMockBody = true; + ctx.activeMockReturnTypeCode = bodyOverload?.returnType ?? TypeCodes.VOID; + if (bodyOverload.HasValue + && bodyOverload.Value.returnType != TypeCodes.VOID + && VmUtil.TryGetVariableType(bodyOverload.Value.returnType, out var mockRetVarType)) + { + ctx.activeMockReturnTypeInfo = TypeInfo.FromVariableType(mockRetVarType); + } + else + { + ctx.activeMockReturnTypeInfo = TypeInfo.Void; + } + // Build the real-arg list (in declaration order) and the + // set of names bound to a ref param. PassthroughExpression's + // validator reads these. + if (bodyOverload.HasValue) + { + var ovArgs = bodyOverload.Value.args ?? System.Array.Empty(); + var realArgsOrdered = new List(); + var boundRefs = new HashSet(StringComparer.OrdinalIgnoreCase); + var paramIdx = 0; + for (var ai = 0; ai < ovArgs.Length; ai++) + { + if (ovArgs[ai].isVmArg) continue; + realArgsOrdered.Add(ovArgs[ai]); + if (ovArgs[ai].isRef && paramIdx < mock.parameters.Count) + { + boundRefs.Add(mock.parameters[paramIdx].variableName); + } + paramIdx++; + } + ctx.activeMockArgInfos = realArgsOrdered.ToArray(); + ctx.activeMockBoundRefParamNames = boundRefs; + } + else + { + ctx.activeMockArgInfos = System.Array.Empty(); + ctx.activeMockBoundRefParamNames = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + // Walk the body with the standard statement checker so locals, + // ifs, expression statements, asserts, etc. all get proper + // type-checking and symbol resolution. The dedicated + // MockExitMockStatement / MockForbidStatement / MockRefStatement + // cases below resolve their internal expressions; the generic + // dispatch handles the rest. + foreach (var stmt in mock.body) + { + switch (stmt) + { + case MockExitMockStatement rs: + if (rs.expression != null) + { + rs.expression.EnsureVariablesAreDefined(scope, ctx); + } + break; + case MockForbidStatement fs: + if (fs.reason != null) + { + fs.reason.EnsureVariablesAreDefined(scope, ctx); + if (fs.reason.ParsedType.type != VariableType.String + && !fs.reason.ParsedType.unset) + { + fs.Errors.Add(new ParseError(fs.reason, ErrorCodes.MockForbidReasonMustBeString)); + } + } + break; + default: + // Send single-statement lists through CheckStatements + // so it shares all the path-aware infrastructure + // (loops, defers, declarations, etc.). + var oneShot = new List { stmt }; + oneShot.CheckStatements(scope, ctx); + break; + } + } + + // `runto` is a test-flow primitive — it switches execution + // between the test body and main-program code. It has no + // sensible meaning inside a mock body (which is run when a + // command is invoked, not when the test is navigating). Catch + // any occurrence anywhere in the body tree, not just top-level, + // so wrapping in `if`/`while` doesn't sneak past the check. + foreach (var stmt in mock.body) + { + stmt.Visit(node => + { + if (node is RuntoStatement runtoNode) + { + runtoNode.Errors.Add(new ParseError(runtoNode.StartToken, + ErrorCodes.RuntoInsideMockBody)); + } + }); + } + + // Validate self-recursive calls to the mocked command — these + // get rewritten to CALL_HOST_REAL with a scope swap, so any + // ref arg's address must point into the caller's scope. The + // only body-level names that satisfy that are the mock's own + // bound ref params (their hidden ptr targets the caller). + // Any other expression at a ref slot is a hard error. + if (mock.commandName != null + && ctx.activeMockBoundRefParamNames != null) + { + var boundRefNames = ctx.activeMockBoundRefParamNames; + foreach (var stmt in mock.body) + { + stmt.Visit(node => + { + if (node is CommandStatement cs + && cs.command.name != null + && string.Equals(cs.command.name, mock.commandName, + StringComparison.OrdinalIgnoreCase)) + { + ValidateSelfRecursiveRefArgs(cs.command, cs.args, + cs.argMap, boundRefNames); + } + else if (node is CommandExpression ce + && ce.command.name != null + && string.Equals(ce.command.name, mock.commandName, + StringComparison.OrdinalIgnoreCase)) + { + ValidateSelfRecursiveRefArgs(ce.command, ce.args, + ce.argMap, boundRefNames); + } + }); + } + } + + // Resolve symbols + type on `endmock ` while the body + // scope (with params) is still pushed. + if (mock.endmockExpression != null) + { + mock.endmockExpression.EnsureVariablesAreDefined(scope, ctx); + } + + // Strict ref-arg validation: every ref param must have at least + // one top-level assignment in the body. Otherwise the caller's + // variable is left in an undefined state when the mock runs. + // `forbid` short-circuits this — the test halts before the + // caller observes anything, so no writes are needed. + if (bodyOverload.HasValue && seenForbid == null && mock.parameters.Count > 0) + { + var args = bodyOverload.Value.args ?? System.Array.Empty(); + var realArgIndices = new List(); + for (var ai = 0; ai < args.Length; ai++) + { + if (!args[ai].isVmArg) realArgIndices.Add(ai); + } + + // A self-recursive call to the mocked command inside the + // body invokes the real host, which writes through every + // ref it's passed. If the body contains such a call (at + // any nesting level) we treat every ref param as assigned + // — the user delegated the writes to the real host. The + // compiler still enforces, per call, that each ref arg + // names a bound ref param (MockBodyRefArgMustBeBoundRefParam). + var hasSelfCall = false; + var mockedName = mock.commandName; + if (mockedName != null) + { + foreach (var stmt in mock.body) + { + stmt.Visit(node => + { + if (node is CommandStatement cs + && cs.command.name != null + && string.Equals(cs.command.name, mockedName, + StringComparison.OrdinalIgnoreCase)) + { + hasSelfCall = true; + } + if (node is CommandExpression ce + && ce.command.name != null + && string.Equals(ce.command.name, mockedName, + StringComparison.OrdinalIgnoreCase)) + { + hasSelfCall = true; + } + }); + if (hasSelfCall) break; + } + } + + for (var pi = 0; pi < mock.parameters.Count && pi < realArgIndices.Count; pi++) + { + if (!args[realArgIndices[pi]].isRef) continue; + if (hasSelfCall) continue; + var paramName = mock.parameters[pi].variableName; + var assigned = false; + foreach (var stmt in mock.body) + { + if (stmt is AssignmentStatement asn + && asn.variable is VariableRefNode lhs + && string.Equals(lhs.variableName, paramName, + StringComparison.OrdinalIgnoreCase)) + { + assigned = true; + break; + } + } + if (!assigned) + { + mock.parameters[pi].Errors.Add(new ParseError(mock.parameters[pi], + ErrorCodes.MockRefParamNotAssigned)); + } + } + } + + scope.localVariables.Pop(); + + // Restore the outer ctx now that we're done walking this + // mock body. Nested mocks aren't legal (mock is block-only at + // the top level of a test), but this still keeps the stack + // discipline tidy in case a future change allows them. + ctx.insideMockBody = prevInsideMock; + ctx.activeMockReturnTypeCode = prevMockReturnTc; + ctx.activeMockReturnTypeInfo = prevMockReturnInfo; + ctx.activeMockArgInfos = prevMockArgInfos; + ctx.activeMockBoundRefParamNames = prevMockBoundRefs; + + if (seenReturns != null && seenForbid != null) + { + // `returns` + `forbid` in the same body is nonsensical — the + // forbid prevents the return path from being reached. + seenForbid.Errors.Add(new ParseError(seenForbid.StartToken, ErrorCodes.MockReturnsAndForbid)); + } + + // Look up the command in the scope's CommandCollection to validate + // `returns` against the command's declared return type. We need + // ALL overloads — a mock applies to every overload of the same + // name, so the returns expression must satisfy every one of them. + // + // Type compatibility uses EnforceTypeAssignment so the same numeric + // coercion rules that apply to `local n as long = 5` apply here: + // an int literal is fine as a `returns` value for a long-returning + // command, etc. Anything else surfaces an InvalidCast/InvalidType + // error on the expression — we then translate the first such error + // into a clearer MockReturnsTypeMismatch and stop. + if (scope.commands != null && mock.commandName != null + && scope.commands.Lookup.TryGetValue(mock.commandName, out var overloads) + && overloads.Count > 0) + { + // When the user names params, at least one overload must + // have a matching non-VmArg arg count. Otherwise the mock + // can't bind cleanly and the compiler will refuse to emit + // any body (silent no-op without an error). + if (mock.parameters.Count > 0) + { + var hasMatchingOverload = false; + foreach (var ov in overloads) + { + var ovArgs = ov.args ?? System.Array.Empty(); + var realCount = 0; + foreach (var a in ovArgs) if (!a.isVmArg) realCount++; + if (realCount == mock.parameters.Count) { hasMatchingOverload = true; break; } + } + if (!hasMatchingOverload) + { + mock.Errors.Add(new ParseError( + mock.commandNameToken ?? mock.StartToken, + ErrorCodes.MockParamCountNoMatchingOverload)); + } + } + + // Strict body validation: a value-returning command's mock + // body must produce a return value via one of three paths: + // - exitmock somewhere in the body (top level) + // - endmock as the closing form (fall-through) + // - forbid (the test halts before the caller observes the + // missing return) + // Without one of these the caller pops a return value that + // was never pushed → stack corruption at the call site. + if (seenReturns == null && seenForbid == null && mock.endmockExpression == null) + { + var anyValueReturning = false; + foreach (var ov in overloads) + { + if (ov.returnType != TypeCodes.VOID) { anyValueReturning = true; break; } + } + if (anyValueReturning) + { + mock.Errors.Add(new ParseError(mock.StartToken ?? mock.commandNameToken, + ErrorCodes.MockValueCommandMissingReturns)); + } + } + + // Return-type checks against each overload. Apply to both + // `exitmock ` (seenReturns) and `endmock ` + // (mock.endmockExpression). Each one must satisfy the + // command's return type or — if the command is void — not + // appear at all. + CheckMockReturnAgainstOverloads(seenReturns?.expression, + seenReturns?.StartToken, overloads, scope); + CheckMockReturnAgainstOverloads(mock.endmockExpression, + mock.endmockExpression?.StartToken, overloads, scope); + } + } + + // For a self-recursive call inside a mock body, each ref-position + // user arg must be a VariableRefNode naming one of the mock's + // bound ref params. Anything else would push a pointer that the + // CALL_HOST_REAL scope swap can't make sense of (a body-local + // address means "register N in body scope"; after the swap that + // address indexes the wrong scope and would clobber unrelated + // data). Skip non-ref slots — they're plain expressions and the + // standard command-arg checks cover them. + static void ValidateSelfRecursiveRefArgs(CommandInfo command, + List args, List argMap, + HashSet boundRefNames) + { + if (command.args == null) return; + var argCounter = 0; + for (var i = 0; i < command.args.Length; i++) + { + if (command.args[i].isVmArg) continue; + if (command.args[i].isParams) break; + if (argCounter >= args.Count) break; + if (command.args[i].isRef) + { + var userExpr = args[argCounter]; + if (!(userExpr is VariableRefNode vn) + || boundRefNames == null + || !boundRefNames.Contains(vn.variableName)) + { + userExpr.Errors.Add(new ParseError(userExpr, + ErrorCodes.MockBodyRefArgMustBeBoundRefParam)); + } + } + argCounter++; + } + } + + // Helper: validate a single return-expression (from exitmock or + // endmock) against every overload of the mocked command. Adds the + // appropriate error to the expression node on the first mismatch. + static void CheckMockReturnAgainstOverloads( + IExpressionNode returnExpr, Token reportToken, + List overloads, Scope scope) + { + if (returnExpr == null) return; + foreach (var overload in overloads) + { + if (overload.returnType == TypeCodes.VOID) + { + returnExpr.Errors.Add(new ParseError(reportToken ?? returnExpr.StartToken, + ErrorCodes.MockReturnsOnVoidCommand)); + return; + } + if (returnExpr.ParsedType.unset) continue; + if (!TypeInfo.TryGetFromTypeCode(overload.returnType, out var expectedType)) continue; + + var probe = new ProbeNode(); + scope.EnforceTypeAssignment(probe, + returnExpr.ParsedType, expectedType, + softLeft: false, out _); + if (probe.Errors.Count > 0) + { + returnExpr.Errors.Add(new ParseError(returnExpr, + ErrorCodes.MockReturnsTypeMismatch)); + return; + } + } + } + + // Throwaway IAstNode used to capture errors from EnforceTypeAssignment + // without polluting a real source node. EnforceTypeAssignment adds + // ParseErrors to whatever node is passed in; we want to test assignment + // legality without committing those errors to the user's expression. + sealed class ProbeNode : IAstNode + { + private readonly List _errors = new List(); + public List Errors => _errors; + public bool HasErrors => _errors.Count > 0; + public Token StartToken => null; + public Token EndToken => null; + public TypeInfo ParsedType => TypeInfo.Unset; + public TransitiveTypeFlags TransitiveFlags { get; set; } + public Symbol DeclaredFromSymbol { get; set; } + } + + static void ValidateClearMockStatement(ClearMockStatement clear) + { + // Nothing to validate at the scope level — the parser already + // checked for `mock ` / `mocks` shape, and the command name + // (if present) was a CommandWord token (so it's known to the + // command collection). + } + // static void TryGetSymbolTable(this StructFieldReference) static void EnsureLabel(Scope scope, string label, AstNode node) { @@ -709,6 +1570,13 @@ public static void EnsureVariablesAreDefined(this IExpressionNode expr, Scope sc { if (scope.functionTable.TryGetValue(arrayRef.variableName, out var function)) { + + // if the function is not a top level, and the current scope IS top level; then we have an issue. + if (function.region != FunctionStatement.REGION_TOP_LEVEL && scope.currentRegionName.Peek() == FunctionStatement.REGION_TOP_LEVEL) + { + arrayRef.Errors.Add(new ParseError(arrayRef.startToken, ErrorCodes.CannotCallTestFunctionFromOutsideTest)); + } + TypeInfo functionType = default; arrayRef.TransitiveFlags |= function.TransitiveFlags; if (ctx.functionHistory.Contains(function.name)) @@ -867,6 +1735,30 @@ public static void EnsureVariablesAreDefined(this IExpressionNode expr, Scope sc case LiteralRealExpression literalReal: literalReal.ParsedType = TypeInfo.Real; break; + case CallCountExpression callCountExpr: + // `call count ` always evaluates to an int. The + // command name was already validated by the lexer's + // CommandNameTree pass; nothing further to do. + callCountExpr.ParsedType = TypeInfo.Int; + break; + case LenExpression lenExpr: + // `len(...)` always evaluates to an int. Resolve inner + // expression first so its ParsedType is set, then + // validate it's array- or string-typed. + lenExpr.ParsedType = TypeInfo.Int; + if (lenExpr.inner != null) + { + lenExpr.inner.EnsureVariablesAreDefined(scope, ctx); + var innerType = lenExpr.inner.ParsedType; + if (!innerType.unset + && !innerType.IsArray + && innerType.type != VariableType.String) + { + lenExpr.inner.Errors.Add(new ParseError(lenExpr.inner, + ErrorCodes.LenInvalidType)); + } + } + break; default: break; } @@ -879,6 +1771,28 @@ public class EnsureTypeContext public HashSet functionHistory = new HashSet(); public bool HasLoop { get; private set; } + // Set while walking the body of a `mock` statement. Drives: + // - PassthroughExpression validation: passthrough outside a mock + // body is an error. + // - PassthroughExpression ParsedType: set to the active mock + // command's return type so callers like `r = passthrough` get + // the right type info. + public bool insideMockBody; + public byte activeMockReturnTypeCode; + public TypeInfo activeMockReturnTypeInfo; + + // Active mock command's full (non-VmArg) arg metadata, in + // declaration order. PassthroughExpression uses this to validate + // explicit `passthrough(...)` arg count + per-position kind + // (value / ref / params). + public CommandArgInfo[] activeMockArgInfos; + + // Names of the mock's bound REF parameters. A ref argument in + // `passthrough(...)` must name one of these — otherwise we'd + // hand the real command a ptr that doesn't actually target the + // caller's scope and the writeback would land in the wrong place. + public HashSet activeMockBoundRefParamNames; + public EnsureTypeContext WithFunction(FunctionStatement function) { var names = new HashSet(functionHistory); diff --git a/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs new file mode 100644 index 0000000..6a51b88 --- /dev/null +++ b/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs @@ -0,0 +1,683 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FadeBasic.Ast.Visitors +{ + /// + /// Enforces the strict scope_at(:L) semantics from TEST_DESIGN.md §5. + /// A test body sees only: + /// + /// Test-locals (declared via local in the test). + /// Test-functions (declared via function inside the test). + /// Always-visible globals (declared via global X at top level). + /// Names declared by program top-level execution up to the most recent runto target. + /// + /// References to program-declared names that are not visible at the + /// current point are flagged with TestVariableUnreachable (no runto has + /// reached the declaration yet) or TestVariableNotYetDeclared (the + /// runto target is earlier than the declaration). + /// + public static class TestScopeStrictnessVisitor + { + public static void EnforceStrictTestScopes(this ProgramNode program) + { + var scopeAt = ComputeTopLevelScopeAt(program, out var globalNames, out var allTopLevelNames); + + // Extend scope_at to cover function-internal labels too. + // For a label inside a function, the visible names are: + // globals + function params + function locals declared up to that label + // (Main-body names visible at the function's callsites are NOT included + // here — that pulls in callsite-intersection analysis which we defer. + // Users who want test-visibility for shared variables should use `global`.) + ComputeFunctionInternalScopeAts(program, globalNames, scopeAt, allTopLevelNames); + + // Build a lookup from test name to TestNode (case-insensitive). + // Used to resolve `from ` and to walk the from-chain when + // computing scope inheritance. + var testsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var test in program.tests) + { + if (test.name != null) + { + testsByName[test.name] = test; + } + } + + // Flag unknown-parent and cycle errors up front. A test in a + // broken chain still gets its body validated (so the user can + // see all relevant errors at once), but with NO parent state + // seeded — otherwise we'd risk infinite walks or stale data. + var inBrokenChain = DetectFromChainErrors(program.tests, testsByName); + + // Topological order by from-chain: parents always validate + // before children. Tests not in a chain order as-encountered. + // We compute each test's end-state (visible set, last runto + // target, test-locals, test-functions) during ValidateTest so + // descendants can pick it up. + var endStates = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ordered = TopologicalOrderByFromChain(program.tests, testsByName, inBrokenChain); + + foreach (var test in ordered) + { + TestEndState parentState = null; + if (test.fromParent != null + && !inBrokenChain.Contains(test.name ?? "") + && endStates.TryGetValue(test.fromParent, out var foundParentState)) + { + parentState = foundParentState; + } + + var endState = ValidateTest(test, scopeAt, globalNames, + allTopLevelNames, parentState); + if (test.name != null) + { + endStates[test.name] = endState; + } + } + } + + // Snapshot of a test's final scope state at the end of its body — + // what a child should see as its starting visibility/locals when it + // inherits via `from`. Test-locals and test-functions piggyback + // because runtime register sharing already makes them physically + // present in the child's run; we just need the visitor to know + // they're visible to keep static checks aligned. + private sealed class TestEndState + { + public HashSet Visible; + public string LastRuntoTarget; + public HashSet TestLocals; + public HashSet TestFunctions; + } + + // Walk the from-chain graph once: report unknown parents and + // cycles. Return the set of test names that are in a broken chain + // so the per-test validator can skip parent-state inheritance for + // them. We still validate their bodies so the user gets a complete + // error picture in one pass. + private static HashSet DetectFromChainErrors( + List tests, + Dictionary testsByName) + { + var broken = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var test in tests) + { + if (test.fromParent == null) continue; + if (test.name == null) continue; + + // Unknown parent — single, decisive error. + if (!testsByName.ContainsKey(test.fromParent)) + { + test.Errors.Add(new ParseError( + test.fromParentToken ?? test.startToken, + ErrorCodes.TestFromParentUnknown, test.fromParent)); + broken.Add(test.name); + continue; + } + + // Cycle check: walk up the chain from this test, marking + // visited names. Re-encountering one means a cycle includes + // this test (or an ancestor of it). + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var cursor = test; + while (cursor != null) + { + if (cursor.name == null) break; + if (!visited.Add(cursor.name)) + { + // Cycle — flag every test we passed through. Pin + // the error on the test that triggered the walk + // (the user-facing one) so the message is local. + test.Errors.Add(new ParseError( + test.fromParentToken ?? test.startToken, + ErrorCodes.TestFromParentCycle, test.fromParent)); + broken.Add(test.name); + break; + } + if (cursor.fromParent == null) break; + if (!testsByName.TryGetValue(cursor.fromParent, out var nextCursor)) + { + break; // unknown parent handled above for the originator + } + cursor = nextCursor; + } + } + return broken; + } + + // Kahn-style topological sort by from-chain. Tests with no parent + // (or with a broken chain) come first. Children come after their + // parents. Cycle members appear in `broken` — they're placed at + // the end with no parent-state inheritance to avoid infinite walks. + private static List TopologicalOrderByFromChain( + List tests, + Dictionary testsByName, + HashSet broken) + { + var result = new List(tests.Count); + var emitted = new HashSet(StringComparer.OrdinalIgnoreCase); + + bool TryEmit(TestNode t) + { + if (t.name == null) return false; + if (emitted.Contains(t.name)) return true; + if (t.fromParent == null + || broken.Contains(t.name) + || !testsByName.TryGetValue(t.fromParent, out var parent)) + { + result.Add(t); + emitted.Add(t.name); + return true; + } + if (!emitted.Contains(parent.name)) return false; + result.Add(t); + emitted.Add(t.name); + return true; + } + + // Iterate until no progress — at most O(tests^2), trivial for + // realistic test counts. + var pending = new List(tests); + var madeProgress = true; + while (pending.Count > 0 && madeProgress) + { + madeProgress = false; + for (var i = pending.Count - 1; i >= 0; i--) + { + if (TryEmit(pending[i])) + { + pending.RemoveAt(i); + madeProgress = true; + } + } + } + // Anything still pending is in a chain with a cycle we already + // flagged — append without parent-state inheritance. + foreach (var t in pending) + { + if (t.name != null && !emitted.Contains(t.name)) + { + result.Add(t); + emitted.Add(t.name); + } + } + return result; + } + + private static void ComputeFunctionInternalScopeAts( + ProgramNode program, + HashSet globalNames, + Dictionary> scopeAt, + HashSet allTopLevelNames) + { + foreach (var fn in program.functions) + { + var fnState = new HashSet(globalNames, StringComparer.OrdinalIgnoreCase); + if (fn.parameters != null) + { + foreach (var param in fn.parameters) + { + if (param.variable != null) + { + fnState.Add(param.variable.variableName); + allTopLevelNames.Add(param.variable.variableName); + } + } + } + WalkStatements(fn.statements, fnState, scopeAt); + // Add function-local names to allTopLevelNames so the test validator + // can distinguish "declared but not visible from this runto" vs + // "doesn't exist at all". + foreach (var n in fnState) allTopLevelNames.Add(n); + } + } + + // Walk program top-level statements once, accumulating declared names in + // source order. At each label declaration, snapshot the current set. + // Globals (`global X = ...`) are present from the start; bare top-level + // assignments (`x = 5`) get added when their statement is reached. + // Branch rule: both arms of `if/else` contribute their names at the merge + // point (matches Fade's existing semantics). + private static Dictionary> ComputeTopLevelScopeAt( + ProgramNode program, + out HashSet globalNames, + out HashSet allTopLevelNames) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var globals = new HashSet(StringComparer.OrdinalIgnoreCase); + + // First pass: collect globals (always visible). + program.Visit(node => + { + if (node is DeclarationStatement decl + && decl.scopeType == DeclarationScopeType.Global + && IsAtTopLevel(node, program)) + { + globals.Add(decl.variable); + } + }); + + var current = new HashSet(globals, StringComparer.OrdinalIgnoreCase); + WalkStatements(program.statements, current, result); + + globalNames = globals; + allTopLevelNames = new HashSet(current, StringComparer.OrdinalIgnoreCase); + return result; + } + + private static bool IsAtTopLevel(IAstNode node, ProgramNode program) + { + // Heuristic: a node is "top-level" if it appears in program.statements, + // not inside any function body. The Visit method walks recursively, so + // we need a cheap check. For simplicity, use the start token's depth in + // function bodies — if any function contains the node, it's not top-level. + foreach (var fn in program.functions) + { + foreach (var stmt in fn.statements) + { + if (ReferenceEquals(stmt, node)) return false; + if (stmt is IAstVisitable visitable) + { + var found = visitable.FindFirst(n => ReferenceEquals(n, node)); + if (found != null) return false; + } + } + } + return true; + } + + private static void WalkStatements( + IEnumerable stmts, + HashSet current, + Dictionary> result) + { + foreach (var stmt in stmts) + { + switch (stmt) + { + case LabelDeclarationNode label: + // Snapshot the visible-names set at this label's position. + result[label.label] = new HashSet(current, StringComparer.OrdinalIgnoreCase); + break; + + case DeclarationStatement decl: + current.Add(decl.variable); + break; + + case AssignmentStatement asn when asn.variable is VariableRefNode vref: + current.Add(vref.variableName); + break; + + case CommandStatement cmd: + // Ref-args at top level introduce variables — the base + // scope checker registers them via Scope.AddCommand -> + // TryAddVariable. Mirror that here so the strict + // test-scope check knows the binding exists. + if (cmd.command.args != null && cmd.argMap != null) + { + for (var i = 0; i < cmd.args.Count && i < cmd.argMap.Count; i++) + { + var descIdx = cmd.argMap[i]; + if (descIdx < 0 || descIdx >= cmd.command.args.Length) continue; + if (cmd.command.args[descIdx].isRef + && cmd.args[i] is VariableRefNode refV) + { + current.Add(refV.variableName); + } + } + } + break; + + case ForStatement forStmt: + if (forStmt.variableNode is VariableRefNode forVar) + { + current.Add(forVar.variableName); + } + WalkStatements(forStmt.statements, current, result); + break; + + case WhileStatement whileStmt: + WalkStatements(whileStmt.statements, current, result); + break; + + case DoLoopStatement doStmt: + WalkStatements(doStmt.statements, current, result); + break; + + case RepeatUntilStatement repeatStmt: + WalkStatements(repeatStmt.statements, current, result); + break; + + case IfStatement ifStmt: + // Both branches contribute names — Fade's existing + // branch-merge semantics. + if (ifStmt.positiveStatements != null) + { + WalkStatements(ifStmt.positiveStatements, current, result); + } + if (ifStmt.negativeStatements != null) + { + WalkStatements(ifStmt.negativeStatements, current, result); + } + break; + + case SwitchStatement switchStmt: + if (switchStmt.cases != null) + { + foreach (var c in switchStmt.cases) + { + if (c.statements != null) WalkStatements(c.statements, current, result); + } + } + if (switchStmt.defaultCase?.statements != null) + { + WalkStatements(switchStmt.defaultCase.statements, current, result); + } + break; + } + } + } + + private static TestEndState ValidateTest( + TestNode test, + Dictionary> scopeAt, + HashSet globalNames, + HashSet allTopLevelNames, + TestEndState parentState) + { + var testProgram = test.testProgram; + + // Seed test-locals and test-functions from the parent so the + // child's body can reference names the parent declared. The + // runtime makes them physically available (shared compile/run + // scope + GOSUB launcher), and we mirror that visibility here. + var testLocals = parentState != null + ? new HashSet(parentState.TestLocals, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + var testFunctions = parentState != null + ? new HashSet(parentState.TestFunctions, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var fn in testProgram.functions) + { + testFunctions.Add(fn.name); + } + + // Visible program-scope names. Inherited from parent when + // available so any names the parent unlocked via runto are + // already in view at the child's first statement. + var visible = parentState != null + ? new HashSet(parentState.Visible, StringComparer.OrdinalIgnoreCase) + : new HashSet(globalNames, StringComparer.OrdinalIgnoreCase); + string currentRuntoTarget = parentState?.LastRuntoTarget; + + void VisitStatement(IStatementNode stmt) + { + switch (stmt) + { + case RuntoStatement runto: + if (scopeAt.TryGetValue(runto.targetLabel, out var snapshot)) + { + visible = new HashSet(snapshot, StringComparer.OrdinalIgnoreCase); + currentRuntoTarget = runto.targetLabel; + + // Runto-induced visibility colliding with a + // test-local is a real conflict (globals are + // always-shadowable so we exclude them). + foreach (var name in visible) + { + if (globalNames.Contains(name)) continue; + if (testLocals.Contains(name)) + { + runto.Errors.Add(new ParseError( + runto.targetLabelToken ?? runto.StartToken ?? runto.EndToken, + ErrorCodes.TestRuntoShadowsLocal, + name)); + } + } + } + else + { + // Unknown label -> hard parse error. Leave visible / + // currentRuntoTarget unchanged so subsequent refs + // aren't double-flagged with a misleading + // TestVariableNotYetDeclared. + runto.Errors.Add(new ParseError( + runto.targetLabelToken ?? runto.StartToken ?? runto.EndToken, + ErrorCodes.RuntoUnknownLabel, + runto.targetLabel)); + } + break; + + case DeclarationStatement decl when decl.scopeType == DeclarationScopeType.Local: + // Declaring a test-local for a name that's currently + // visible from a runto'd program scope is a conflict + // (globals are excluded — they're always shadowable). + if (visible.Contains(decl.variable) && !globalNames.Contains(decl.variable)) + { + decl.Errors.Add(new ParseError(decl.StartToken, + ErrorCodes.TestRuntoShadowsLocal, decl.variable)); + } + testLocals.Add(decl.variable); + if (decl.initializerExpression != null) + { + CheckExpression(decl.initializerExpression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + break; + + case AssignmentStatement asn: + // RHS must be visible. + CheckExpression(asn.expression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + // LHS: bare `name = expr` follows BASIC's rule — + // assigning to an unbound name creates a fresh local in + // the enclosing scope (here, the test). When `name` IS + // visible from a runto'd program scope, the assignment + // writes through to that program-scope variable (intentional + // state setup), so no implicit-local is created. + if (asn.variable is VariableRefNode vref) + { + if (!testLocals.Contains(vref.variableName) + && !visible.Contains(vref.variableName)) + { + testLocals.Add(vref.variableName); + } + } + break; + + case AssertStatement assert: + if (assert.condition != null) + { + CheckExpression(assert.condition, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + if (assert.reason != null) + { + CheckExpression(assert.reason, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + break; + + case IfStatement ifStmt: + if (ifStmt.condition != null) + { + CheckExpression(ifStmt.condition, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + if (ifStmt.positiveStatements != null) + foreach (var s in ifStmt.positiveStatements) VisitStatement(s); + if (ifStmt.negativeStatements != null) + foreach (var s in ifStmt.negativeStatements) VisitStatement(s); + break; + + case ForStatement forStmt: + if (forStmt.variableNode is VariableRefNode forVar) testLocals.Add(forVar.variableName); + if (forStmt.startValueExpression != null) + CheckExpression(forStmt.startValueExpression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + if (forStmt.endValueExpression != null) + CheckExpression(forStmt.endValueExpression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + if (forStmt.stepValueExpression != null) + CheckExpression(forStmt.stepValueExpression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + if (forStmt.statements != null) + foreach (var s in forStmt.statements) VisitStatement(s); + break; + + case WhileStatement whileStmt: + if (whileStmt.condition != null) + CheckExpression(whileStmt.condition, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + if (whileStmt.statements != null) + foreach (var s in whileStmt.statements) VisitStatement(s); + break; + + case DoLoopStatement doStmt: + if (doStmt.statements != null) + foreach (var s in doStmt.statements) VisitStatement(s); + break; + + case RepeatUntilStatement repeatStmt: + if (repeatStmt.statements != null) + foreach (var s in repeatStmt.statements) VisitStatement(s); + if (repeatStmt.condition != null) + CheckExpression(repeatStmt.condition, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + break; + + case SwitchStatement switchStmt: + if (switchStmt.expression != null) + CheckExpression(switchStmt.expression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + if (switchStmt.cases != null) + foreach (var c in switchStmt.cases) + if (c.statements != null) + foreach (var s in c.statements) VisitStatement(s); + if (switchStmt.defaultCase?.statements != null) + foreach (var s in switchStmt.defaultCase.statements) VisitStatement(s); + break; + + case CommandStatement cmd: + // Ref-args with bare names follow the AssignmentStatement + // LHS rule: known-but-not-visible -> error; otherwise + // implicit test-local. Everything else flows through the + // standard expression check. + if (cmd.command.args != null && cmd.argMap != null) + { + for (var i = 0; i < cmd.args.Count; i++) + { + var argExpr = cmd.args[i]; + var descIdx = i < cmd.argMap.Count ? cmd.argMap[i] : -1; + var isRef = descIdx >= 0 + && descIdx < cmd.command.args.Length + && cmd.command.args[descIdx].isRef; + var refVref = isRef ? argExpr as VariableRefNode : null; + + if (refVref != null) + { + var name = refVref.variableName; + if (testLocals.Contains(name) || visible.Contains(name)) + { + // already in scope; ref read/write is fine + } + else if (allTopLevelNames.Contains(name)) + { + AddVisibilityError(cmd, name, currentRuntoTarget); + } + else + { + testLocals.Add(name); + } + } + else + { + CheckExpression(argExpr, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + } + } + break; + + case ExpressionStatement expStmt: + CheckExpression(expStmt.expression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + break; + + case FunctionStatement _: + // Function bodies are validated independently; their own + // parameters/locals are scoped within. Don't descend. + break; + } + } + + void AddVisibilityError(IAstNode node, string name, string runtoTarget) + { + var code = runtoTarget == null + ? ErrorCodes.TestVariableUnreachable + : ErrorCodes.TestVariableNotYetDeclared; + node.Errors.Add(new ParseError(node.StartToken ?? node.EndToken, code, name)); + } + + // Check an expression's variable references. + void CheckExpression(IExpressionNode expr, + HashSet testLocalsRef, + HashSet testFunctionsRef, + HashSet visibleRef, + string runtoTargetRef, + HashSet globalsRef, + HashSet allNamesRef) + { + Walk(expr); + + void Walk(IAstVisitable node) + { + if (node == null) return; + switch (node) + { + case VariableRefNode vref: + var name = vref.variableName; + if (testLocalsRef.Contains(name)) return; + if (testFunctionsRef.Contains(name)) return; + if (visibleRef.Contains(name)) return; + // Known program name but unreachable from here -> strict error. + // Unknown to allNames is handled by the main scope checker. + if (allNamesRef.Contains(name)) + { + AddVisibilityError(vref, name, runtoTargetRef); + } + break; + + case StructFieldReference sfr: + // The `right` side is a field name on the type, not + // a variable lookup — skip it. The `left` may itself + // be a struct ref / array ref / var ref; recurse. + Walk(sfr.left); + break; + + default: + { + int d = 0; + node.Visit(child => { if (d == 1) Walk(child); d++; }, _ => d--); + } + break; + } + } + } + + foreach (var stmt in testProgram.statements) + { + VisitStatement(stmt); + } + + return new TestEndState + { + Visible = visible, + LastRuntoTarget = currentRuntoTarget, + TestLocals = testLocals, + TestFunctions = testFunctions + }; + } + } +} diff --git a/FadeBasic/FadeBasic/Errors.cs b/FadeBasic/FadeBasic/Errors.cs index 4936959..1de3c01 100644 --- a/FadeBasic/FadeBasic/Errors.cs +++ b/FadeBasic/FadeBasic/Errors.cs @@ -210,6 +210,58 @@ public static class ErrorCodes public static readonly ErrorCode DeferStatementMissingEndDefer = "[0162] Defer statement is missing a closing EndDefer clause"; public static readonly ErrorCode CommandNotInRuntime = "[0163] This command is only available inside a macro"; public static readonly ErrorCode CommandNotInMacro = "[0163] This command is only available outside of a macro"; + public static readonly ErrorCode TestMissingName = "[0164] Test missing name"; + public static readonly ErrorCode TestMissingEndTest = "[0165] Test missing EndTest clause"; + public static readonly ErrorCode TestDefinedInsideFunction = "[0166] Tests cannot be defined inside functions"; + public static readonly ErrorCode TestDefinedInsideTest = "[0167] Tests cannot be defined inside other tests"; + public static readonly ErrorCode AbstractRequiresTest = "[0168] Abstract keyword must be followed by test"; + public static readonly ErrorCode TestFromMissingParent = "[0169] Test from clause must specify a parent test name"; + public static readonly ErrorCode TestNameAlreadyDeclared = "[0170] Test with this name is already declared"; + public static readonly ErrorCode RuntoMissingLabel = "[0171] Runto statement missing label"; + public static readonly ErrorCode RuntoMissingEndRunto = "[0172] Runto block missing EndRunto clause"; + public static readonly ErrorCode RuntoOutsideTest = "[0173] Runto can only be used inside a test block"; + public static readonly ErrorCode RuntoUnknownLabel = "[0174] Runto target label is not defined anywhere in the program"; + public static readonly ErrorCode RuntoMaxCyclesMissingValue = "[0175] max cycles clause requires an integer expression"; + public static readonly ErrorCode AssertOutsideTest = "[0176] assert can only be used inside a test block"; + public static readonly ErrorCode AssertMissingExpression = "[0177] assert requires a boolean expression"; + public static readonly ErrorCode TestVariableNotYetDeclared = "[0178] Variable is declared in the program but not yet at the most recent runto target"; + public static readonly ErrorCode TestVariableUnreachable = "[0179] Variable is not visible from the test at this point — no runto has reached its declaration"; + public static readonly ErrorCode MockMissingCommandName = "[0180] mock requires a command name"; + public static readonly ErrorCode MockUnknownCommand = "[0181] mock target is not a known command"; + public static readonly ErrorCode MockMissingEndMock = "[0182] mock block missing endmock clause"; + public static readonly ErrorCode MockEntryRequiresReturnsOrForbid = "[0183] mock body statement must be `returns ` or `forbid`"; + public static readonly ErrorCode MockOutsideTest = "[0186] mock can only be used inside a test block"; + public static readonly ErrorCode ClearMockMissingTarget = "[0187] clear must be followed by `mock ` or `mocks`"; + public static readonly ErrorCode ClearMockOutsideTest = "[0188] clear mock(s) can only be used inside a test block"; + public static readonly ErrorCode TestNestingNotAllowed = "[0189] tests cannot be declared inside another test"; + public static readonly ErrorCode TestRuntoShadowsLocal = "[0190] runto brings a program-scope variable into view that conflicts with a test-local of the same name"; + public static readonly ErrorCode AssertReasonMissingExpression = "[0191] assert reason clause (after `,`) requires a string expression"; + public static readonly ErrorCode AssertReasonMustBeString = "[0192] assert reason expression must be a string"; + public static readonly ErrorCode MockReturnsMissingExpression = "[0193] `exitmock` requires an expression"; + public static readonly ErrorCode MockForbidReasonMustBeString = "[0194] forbid reason expression must be a string"; + public static readonly ErrorCode MockReturnsOnVoidCommand = "[0195] `exitmock`/`endmock ` is not allowed when the command has no return value"; + public static readonly ErrorCode MockReturnsTypeMismatch = "[0196] mock return-value expression type does not match the command's return type"; + public static readonly ErrorCode MockMultipleReturns = "[0197] mock body has multiple `exitmock` statements; only one is allowed"; + public static readonly ErrorCode MockMultipleForbid = "[0198] mock body has multiple `forbid` statements; only one is allowed"; + public static readonly ErrorCode MockReturnsAndForbid = "[0199] mock body cannot mix `returns` and `forbid`"; + public static readonly ErrorCode MockValueCommandMissingReturns = "[0252] mock body for a value-returning command must contain `exitmock`, `endmock `, or `forbid`"; + public static readonly ErrorCode MockRefParamNotAssigned = "[0253] ref parameter must be assigned in the mock body — the caller's variable is left undefined otherwise"; + public static readonly ErrorCode RuntoInsideMockBody = "[0257] `runto` is a test-control statement and cannot appear inside a mock body"; + public static readonly ErrorCode MockParamsMissingCloseParen = "[0258] mock parameter list opened with `(` is missing its closing `)`"; + public static readonly ErrorCode MockParamCountNoMatchingOverload = "[0259] mock parameter count does not match any overload of the command"; + public static readonly ErrorCode ParamsCannotMixArrayWithInline = "[0260] cannot mix an array spread with inline values at the same `params` position"; + public static readonly ErrorCode ParamsArrayMustBeRankOne = "[0261] only single-dimensional arrays can be spread into a `params` arg"; + public static readonly ErrorCode ParamsArrayElementTypeMismatch = "[0262] array element type does not match the `params` arg's element type"; + public static readonly ErrorCode LenMissingParens = "[0263] `len` requires parentheses around its argument"; + public static readonly ErrorCode LenMissingExpression = "[0264] `len(...)` requires an array or string expression inside"; + public static readonly ErrorCode LenMissingCloseParen = "[0265] `len(` is missing its closing `)`"; + public static readonly ErrorCode LenInvalidType = "[0266] `len` only accepts array or string expressions"; + public static readonly ErrorCode CallCountMissingCommand = "[0251] `call count` must be followed by a command name"; + public static readonly ErrorCode MockBodyRefArgMustBeBoundRefParam = "[0267] inside a mock body, a self-recursive call to the mocked command must pass one of the mock's bound ref parameters at each ref position"; + public static readonly ErrorCode TestFromParentUnknown = "[0269] `test ... from ` references a parent test that does not exist"; + public static readonly ErrorCode TestFromParentCycle = "[0270] `from`-chain forms a cycle — a test cannot transitively inherit from itself"; + public static readonly ErrorCode TestDuplicateName = "[0271] another test with this name already exists; test names must be unique within a program"; + public static readonly ErrorCode MockParamsObjectArrayUnnamable = "[0268] cannot bind a name to a `params object[]` argument in a mock body"; // 200 series represents post-parse issues public static readonly ErrorCode InvalidReference = "[0200] Invalid reference"; @@ -233,6 +285,7 @@ public static class ErrorCodes public static readonly ErrorCode ArrayCannotAssignFromDefault = "[0218] Cannot assign default to array"; public static readonly ErrorCode TokenizationContainsHaunted = "[0219] Tokenization cannot be resolved without running the program "; public static readonly ErrorCode VariableUsesHaunted = "[0220] Variable cannot include tokens generated from a non deterministic macro"; + public static readonly ErrorCode CannotCallTestFunctionFromOutsideTest = "[0221] Cannot invoke function declared in test"; // 300 series represents type issues public static readonly ErrorCode SymbolAlreadyDeclared = "[0300] Symbol already declared"; diff --git a/FadeBasic/FadeBasic/FadeBasic.csproj b/FadeBasic/FadeBasic/FadeBasic.csproj index 9ba2fb6..ccf0a93 100644 --- a/FadeBasic/FadeBasic/FadeBasic.csproj +++ b/FadeBasic/FadeBasic/FadeBasic.csproj @@ -14,12 +14,16 @@ - + + diff --git a/FadeBasic/FadeBasic/Json/IJsonable.cs b/FadeBasic/FadeBasic/Json/IJsonable.cs index 96f3cd7..cc728d0 100644 --- a/FadeBasic/FadeBasic/Json/IJsonable.cs +++ b/FadeBasic/FadeBasic/Json/IJsonable.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Net.NetworkInformation; @@ -13,7 +14,7 @@ namespace FadeBasic.Json /// public interface IJsonable { - void ProcessJson(IJsonOperation op); + void ProcessJson(ref T op) where T : IJsonOperation; } public interface IJsonableSerializationCallbacks : IJsonable @@ -24,13 +25,13 @@ public interface IJsonableSerializationCallbacks : IJsonable public interface IJsonOperation { - void Process(IJsonable jsonable); void IncludeField(string name, ref int fieldValue); void IncludeField(string name, ref byte fieldValue); void IncludeField(string name, ref bool fieldValue); void IncludeField(string name, ref string fieldValue); void IncludeField(string name, ref byte[] fieldValue); void IncludeField(string name, ref int[] fieldValue); + void IncludeField(string name, ref double fieldValue); void IncludeField(string name, ref DebugMessageType fieldValue); void IncludeField(string name, ref Dictionary fieldValue); void IncludeField(string name, ref T fieldValue) where T : IJsonable, new(); @@ -43,16 +44,21 @@ public static class JsonableExtensions { public static T FromJson(string json) where T : IJsonable, new() { - var data = JsonData.Parse(json); - return FromJson(data); + int start = 0; + while (start < json.Length && (json[start] == ' ' || json[start] == '\t' || json[start] == '\r' || json[start] == '\n')) + start++; + var instance = new T(); + var op = new StreamingJsonReadOp(json, start); + try + { + op.Process(instance); + } + finally + { + op.Return(); + } + return instance; } - - // public static T FromJson2(string json) where T : IJsonable, new() - // { - // // var data = JsonData.Parse(json); - // var data = Jsonable2.Parse(json); - // return FromJson(data); - // } public static T FromJson(JsonData json) where T : IJsonable, new() { @@ -61,23 +67,22 @@ public static class JsonableExtensions op.Process(instance); return instance; } - + public static string Jsonify(this IJsonable jsonable) { var sb = new StringBuilder(); var op = new JsonWriteOp(sb); - op.Process(jsonable); - return sb.ToString(); } + } static class JsonConstants { public const char OPEN_BRACKET = '{'; public const char CLOSE_BRACKET = '}'; - + public const char OPEN_ARRAY = '['; public const char CLOSE_ARRAY = ']'; public const char COMMA = ','; @@ -93,17 +98,11 @@ public class JsonData public Dictionary> numberArrays = new Dictionary>(); public Dictionary ints = new Dictionary(); public Dictionary strings = new Dictionary(); - + public static JsonData Parse(string json) { var span = json.AsSpan(); var index = 0; - - // ReadAndAssert(ref span, JsonConstants.OPEN_BRACKET); - // if (!TryRead(ref span, out var curr)) - // { - // throw new NotImplementedException("end of stream unhandled"); - // } var topObj = new JsonData(); switch (span[0]) @@ -121,16 +120,12 @@ void ReadObject(ref ReadOnlySpan span, out JsonData obj) ReadAndAssert(ref span, JsonConstants.OPEN_BRACKET); - // now need to read the value... while (span[index] != JsonConstants.CLOSE_BRACKET) { - // parse an object! ReadString(ref span, out var field); ReadAndAssert(ref span, JsonConstants.COLON); - Read(ref span, out var valuePeek); - // var valuePeek = span[index]; if (char.IsDigit(valuePeek) || valuePeek == '-') { @@ -197,47 +192,21 @@ void ReadObject(ref ReadOnlySpan span, out JsonData obj) obj.arrays[field.ToString()] = new List(); obj.numberArrays[field.ToString()] = numberList; } - - // while (span[index] != JsonConstants.CLOSE_ARRAY) - // { - // if (list.Count > 0) - // { - // ReadAndAssert(ref span, JsonConstants.COMMA); - // } - // ReadObject(ref span, out var element); - // list.Add(element); - // } ReadAndAssert(ref span, JsonConstants.CLOSE_ARRAY); - - - // obj.arrays[field.ToString()] = list; } - // read comma if it exists... var prePeakIndex = index; if (TryRead(ref span, out valuePeek)) { if (valuePeek != JsonConstants.COMMA) { index = prePeakIndex; - } } - // Read(ref span, out valuePeek); - // if (valuePeek == JsonConstants.COMMA) - // { - // // this is an allowed skip - // } - // else - // { - // // revert the peak! - // index = prePeakIndex; - // } } ReadAndAssert(ref span, JsonConstants.CLOSE_BRACKET); - } void Read(ref ReadOnlySpan span, out char next) @@ -247,7 +216,7 @@ void Read(ref ReadOnlySpan span, out char next) throw new Exception("hit end of json stream"); } } - + bool TryRead(ref ReadOnlySpan span, out char next) { next = ' '; @@ -256,7 +225,7 @@ bool TryRead(ref ReadOnlySpan span, out char next) { next = span[index++]; } - + return true; } @@ -278,7 +247,7 @@ void ReadInteger(ref ReadOnlySpan span, out int value) var intSpan = span.Slice(start, index - start ); int.TryParse(intSpan.ToString(), out value); } - + void ReadString(ref ReadOnlySpan span, out ReadOnlySpan field) { ReadAndAssert(ref span, JsonConstants.QUOTE); @@ -294,7 +263,6 @@ void ReadString(ref ReadOnlySpan span, out ReadOnlySpan field) if (curr == JsonConstants.ESCAPE) { - // skip! requireEscapeRemoval = true; if (!TryRead(ref span, out var next)) { @@ -327,24 +295,18 @@ void ReadString(ref ReadOnlySpan span, out ReadOnlySpan field) var c = field[i]; switch (c) { - // case JsonConstants.QUOTE: case JsonConstants.ESCAPE: - // peek at the next character... - // if (i + 1 < field.Length) { var peek = field[i + 1]; switch (peek) { - // skip certain characters? case JsonConstants.ESCAPE: buffer.Append(c); i++; break; } } - - // skip break; default: buffer.Append(c); @@ -355,7 +317,7 @@ void ReadString(ref ReadOnlySpan span, out ReadOnlySpan field) field = buffer.ToString().AsSpan(); } } - + void ReadAndAssert(ref ReadOnlySpan span, char next) { if (!TryRead(ref span, out var curr)) @@ -382,9 +344,9 @@ public JsonReadOp(JsonData data) public void Process(IJsonable jsonable) { - - jsonable.ProcessJson(this); - + var self = this; + jsonable.ProcessJson(ref self); + if (jsonable is IJsonableSerializationCallbacks cbr) { cbr.OnAfterDeserialized(); @@ -433,7 +395,7 @@ public void IncludeField(string name, ref int[] fieldValue) } } } - + public void IncludeField(string name, ref byte[] fieldValue) { if (!_data.numberArrays.TryGetValue(name, out var numbers)) @@ -450,6 +412,12 @@ public void IncludeField(string name, ref byte[] fieldValue) } } + public void IncludeField(string name, ref double fieldValue) + { + if (_data.ints.TryGetValue(name, out var intVal)) + fieldValue = intVal; + } + public void IncludeField(string name, ref DebugMessageType fieldValue) { if (_data.ints.TryGetValue(name, out var fieldInt)) @@ -526,14 +494,31 @@ public void IncludeField(string name, ref Dictionary fieldValue) } } - public class JsonWriteOp : IJsonOperation + public struct JsonWriteOp : IJsonOperation { private readonly StringBuilder _sb; - private int fieldCount = 0; + private int fieldCount; public JsonWriteOp(StringBuilder sb) { _sb = sb; + fieldCount = 0; + } + + private void AppendEscaped(string value) + { + var segStart = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c == '"' || c == '\\') + { + if (i > segStart) _sb.Append(value, segStart, i - segStart); + _sb.Append(c == '"' ? "\\\"" : "\\\\"); + segStart = i + 1; + } + } + if (segStart < value.Length) _sb.Append(value, segStart, value.Length - segStart); } void IncludePrim(string name, ref T prim) where T : struct @@ -542,13 +527,13 @@ void IncludePrim(string name, ref T prim) where T : struct { _sb.Append(JsonConstants.COMMA); } - + _sb.Append(JsonConstants.QUOTE); _sb.Append(name); _sb.Append(JsonConstants.QUOTE); _sb.Append(JsonConstants.COLON); _sb.Append(prim); - + fieldCount++; } @@ -559,7 +544,7 @@ public void Process(IJsonable jsonable) { cbr.OnBeforeSerialize(); } - jsonable.ProcessJson(this); + jsonable.ProcessJson(ref this); _sb.Append(JsonConstants.CLOSE_BRACKET); } @@ -567,6 +552,17 @@ public void Process(IJsonable jsonable) public void IncludeField(string name, ref byte fieldValue) => IncludePrim(name, ref fieldValue); public void IncludeField(string name, ref ulong fieldValue) => IncludePrim(name, ref fieldValue); + public void IncludeField(string name, ref double fieldValue) + { + if (fieldCount > 0) _sb.Append(JsonConstants.COMMA); + _sb.Append(JsonConstants.QUOTE); + _sb.Append(name); + _sb.Append(JsonConstants.QUOTE); + _sb.Append(JsonConstants.COLON); + _sb.Append(fieldValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + fieldCount++; + } + public void IncludeField(string name, ref bool fieldValue) { var value = fieldValue ? 1 : 0; @@ -579,9 +575,8 @@ public void IncludeField(string name, ref string fieldValue) { _sb.Append(JsonConstants.COMMA); } - + _sb.Append(JsonConstants.QUOTE); - _sb.Append(name); _sb.Append(JsonConstants.QUOTE); _sb.Append(JsonConstants.COLON); @@ -593,35 +588,13 @@ public void IncludeField(string name, ref string fieldValue) else { _sb.Append(JsonConstants.QUOTE); - - // need to escape the string content... - for (var i = 0; i < fieldValue.Length; i++) - { - var c = fieldValue[i]; - switch (c) - { - case '\"': - _sb.Append("\\\""); - break; - case '\\': - // if (i + 1 < fieldValue.Length) - // { - // // if (fieldValue) - // } - _sb.Append("\\\\"); - break; - default: - _sb.Append(c); - break; - } - } + AppendEscaped(fieldValue); _sb.Append(JsonConstants.QUOTE); } - + fieldCount++; } - public void IncludeField(string name, ref int[] fieldValue) { if (fieldCount > 0) @@ -640,12 +613,11 @@ public void IncludeField(string name, ref int[] fieldValue) _sb.Append(JsonConstants.COMMA); } _sb.Append(fieldValue[i]); - } _sb.Append(JsonConstants.CLOSE_ARRAY); fieldCount++; } - + public void IncludeField(string name, ref byte[] fieldValue) { if (fieldCount > 0) @@ -664,7 +636,6 @@ public void IncludeField(string name, ref byte[] fieldValue) _sb.Append(JsonConstants.COMMA); } _sb.Append(fieldValue[i]); - } _sb.Append(JsonConstants.CLOSE_ARRAY); fieldCount++; @@ -701,25 +672,21 @@ public void IncludeField(string name, ref Dictionary fieldValue) _sb.Append(kvp.Key); _sb.Append(JsonConstants.QUOTE); _sb.Append(JsonConstants.COLON); - + var val = kvp.Value; _sb.Append(val); - - // subOp.IncludeField(kvp.ToString(), ref val); } _sb.Append(JsonConstants.CLOSE_BRACKET); fieldCount++; - } public void IncludeField(string name, ref T fieldValue) where T : IJsonable, new() { - if (fieldCount > 0) { _sb.Append(JsonConstants.COMMA); } - + _sb.Append(JsonConstants.QUOTE); _sb.Append(name); _sb.Append(JsonConstants.QUOTE); @@ -733,9 +700,8 @@ public void IncludeField(string name, ref Dictionary fieldValue) { _sb.Append(JsonConstants.OPEN_BRACKET); var subOp = new JsonWriteOp(_sb); - fieldValue.ProcessJson(subOp); + fieldValue.ProcessJson(ref subOp); _sb.Append(JsonConstants.CLOSE_BRACKET); - } fieldCount++; @@ -743,19 +709,18 @@ public void IncludeField(string name, ref Dictionary fieldValue) public void IncludeField(string name, ref List fieldValue) where T : IJsonable, new() { - if (fieldCount > 0) { _sb.Append(JsonConstants.COMMA); } - + _sb.Append(JsonConstants.QUOTE); _sb.Append(name); _sb.Append(JsonConstants.QUOTE); _sb.Append(JsonConstants.COLON); _sb.Append(JsonConstants.OPEN_ARRAY); - + for (var i = 0 ; i < fieldValue.Count; i ++) { var subOp = new JsonWriteOp(_sb); @@ -767,7 +732,6 @@ public void IncludeField(string name, ref Dictionary fieldValue) } _sb.Append(JsonConstants.CLOSE_ARRAY); fieldCount++; - } public void IncludeField(string name, ref Dictionary fieldValue) where T : IJsonable, new() @@ -795,16 +759,13 @@ public void IncludeField(string name, ref Dictionary fieldValue) _sb.Append(kvp.Key); _sb.Append(JsonConstants.QUOTE); _sb.Append(JsonConstants.COLON); - + var subOp = new JsonWriteOp(_sb); var val = kvp.Value; subOp.Process(val); - // subOp.IncludeField(kvp.ToString(), ref val); } _sb.Append(JsonConstants.CLOSE_BRACKET); fieldCount++; - - } public void IncludeField(string name, ref Dictionary fieldValue) where T : IJsonable, new() @@ -832,15 +793,385 @@ public void IncludeField(string name, ref Dictionary fieldValue) _sb.Append(kvp.Key); _sb.Append(JsonConstants.QUOTE); _sb.Append(JsonConstants.COLON); - + var subOp = new JsonWriteOp(_sb); var val = kvp.Value; subOp.Process(val); - // subOp.IncludeField(kvp.ToString(), ref val); } _sb.Append(JsonConstants.CLOSE_BRACKET); fieldCount++; + } + } + + public struct StreamingJsonReadOp : IJsonOperation + { + private readonly string _json; + // Flat field index: [nameStart, nameLen, valuePos] per field (stride 3) + private int[] _fields; + private int _fieldCount; + internal int ObjEnd; + + private const int FieldStride = 3; + private const int InitialCapacity = 8; + + public StreamingJsonReadOp(string json, int objStart) + { + _json = json; + _fieldCount = 0; + ObjEnd = 0; + _fields = ArrayPool.Shared.Rent(InitialCapacity * FieldStride); + BuildIndex(objStart); + } + + public void Return() + { + if (_fields != null) + { + ArrayPool.Shared.Return(_fields); + _fields = null; + } + } + + private void BuildIndex(int i) + { + i++; // skip '{' + SkipWs(ref i); + while (i < _json.Length && _json[i] != '}') + { + // Record field name span without allocating a string + i++; // skip opening '"' + var nameStart = i; + while (i < _json.Length && _json[i] != '"') + { + if (_json[i] == '\\') i++; // skip escaped char + i++; + } + var nameLen = i - nameStart; + i++; // skip closing '"' + SkipWs(ref i); + i++; // skip ':' + SkipWs(ref i); + + if (_fieldCount * FieldStride >= _fields.Length) + { + var newSize = _fields.Length * 2; + var grown = ArrayPool.Shared.Rent(newSize); + Array.Copy(_fields, grown, _fieldCount * FieldStride); + ArrayPool.Shared.Return(_fields); + _fields = grown; + } + var slot = _fieldCount * FieldStride; + _fields[slot] = nameStart; + _fields[slot + 1] = nameLen; + _fields[slot + 2] = i; // value position + _fieldCount++; + + SkipValue(ref i); + SkipWs(ref i); + if (i < _json.Length && _json[i] == ',') { i++; SkipWs(ref i); } + } + ObjEnd = i + 1; // past '}' + } + private int FindField(string name) + { + var len = name.Length; + for (var f = 0; f < _fieldCount; f++) + { + var slot = f * FieldStride; + if (_fields[slot + 1] != len) continue; + var start = _fields[slot]; + var match = true; + for (var j = 0; j < len; j++) + if (_json[start + j] != name[j]) { match = false; break; } + if (match) return _fields[slot + 2]; + } + return -1; + } + + public void Process(IJsonable jsonable) + { + jsonable.ProcessJson(ref this); + if (jsonable is IJsonableSerializationCallbacks cb) + cb.OnAfterDeserialized(); + } + + private void SkipWs(ref int i) + { + while (i < _json.Length && (_json[i] == ' ' || _json[i] == '\t' || _json[i] == '\r' || _json[i] == '\n')) + i++; + } + + private void SkipString(ref int i) + { + i++; // skip '"' + while (i < _json.Length) + { + var c = _json[i++]; + if (c == '\\') i++; + else if (c == '"') return; + } + } + + private void SkipBraced(ref int i) + { + var open = _json[i]; + var close = open == '{' ? '}' : ']'; + var depth = 1; + i++; + while (i < _json.Length && depth > 0) + { + var c = _json[i++]; + if (c == '"') { i--; SkipString(ref i); } + else if (c == open) depth++; + else if (c == close) depth--; + } + } + + private void SkipValue(ref int i) + { + if (i >= _json.Length) return; + var c = _json[i]; + if (c == '"') SkipString(ref i); + else if (c == '{' || c == '[') SkipBraced(ref i); + else if (c == 'n') i += 4; // null + else if (c == 't') i += 4; // true + else if (c == 'f') i += 5; // false + else // number + { + while (i < _json.Length && (_json[i] == '-' || _json[i] == '+' || char.IsDigit(_json[i]) || _json[i] == '.' || _json[i] == 'e' || _json[i] == 'E')) + i++; + } + } + + private string ReadStringValue(ref int i) + { + i++; // skip '"' + var start = i; + var hasEscape = false; + while (i < _json.Length) + { + var c = _json[i++]; + if (c == '\\') { hasEscape = true; i++; } + else if (c == '"') break; + } + var contentEnd = i - 1; + if (!hasEscape) + return _json.Substring(start, contentEnd - start); + var sb = new StringBuilder(contentEnd - start); + for (var j = start; j < contentEnd; j++) + { + var c = _json[j]; + if (c == '\\' && j + 1 < contentEnd) + { + j++; + switch (_json[j]) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + default: sb.Append(_json[j]); break; + } + } + else sb.Append(c); + } + return sb.ToString(); + } + + private bool IsNull(int i) => i < _json.Length && _json[i] == 'n'; + + private int ParseIntAt(int i) + { + var sign = 1; + if (i < _json.Length && _json[i] == '-') { sign = -1; i++; } + var value = 0; + while (i < _json.Length && _json[i] >= '0' && _json[i] <= '9') + value = value * 10 + (_json[i++] - '0'); + return sign * value; + } + + private double ParseDoubleAt(int i) + { + var end = i; + if (end < _json.Length && (_json[end] == '-' || _json[end] == '+')) end++; + while (end < _json.Length && (char.IsDigit(_json[end]) || _json[end] == '.' || _json[end] == 'e' || _json[end] == 'E' || _json[end] == '+' || _json[end] == '-')) + end++; + double.TryParse(_json.Substring(i, end - i), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value); + return value; + } + + public void IncludeField(string name, ref int fieldValue) + { + if (FindField(name) is var i && i >= 0 && !IsNull(i)) + fieldValue = ParseIntAt(i); + } + + public void IncludeField(string name, ref byte fieldValue) + { + if (FindField(name) is var i && i >= 0 && !IsNull(i)) + fieldValue = (byte)ParseIntAt(i); + } + + public void IncludeField(string name, ref bool fieldValue) + { + if (FindField(name) is var i && i >= 0 && !IsNull(i)) + fieldValue = ParseIntAt(i) != 0; + } + + public void IncludeField(string name, ref string fieldValue) + { + var i = FindField(name); if (i < 0) return; + if (IsNull(i)) { fieldValue = null; return; } + fieldValue = ReadStringValue(ref i); + } + + public void IncludeField(string name, ref byte[] fieldValue) + { + var i = FindField(name); if (i < 0) return; + if (IsNull(i)) { fieldValue = Array.Empty(); return; } + i++; // skip '[' + SkipWs(ref i); + if (i < _json.Length && _json[i] == ']') { fieldValue = Array.Empty(); return; } + var list = new List(); + while (i < _json.Length && _json[i] != ']') + { + SkipWs(ref i); + list.Add((byte)ParseIntAt(i)); + SkipValue(ref i); + SkipWs(ref i); + if (i < _json.Length && _json[i] == ',') { i++; SkipWs(ref i); } + } + fieldValue = list.ToArray(); + } + + public void IncludeField(string name, ref int[] fieldValue) + { + var i = FindField(name); if (i < 0) return; + if (IsNull(i)) { fieldValue = Array.Empty(); return; } + i++; // skip '[' + SkipWs(ref i); + if (i < _json.Length && _json[i] == ']') { fieldValue = Array.Empty(); return; } + var list = new List(); + while (i < _json.Length && _json[i] != ']') + { + SkipWs(ref i); + list.Add(ParseIntAt(i)); + SkipValue(ref i); + SkipWs(ref i); + if (i < _json.Length && _json[i] == ',') { i++; SkipWs(ref i); } + } + fieldValue = list.ToArray(); + } + + public void IncludeField(string name, ref double fieldValue) + { + if (FindField(name) is var i && i >= 0 && !IsNull(i)) + fieldValue = ParseDoubleAt(i); + } + + public void IncludeField(string name, ref DebugMessageType fieldValue) + { + if (FindField(name) is var i && i >= 0 && !IsNull(i)) + fieldValue = (DebugMessageType)ParseIntAt(i); + } + + public void IncludeField(string name, ref Dictionary fieldValue) + { + var i = FindField(name); if (i < 0) return; + if (IsNull(i)) return; + fieldValue = new Dictionary(); + i++; // skip '{' + SkipWs(ref i); + while (i < _json.Length && _json[i] != '}') + { + var key = ReadStringValue(ref i); + SkipWs(ref i); + i++; // skip ':' + SkipWs(ref i); + fieldValue[key] = ParseIntAt(i); + SkipValue(ref i); + SkipWs(ref i); + if (i < _json.Length && _json[i] == ',') { i++; SkipWs(ref i); } + } + } + + public void IncludeField(string name, ref T fieldValue) where T : IJsonable, new() + { + var i = FindField(name); if (i < 0) return; + if (IsNull(i)) return; + var subOp = new StreamingJsonReadOp(_json, i); + fieldValue = new T(); + subOp.Process(fieldValue); + subOp.Return(); + } + + public void IncludeField(string name, ref List fieldValue) where T : IJsonable, new() + { + var i = FindField(name); if (i < 0) return; + if (IsNull(i)) return; + i++; // skip '[' + SkipWs(ref i); + fieldValue = new List(); + while (i < _json.Length && _json[i] != ']') + { + var subOp = new StreamingJsonReadOp(_json, i); + var item = new T(); + subOp.Process(item); + i = subOp.ObjEnd; + subOp.Return(); + fieldValue.Add(item); + SkipWs(ref i); + if (i < _json.Length && _json[i] == ',') { i++; SkipWs(ref i); } + } + } + + public void IncludeField(string name, ref Dictionary fieldValue) where T : IJsonable, new() + { + var i = FindField(name); if (i < 0) return; + if (IsNull(i)) return; + fieldValue = new Dictionary(); + i++; // skip '{' + SkipWs(ref i); + while (i < _json.Length && _json[i] != '}') + { + var key = ReadStringValue(ref i); + SkipWs(ref i); + i++; // skip ':' + SkipWs(ref i); + int.TryParse(key, out var intKey); + var subOp = new StreamingJsonReadOp(_json, i); + var item = new T(); + subOp.Process(item); + i = subOp.ObjEnd; + subOp.Return(); + fieldValue[intKey] = item; + SkipWs(ref i); + if (i < _json.Length && _json[i] == ',') { i++; SkipWs(ref i); } + } + } + + public void IncludeField(string name, ref Dictionary fieldValue) where T : IJsonable, new() + { + var i = FindField(name); if (i < 0) return; + if (IsNull(i)) return; + fieldValue = new Dictionary(); + i++; // skip '{' + SkipWs(ref i); + while (i < _json.Length && _json[i] != '}') + { + var key = ReadStringValue(ref i); + SkipWs(ref i); + i++; // skip ':' + SkipWs(ref i); + var subOp = new StreamingJsonReadOp(_json, i); + var item = new T(); + subOp.Process(item); + i = subOp.ObjEnd; + subOp.Return(); + fieldValue[key] = item; + SkipWs(ref i); + if (i < _json.Length && _json[i] == ',') { i++; SkipWs(ref i); } + } } } -} \ No newline at end of file + +} diff --git a/FadeBasic/FadeBasic/Launch/DebugSession.cs b/FadeBasic/FadeBasic/Launch/DebugSession.cs index 9830c11..d02568b 100644 --- a/FadeBasic/FadeBasic/Launch/DebugSession.cs +++ b/FadeBasic/FadeBasic/Launch/DebugSession.cs @@ -29,7 +29,7 @@ public class DiscoveryMessage : IJsonable public string processName; public int processId; public string processWindowTitle; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(port), ref port); op.IncludeField(nameof(processId), ref processId); @@ -56,6 +56,17 @@ enum State protected bool requestedExit; protected bool started; + + /// + /// When true, reaching end-of-program does not automatically fire + /// REV_REQUEST_EXITED to the connected debug client. Hosts that + /// drive the VM through multiple programs in one process (e.g. a test + /// runner that s between tests, or any consumer + /// that wants to keep the debugger session alive across program + /// completions) should set this. An explicit + /// or socket close still terminates the session normally. + /// + public bool suppressExitOnProgramEnd; protected Task _serverTask; protected Task _processingTask; @@ -182,21 +193,74 @@ public DebugSession(VirtualMachine vm, DebugData dbg, CommandCollection commandC public void Restart(VirtualMachine nextVm, DebugData nextDebugData, CommandCollection commandCollection) { - // put this as a message so that the read-loop causes an interupt in the running VM. - // otherwise, the existing VM will get stuck in a read-loop. - receivedMessages.Enqueue(new MockResetMessage() - { - type = DebugMessageType.REV_REQUEST_RESTART, - nextDebugData = nextDebugData, - nextMachine = nextVm, - nextCommands = commandCollection - }); - - // flip some state so the program does not run, until hello is received. + // Suspend the current VM first so anything currently executing it + // (on this thread or otherwise) drops out before we swap. Restart() + // is expected to be called from the same thread that drives the VM + // (via StartDebugging), so by the time we return the swap is in + // effect for the caller's next tick. + _vm?.Suspend(); + + // Swap synchronously. The previous design enqueued a + // MockResetMessage and let the read-loop perform the swap when + // ReadMessage was next called from inside StartDebugging. That + // failed when the prior VM had reached end-of-program: the inner + // exec loop's `instructionIndex < program.Length` guard short- + // circuited, ReadMessage was never invoked, and the swap message + // sat in the queue forever — leaving _vm pointing at the dead VM + // while the caller's _vm field already pointed at the new one. + ApplyRestart(nextVm, nextDebugData, commandCollection); + } + + // Shared swap path used by Restart() and the (now-defensive) inbound + // REV_REQUEST_RESTART message handler. Mutates _vm and the surrounding + // state on the calling thread; safe because the only writers of these + // fields are the message-processing loop and Restart(), both invoked + // from the same VM-driving thread. + private void ApplyRestart(VirtualMachine nextVm, DebugData nextDebugData, CommandCollection nextCommands) + { + _vm = nextVm; + _vm.shouldThrowRuntimeException = false; + _vm.logger = logger; + + _commandCollection = nextCommands; + _dbg = nextDebugData; + + instructionMap = new IndexCollection(_dbg.statementTokens); + variableDb = new DebugVariableDatabase(_vm, _dbg, logger); + + logger.Log("RESTARTING debug session... version=" + typeof(DebugSession).Assembly.GetName().Version); + foreach (var token in _dbg.statementTokens) + { + var json = JsonableExtensions.Jsonify(token); + logger.Log(json); + } + + // reset state variables + pauseRequestedByMessageId = 0; + resumeRequestedByMessageId = 0; + currentInsLookupOffset = 0; + stepNextMessage = null; + stepIntoMessage = null; + stepOutMessage = null; + stepStackDepth = 0; + stepOverFromToken = null; + stepInFromToken = null; + stepOutFromToken = null; + breakpointTokens.Clear(); + hitBreakpointToken = null; + + // Gate the new VM behind a fresh PROTO_HELLO from any connected + // debugger so a mid-step client doesn't continue executing against + // stale program state. debuggerSaidHello = 0; debuggerReset = 1; - - _vm.Suspend(); + + // tell the DAP Host that we are planning to reboot! + outboundMessages.Enqueue(new DebugMessage() + { + id = GetNextMessageId(), + type = DebugMessageType.REV_REQUEST_RESTART + }); } public void StartServer() @@ -219,13 +283,20 @@ public void StartServer() public void ShutdownServer() { - logger.Log("Starting server shutdown..."); + // No-op when the server was never started (e.g., a DebugSession + // constructed for in-process debugging without a network listener, + // or a test host that creates the session but skips StartServer). + // Without this guard _cts is null and the Cancel() below NREs on + // dispose paths. + if (!started) return; + + logger?.Log("Starting server shutdown..."); while (didClientConnect && outboundMessages.Count > 0) { Thread.Sleep(10); // wait for messages to go away... } - logger.Log("Messages done..."); - _cts.Cancel(); + logger?.Log("Messages done..."); + _cts?.Cancel(); } protected bool didClientConnect = false; @@ -316,7 +387,15 @@ protected void SendRuntimeErrorMessage(string message) }); } - protected void SendExitedMessage() + /// + /// Tell the connected debug client that this session is exiting. Use + /// this from a host that has set + /// (so the auto-fire at end-of-program is disabled) and needs to + /// emit the EXITED event explicitly when its overall lifetime ends — + /// e.g., a test runner after the last test, or any consumer that + /// drives multiple programs in one debug session. + /// + public void SendExitedMessage() { logger?.Debug("Sending exit message"); var message = new DebugMessage() @@ -338,48 +417,15 @@ protected void ReadMessage() switch (message.type) { case DebugMessageType.REV_REQUEST_RESTART: - - var mock = message as MockResetMessage; - _vm = mock.nextMachine; - _vm.shouldThrowRuntimeException = false; - _vm.logger = logger; - - _commandCollection = mock.nextCommands; - - _dbg = mock.nextDebugData; - - instructionMap = new IndexCollection(_dbg.statementTokens); - variableDb = new DebugVariableDatabase(_vm, _dbg, logger); - - logger.Log("RESTARTING debug session... version=" + typeof(DebugSession).Assembly.GetName().Version); - foreach (var token in _dbg.statementTokens) + // Defensive: Restart() now swaps synchronously and + // does not enqueue this message, so this case is + // unreachable from internal callers. Kept for any + // external code that drives the message bus + // directly. + if (message is MockResetMessage mock) { - var json = JsonableExtensions.Jsonify(token); - logger.Log(json); + ApplyRestart(mock.nextMachine, mock.nextDebugData, mock.nextCommands); } - - // reset state variables - - pauseRequestedByMessageId = 0; - resumeRequestedByMessageId = 0; - currentInsLookupOffset = 0; - stepNextMessage = null; - stepIntoMessage = null; - stepOutMessage = null; - stepStackDepth = 0; - stepOverFromToken = null; - stepInFromToken = null; - stepOutFromToken = null; - breakpointTokens.Clear(); - hitBreakpointToken = null; - - // tell the DAP Host that we are planning to reboot! - outboundMessages.Enqueue(new DebugMessage() - { - id = GetNextMessageId(), - type = DebugMessageType.REV_REQUEST_RESTART - }); - break; case DebugMessageType.PROTO_HELLO: hasConnectedDebugger = 1; @@ -2176,7 +2222,13 @@ public virtual void StartDebugging(int ops = 0) logger?.Debug("done with debug loop"); - if (_vm.instructionIndex >= _vm.program.Length || requestedExit) + // Always honor an explicit requestedExit. End-of-program only + // fires EXITED when the host hasn't opted into managing program + // lifetime via suppressExitOnProgramEnd — without that opt-out, + // a test runner that swaps VMs between tests would tell the + // debugger "process exited" mid-session and lose the connection. + if (requestedExit || + (_vm.instructionIndex >= _vm.program.Length && !suppressExitOnProgramEnd)) { SendExitedMessage(); } @@ -2231,7 +2283,7 @@ public class DebugMessage : IJsonable, IHasRawBytes public DebugMessageType type; - public virtual void ProcessJson(IJsonOperation op) + public virtual void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(id), ref id); op.IncludeField(nameof(type), ref type); @@ -2243,9 +2295,9 @@ public virtual void ProcessJson(IJsonOperation op) public class ExplodedMessage : DebugMessage { public string message; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(message), ref message); } } @@ -2255,9 +2307,9 @@ public class EvalMessage : DebugMessage public int frameIndex; public string expression; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(frameIndex), ref frameIndex); op.IncludeField(nameof(expression), ref expression); } @@ -2268,9 +2320,9 @@ public class SetVariableMessage : DebugMessage public int variableId; public int frameId; public string rhs; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(variableId), ref variableId); op.IncludeField(nameof(frameId), ref frameId); op.IncludeField(nameof(rhs), ref rhs); @@ -2280,9 +2332,9 @@ public override void ProcessJson(IJsonOperation op) public class EvalResponse : DebugMessage { public DebugEvalResult result; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(result), ref result); } } @@ -2292,9 +2344,9 @@ public class StepNextResponseMessage : DebugMessage public string reason; public int status; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(reason), ref reason); op.IncludeField(nameof(status), ref status); } @@ -2304,9 +2356,9 @@ public class ScopesMessage : DebugMessage { public List scopes = new List(); - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(scopes), ref scopes); } } @@ -2317,7 +2369,7 @@ public class DebugScope : IJsonable public string scopeName; public string evalName; public List variables = new List(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(id), ref id); op.IncludeField(nameof(scopeName), ref scopeName); @@ -2340,7 +2392,7 @@ public class DebugVariable : IJsonable // json ignored on purpose. public DebugRuntimeVariable runtimeVariable; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(id), ref id); op.IncludeField(nameof(name), ref name); @@ -2356,9 +2408,9 @@ public class StackFrameMessage : DebugMessage { public List frames; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(frames), ref frames); } } @@ -2366,9 +2418,9 @@ public override void ProcessJson(IJsonOperation op) public class HelloResponseMessage : DebugMessage { public int processId; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(processId), ref processId); } } diff --git a/FadeBasic/FadeBasic/Launch/ITestLaunchable.cs b/FadeBasic/FadeBasic/Launch/ITestLaunchable.cs new file mode 100644 index 0000000..8772cc5 --- /dev/null +++ b/FadeBasic/FadeBasic/Launch/ITestLaunchable.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using FadeBasic.Virtual; + +namespace FadeBasic.Launch +{ + /// + /// An that also carries a discovered test + /// manifest. The console-app launcher inspects this when handling + /// --fade-test=name / --fade-list-tests arguments. + /// Implementations include and + /// the generated launchable class baked into compiled console apps. + /// + public interface ITestLaunchable : ILaunchable + { + IReadOnlyList TestManifest { get; } + } +} diff --git a/FadeBasic/FadeBasic/Launch/LaunchUtil.cs b/FadeBasic/FadeBasic/Launch/LaunchUtil.cs index 49dec16..ccd3299 100644 --- a/FadeBasic/FadeBasic/Launch/LaunchUtil.cs +++ b/FadeBasic/FadeBasic/Launch/LaunchUtil.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Sockets; @@ -6,6 +7,7 @@ using System.Xml; using System.Xml.Linq; using FadeBasic.Json; +using FadeBasic.Sdk; using FadeBasic.Virtual; namespace FadeBasic.Launch @@ -31,10 +33,71 @@ public static string PackDebugData(DebugData data) public static DebugData UnpackDebugData(string base64Json) { + // Release builds skip debug-data emission, so the generated + // launcher hands us an empty string. Return an empty DebugData + // rather than letting the JSON parser index into "". + if (string.IsNullOrEmpty(base64Json)) return new DebugData(); var bytes = Convert.FromBase64String(base64Json); var json = Encoding.UTF8.GetString(bytes); return JsonableExtensions.FromJson(json); } + + public static string PackTestManifest(IReadOnlyList manifest) + { + var wrapper = new TestManifest(); + foreach (var entry in manifest) wrapper.entries.Add(entry); + var json = wrapper.Jsonify(); + var bytes = Encoding.UTF8.GetBytes(json); + return Convert.ToBase64String(bytes); + } + + public static IReadOnlyList UnpackTestManifest(string base64Json) + { + if (string.IsNullOrEmpty(base64Json)) return new List(); + var bytes = Convert.FromBase64String(base64Json); + var json = Encoding.UTF8.GetString(bytes); + var wrapper = JsonableExtensions.FromJson(json); + return wrapper.entries; + } + + /// + /// Resolve each manifest entry's source location through the given + /// , replacing the concatenated-source line/char + /// with in-file coordinates and stamping the originating + /// . Idempotent: an entry + /// whose sourceFilePath is already set is left alone, so calling + /// this twice (e.g., once in the SDK path and again in the build-task + /// generation path) doesn't double-shift line numbers. + /// + /// + /// Multi-.fbasic projects depend on this — the IDE Test Explorer + /// uses each entry's sourceFilePath to source-link the right + /// file when the user double-clicks a test. Without this remap, the + /// adapter has no way to associate a manifest entry with its origin. + /// + public static void ApplySourceMap(IReadOnlyList manifest, SourceMap map) + { + if (manifest == null || map == null) return; + foreach (var entry in manifest) + { + if (entry == null) continue; + if (!string.IsNullOrEmpty(entry.sourceFilePath)) continue; + try + { + var loc = map.GetOriginalLocation(entry.sourceLine, entry.sourceChar); + entry.sourceFilePath = loc.fileName; + entry.sourceLine = loc.startLine; + entry.sourceChar = loc.startChar; + } + catch + { + // SourceMap throws when the line falls outside any registered + // file (synthetic/computed positions). Leave the entry as-is + // — its sourceFilePath stays empty and the adapter falls + // back to omitting CodeFilePath rather than guessing. + } + } + } public static byte[] Unpack64(string encoded) { diff --git a/FadeBasic/FadeBasic/Launch/Launcher.cs b/FadeBasic/FadeBasic/Launch/Launcher.cs index 17738ad..951f816 100644 --- a/FadeBasic/FadeBasic/Launch/Launcher.cs +++ b/FadeBasic/FadeBasic/Launch/Launcher.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Sockets; using System.Threading; +using FadeBasic.Sdk; using FadeBasic.Virtual; namespace FadeBasic.Launch @@ -25,21 +27,35 @@ public class LaunchOptions public static readonly LaunchOptions DefaultOptions; static LaunchOptions() { - var debugEnv = Environment.GetEnvironmentVariable(ENV_ENABLE_DEBUG)?.ToLowerInvariant(); - var debugDontWait = Environment.GetEnvironmentVariable(ENV_ENABLE_DEBUG_DONT_WAIT)?.ToLowerInvariant(); + // Best-effort: in WASM there are no env vars / TCP sockets, and + // any throw here gets wrapped in a TypeInitializationException + // for every later access to ANY LaunchOptions field. Swallow + // failures so the type stays usable. DefaultOptions = new LaunchOptions { - debug = debugEnv == "true" || debugEnv == "1", + debug = false, debugPort = 0, - debugWaitForConnection = !(debugDontWait == "true" || debugDontWait == "1"), - debugLogPath = Environment.GetEnvironmentVariable(ENV_DEBUG_LOG_PATH) + debugWaitForConnection = true, + debugLogPath = null, }; + try + { + var debugEnv = Environment.GetEnvironmentVariable(ENV_ENABLE_DEBUG)?.ToLowerInvariant(); + var debugDontWait = Environment.GetEnvironmentVariable(ENV_ENABLE_DEBUG_DONT_WAIT)?.ToLowerInvariant(); + DefaultOptions.debug = debugEnv == "true" || debugEnv == "1"; + DefaultOptions.debugWaitForConnection = !(debugDontWait == "true" || debugDontWait == "1"); + DefaultOptions.debugLogPath = Environment.GetEnvironmentVariable(ENV_DEBUG_LOG_PATH); - if (!int.TryParse(Environment.GetEnvironmentVariable(ENV_DEBUG_PORT), out DefaultOptions.debugPort)) + if (!int.TryParse(Environment.GetEnvironmentVariable(ENV_DEBUG_PORT), out DefaultOptions.debugPort)) + { + DefaultOptions.debugPort = LaunchUtil.FreeTcpPort(); + } + } + catch { - DefaultOptions.debugPort = LaunchUtil.FreeTcpPort(); + // Browser / sandboxed environment — DefaultOptions retains + // the safe defaults set above. } - } } @@ -62,26 +78,167 @@ public static void Run(T instance, LaunchOptions options=null) where T : ILaunchable { options ??= LaunchOptions.DefaultOptions; - + var vm = new VirtualMachine(instance.Bytecode) { hostMethods = HostMethodTable.FromCommandCollection(instance.CommandCollection) }; - + if (!options.debug) { - vm.Execute2(0); // 0 means run until suspend. + vm.Execute2(0); // 0 means run until suspend. } else { var session = new DebugSession(vm, instance.DebugData, instance.CommandCollection, options); machineToDebugTable.Add(vm, (instance, session)); session.StartServer(); - session.DebugForever(); // needs infinite budget. + session.DebugForever(); // needs infinite budget. session.ShutdownServer(); - + + } + + } + + // Args parsing: recognized command-line forms. + public const string ArgFadeTest = "--fade-test"; + public const string ArgFadeListTests = "--fade-list-tests"; + public const string ArgFadeTestAll = "--fade-test-all"; + + /// + /// Console-app entry point that dispatches between normal program + /// execution and the test runner based on . + /// Returns the process exit code (0 = success, 1 = failure or no tests + /// found, 2 = unsupported launchable). + /// Recognized flags: + /// + /// --fade-test=name — run a single test, exit 0/1 on pass/fail. + /// --fade-test-all — run all tests, exit 0/1 on all-pass / any-fail. + /// --fade-list-tests — print test names (one per line) and exit. + /// + /// With no recognized flag, falls through to normal program execution. + /// + public static int Main(string[] args, LaunchOptions options=null) + where T : ILaunchable, new() + { + return Main(new T(), args, options); + } + + public static int Main(T instance, string[] args, LaunchOptions options=null) + where T : ILaunchable + { + if (args != null && args.Length > 0) + { + if (TryDispatchTestArgs(instance, args, out var exitCode)) + { + return exitCode; + } + } + Run(instance, options); + return 0; + } + + // Returns true if args contain a recognized test-runner flag, in which + // case `exitCode` is set. Returns false if no test flag matched (caller + // should fall through to normal program execution). + public static bool TryDispatchTestArgs(ILaunchable instance, string[] args, out int exitCode) + { + exitCode = 0; + string testName = null; + var testAll = false; + var listTests = false; + + for (var i = 0; i < args.Length; i++) + { + var a = args[i]; + if (a == ArgFadeListTests) { listTests = true; continue; } + if (a == ArgFadeTestAll) { testAll = true; continue; } + if (a == ArgFadeTest && i + 1 < args.Length) + { + testName = args[++i]; + continue; + } + if (a.StartsWith(ArgFadeTest + "=")) + { + testName = a.Substring(ArgFadeTest.Length + 1); + continue; + } + } + + if (!listTests && !testAll && testName == null) return false; + + if (!(instance is ITestLaunchable testInstance)) + { + Console.Error.WriteLine( + "fade: this program does not expose a test manifest " + + "(implement ITestLaunchable to enable --fade-test)."); + exitCode = 2; + return true; + } + + var hostMethods = HostMethodTable.FromCommandCollection(testInstance.CommandCollection); + + if (listTests) + { + foreach (var t in testInstance.TestManifest) + { + if (t.isAbstract) continue; + Console.WriteLine(t.name); + } + exitCode = 0; + return true; + } + + if (testAll) + { + exitCode = RunManyAndReport(testInstance.TestManifest, testInstance.Bytecode, hostMethods); + return true; + } + + // Single test by name. + var match = testInstance.TestManifest + .FirstOrDefault(t => string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)); + if (match == null) + { + Console.Error.WriteLine($"fade: no test named `{testName}` was found."); + exitCode = 1; + return true; + } + var result = FadeTestExecutor.RunTest(testInstance.Bytecode, hostMethods, match); + ReportResult(result); + exitCode = result.passed ? 0 : 1; + return true; + } + + static int RunManyAndReport(IReadOnlyList manifest, byte[] bytecode, HostMethodTable hostMethods) + { + var passed = 0; + var failed = 0; + foreach (var entry in manifest) + { + if (entry.isAbstract) continue; + var r = FadeTestExecutor.RunTest(bytecode, hostMethods, entry); + ReportResult(r); + if (r.passed) passed++; else failed++; + } + Console.WriteLine($"fade: {passed} passed, {failed} failed."); + return failed == 0 && (passed + failed) > 0 ? 0 : 1; + } + + static void ReportResult(FadeTestResult r) + { + if (r.passed) + { + Console.WriteLine($" PASS {r.testName} ({r.duration.TotalMilliseconds:F1} ms)"); + } + else + { + Console.WriteLine($" FAIL {r.testName} ({r.duration.TotalMilliseconds:F1} ms)"); + if (!string.IsNullOrEmpty(r.failureMessage)) + { + Console.WriteLine(" " + r.failureMessage); + } } - } } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Launch/RemoteDebugSession.cs b/FadeBasic/FadeBasic/Launch/RemoteDebugSession.cs index 5d344f2..29c3058 100644 --- a/FadeBasic/FadeBasic/Launch/RemoteDebugSession.cs +++ b/FadeBasic/FadeBasic/Launch/RemoteDebugSession.cs @@ -285,7 +285,7 @@ public class Breakpoint : IJsonable public int colNumber; public int status; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(lineNumber), ref lineNumber); op.IncludeField(nameof(colNumber), ref colNumber); @@ -296,9 +296,9 @@ public void ProcessJson(IJsonOperation op) public class RequestBreakpointMessage : DebugMessage { public List breakpoints; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(breakpoints), ref breakpoints); } } @@ -306,9 +306,9 @@ public override void ProcessJson(IJsonOperation op) public class ResponseBreakpointMessage : DebugMessage { public List breakpoints; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(breakpoints), ref breakpoints); } } @@ -317,9 +317,9 @@ public class DebugScopeRequest : DebugMessage { public int frameIndex; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(frameIndex), ref frameIndex); } } @@ -328,9 +328,9 @@ public class DebugVariableExpansionRequest : DebugMessage { public int variableId; - public override void ProcessJson(IJsonOperation op) + public override void ProcessJson(ref T op) { - base.ProcessJson(op); + base.ProcessJson(ref op); op.IncludeField(nameof(variableId), ref variableId); } } @@ -345,7 +345,7 @@ public class DebugEvalResult : IJsonable public DebugScope scope; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(id), ref id); op.IncludeField(nameof(value), ref value); @@ -377,7 +377,7 @@ public DebugStackFrame(int lineNumber, int colNumber) this.colNumber = colNumber; } - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(lineNumber), ref lineNumber); op.IncludeField(nameof(colNumber), ref colNumber); diff --git a/FadeBasic/FadeBasic/Lexer.cs b/FadeBasic/FadeBasic/Lexer.cs index cc701d5..d81a1ec 100644 --- a/FadeBasic/FadeBasic/Lexer.cs +++ b/FadeBasic/FadeBasic/Lexer.cs @@ -65,7 +65,24 @@ public enum LexemType KeywordDefer, KeywordEndDefer, - + + KeywordTest, + KeywordEndTest, + KeywordAbstract, + KeywordFrom, + KeywordRunto, + KeywordEndRunto, + KeywordMaxCycles, + KeywordAssert, + KeywordMock, + KeywordEndMock, + KeywordExitMock, + KeywordForbid, + KeywordClear, + KeywordMocks, + KeywordCallCount, + KeywordLen, + KeywordAs, KeywordTypeInteger, KeywordTypeByte, @@ -153,6 +170,114 @@ public class Lexer { private static Lexem LexemString = new Lexem(LexemType.LiteralString, new Regex("^\""), LexemFlags.MacroConcatable); private static Lexem LexemConstant = new Lexem(LexemType.Constant); + private static readonly List _sortedLexems; + private static readonly Dictionary _lexemForType; + private static readonly Dictionary _keywords; + private static readonly Regex _rxConstant; + + private readonly StringBuilder _strBuffer = new StringBuilder(); + + static Lexer() + { + _sortedLexems = Lexems + .Select(l => l.regex == null + ? l + : new Lexem(l.priority, l.type, + new Regex(l.regex.ToString(), l.regex.Options | RegexOptions.IgnoreCase), + l.flags)) + .OrderBy(l => l.priority) + .ToList(); + + _lexemForType = new Dictionary(); + foreach (var l in Lexems) + { + if (!_lexemForType.ContainsKey(l.type)) + _lexemForType[l.type] = l; + } + foreach (LexemType lt in Enum.GetValues(typeof(LexemType))) + { + if (!_lexemForType.ContainsKey(lt)) + _lexemForType[lt] = new Lexem(lt); + } + + _rxConstant = _sortedLexems.Find(l => l.type == LexemType.Constant).regex; + + _keywords = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["for"] = LexemType.KeywordFor, + ["to"] = LexemType.KeywordTo, + ["step"] = LexemType.KeywordStep, + ["next"] = LexemType.KeywordNext, + ["endfunction"] = LexemType.KeywordEndFunction, + ["function"] = LexemType.KeywordFunction, + ["exitfunction"] = LexemType.KeywordExitFunction, + ["do"] = LexemType.KeywordDo, + ["loop"] = LexemType.KeywordLoop, + ["select"] = LexemType.KeywordSelect, + ["endselect"] = LexemType.KeywordEndSelect, + ["case"] = LexemType.KeywordCase, + ["endcase"] = LexemType.KeywordEndCase, + ["default"] = LexemType.KeywordCaseDefault, + ["repeat"] = LexemType.KeywordRepeat, + ["until"] = LexemType.KeywordUntil, + ["local"] = LexemType.KeywordScope, + ["global"] = LexemType.KeywordScope, + ["if"] = LexemType.KeywordIf, + ["endif"] = LexemType.KeywordEndIf, + ["else"] = LexemType.KeywordElse, + ["then"] = LexemType.KeywordThen, + ["end"] = LexemType.KeywordEnd, + ["exit"] = LexemType.KeywordExit, + ["skip"] = LexemType.KeywordSkip, + ["enddefer"] = LexemType.KeywordEndDefer, + ["defer"] = LexemType.KeywordDefer, + ["endtest"] = LexemType.KeywordEndTest, + ["test"] = LexemType.KeywordTest, + ["abstract"] = LexemType.KeywordAbstract, + ["from"] = LexemType.KeywordFrom, + ["endrunto"] = LexemType.KeywordEndRunto, + ["runto"] = LexemType.KeywordRunto, + ["assert"] = LexemType.KeywordAssert, + ["endmock"] = LexemType.KeywordEndMock, + ["mocks"] = LexemType.KeywordMocks, + ["mock"] = LexemType.KeywordMock, + ["exitmock"] = LexemType.KeywordExitMock, + ["forbid"] = LexemType.KeywordForbid, + ["clear"] = LexemType.KeywordClear, + ["goto"] = LexemType.KeywordGoto, + ["gosub"] = LexemType.KeywordGoSub, + ["return"] = LexemType.KeywordReturn, + ["dim"] = LexemType.KeywordDeclareArray, + ["redim"] = LexemType.KeywordReDimArray, + ["remstart"] = LexemType.KeywordRemStart, + ["remend"] = LexemType.KeywordRemEnd, + ["rem"] = LexemType.KeywordRem, + ["type"] = LexemType.KeywordType, + ["endtype"] = LexemType.KeywordEndType, + ["while"] = LexemType.KeywordWhile, + ["endwhile"] = LexemType.KeywordEndWhile, + ["as"] = LexemType.KeywordAs, + ["boolean"] = LexemType.KeywordTypeBoolean, + ["bool"] = LexemType.KeywordTypeBoolean, + ["byte"] = LexemType.KeywordTypeByte, + ["integer"] = LexemType.KeywordTypeInteger, + ["int"] = LexemType.KeywordTypeInteger, + ["word"] = LexemType.KeywordTypeWord, + ["ushort"] = LexemType.KeywordTypeWord, + ["dword"] = LexemType.KeywordTypeDWord, + ["uint"] = LexemType.KeywordTypeDWord, + ["long"] = LexemType.KeywordTypeDoubleInteger, + ["float"] = LexemType.KeywordTypeFloat, + ["double"] = LexemType.KeywordTypeDoubleFloat, + ["string"] = LexemType.KeywordTypeString, + ["not"] = LexemType.KeywordNot, + ["and"] = LexemType.KeywordAnd, + ["or"] = LexemType.KeywordOr, + ["xor"] = LexemType.KeywordXor, + ["mod"] = LexemType.OpMod, + ["len"] = LexemType.KeywordLen, + }; + } // private static Lexem LexemConstantBegin = new Lexem(LexemType.Constant); // private static Lexem LexemConstant = new Lexem(LexemType.Constant); public static List Lexems = new List @@ -231,7 +356,30 @@ public class Lexer new Lexem(LexemType.KeywordEndDefer, new Regex("^enddefer")), new Lexem(LexemType.KeywordDefer, new Regex("^defer")), - + + new Lexem(LexemType.KeywordEndTest, new Regex("^endtest\\b")), + new Lexem(LexemType.KeywordTest, new Regex("^test\\b")), + new Lexem(LexemType.KeywordAbstract, new Regex("^abstract\\b")), + new Lexem(LexemType.KeywordFrom, new Regex("^from\\b")), + new Lexem(LexemType.KeywordEndRunto, new Regex("^endrunto\\b")), + new Lexem(LexemType.KeywordRunto, new Regex("^runto\\b")), + // Multi-word keyword: `max cycles`. Matches one or more spaces/tabs + // between the two words; ranks higher (more specific) than VariableGeneral. + new Lexem(-2, LexemType.KeywordMaxCycles, new Regex("^max[ \\t]+cycles\\b")), + new Lexem(LexemType.KeywordAssert, new Regex("^assert\\b")), + + new Lexem(LexemType.KeywordEndMock, new Regex("^endmock\\b")), + new Lexem(LexemType.KeywordMocks, new Regex("^mocks\\b")), + new Lexem(LexemType.KeywordMock, new Regex("^mock\\b")), + new Lexem(LexemType.KeywordExitMock, new Regex("^exitmock\\b")), + new Lexem(LexemType.KeywordForbid, new Regex("^forbid\\b")), + new Lexem(LexemType.KeywordClear, new Regex("^clear\\b")), + // Multi-word keyword: `call count`. Higher priority (-2) so it + // matches before VariableGeneral; users who write `call` alone + // (or `call somethingElse`) still get a VariableGeneral token. + new Lexem(-2, LexemType.KeywordCallCount, new Regex("^call[ \\t]+count\\b")), + new Lexem(-2, LexemType.KeywordLen, new Regex("^len\\b")), + new Lexem(LexemType.KeywordGoto, new Regex("^goto")), new Lexem(LexemType.KeywordGoSub, new Regex("^gosub")), new Lexem(LexemType.KeywordReturn, new Regex("^return")), @@ -292,6 +440,228 @@ public List Tokenize(string input, CommandCollection commands = default) return res.tokens; } + private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c == '_'; + private static bool IsHexDigit(char c) => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + + private static bool LineIsKeyword(string src, int absStart, int lineEnd, string kw) + { + int end = absStart + kw.Length; + if (end > lineEnd) return false; + if (string.Compare(src, absStart, kw, 0, kw.Length, StringComparison.OrdinalIgnoreCase) != 0) return false; + return end >= lineEnd || !IsWordChar(src[end]); + } + + private static bool CharPositionStartsWith(string src, int absStart, int lineEnd, string keyword) => + absStart + keyword.Length <= lineEnd && + string.Compare(src, absStart, keyword, 0, keyword.Length, StringComparison.OrdinalIgnoreCase) == 0; + + private static bool TryMatchManual( + string src, int absStart, int lineEnd, + out Lexem lexemOut, out int lengthOut, out Match matchOut) + { + matchOut = null; + lexemOut = null; + lengthOut = 0; + + if (absStart >= lineEnd) return false; + char c = src[absStart]; + int start = absStart; + + switch (c) + { + case ':': lexemOut = _lexemForType[LexemType.EndStatement]; lengthOut = 1; return true; + case ',': lexemOut = _lexemForType[LexemType.ArgSplitter]; lengthOut = 1; return true; + case '(': lexemOut = _lexemForType[LexemType.ParenOpen]; lengthOut = 1; return true; + case ')': lexemOut = _lexemForType[LexemType.ParenClose]; lengthOut = 1; return true; + case '{': lexemOut = _lexemForType[LexemType.BracketOpen]; lengthOut = 1; return true; + case '}': lexemOut = _lexemForType[LexemType.BracketClose]; lengthOut = 1; return true; + case '+': lexemOut = _lexemForType[LexemType.OpPlus]; lengthOut = 1; return true; + case '-': lexemOut = _lexemForType[LexemType.OpMinus]; lengthOut = 1; return true; + case '*': lexemOut = _lexemForType[LexemType.OpMultiply]; lengthOut = 1; return true; + case '/': lexemOut = _lexemForType[LexemType.OpDivide]; lengthOut = 1; return true; + case '^': lexemOut = _lexemForType[LexemType.OpPower]; lengthOut = 1; return true; + case '=': lexemOut = _lexemForType[LexemType.OpEqual]; lengthOut = 1; return true; + case '[': lexemOut = _lexemForType[LexemType.ConstantBracketOpen]; lengthOut = 1; return true; + case ']': lexemOut = _lexemForType[LexemType.ConstantBracketClose]; lengthOut = 1; return true; + + case '`': + lexemOut = _lexemForType[LexemType.KeywordRem]; + lengthOut = lineEnd - start; + return true; + + case '&': + if (start + 1 < lineEnd && src[start + 1] == '&') + { lexemOut = _lexemForType[LexemType.OpBitwiseAnd]; lengthOut = 2; return true; } + return false; + case '|': + if (start + 1 < lineEnd && src[start + 1] == '|') + { lexemOut = _lexemForType[LexemType.OpBitwiseOr]; lengthOut = 2; return true; } + return false; + case '~': + if (start + 1 < lineEnd && src[start + 1] == '~') + { lexemOut = _lexemForType[LexemType.OpBitwiseXor]; lengthOut = 2; return true; } + return false; + + case '>': + { + char next = start + 1 < lineEnd ? src[start + 1] : '\0'; + if (next == '=') { lexemOut = _lexemForType[LexemType.OpGte]; lengthOut = 2; return true; } + if (next == '>') { lexemOut = _lexemForType[LexemType.OpBitwiseRightShift]; lengthOut = 2; return true; } + lexemOut = _lexemForType[LexemType.OpGt]; lengthOut = 1; return true; + } + case '<': + { + char next = start + 1 < lineEnd ? src[start + 1] : '\0'; + if (next == '=') { lexemOut = _lexemForType[LexemType.OpLte]; lengthOut = 2; return true; } + if (next == '<') { lexemOut = _lexemForType[LexemType.OpBitwiseLeftShift]; lengthOut = 2; return true; } + if (next == '>') { lexemOut = _lexemForType[LexemType.OpNotEqual]; lengthOut = 2; return true; } + lexemOut = _lexemForType[LexemType.OpLt]; lengthOut = 1; return true; + } + + case '.': + { + char next = start + 1 < lineEnd ? src[start + 1] : '\0'; + if (next == '.') { lexemOut = _lexemForType[LexemType.OpBitwiseNot]; lengthOut = 2; return true; } + if (next >= '0' && next <= '9') + { + int end = start + 2; + while (end < lineEnd && char.IsDigit(src[end])) end++; + lexemOut = _lexemForType[LexemType.LiteralReal]; + lengthOut = end - start; + return true; + } + lexemOut = _lexemForType[LexemType.FieldSplitter]; lengthOut = 1; return true; + } + + case '%': + { + int end = start + 1; + while (end < lineEnd && (src[end] == '0' || src[end] == '1')) end++; + if (end > start + 1) { lexemOut = _lexemForType[LexemType.LiteralBinary]; lengthOut = end - start; return true; } + return false; + } + + case '#': + { + if (LineIsKeyword(src, start + 1, lineEnd, "endtokenize")) { lexemOut = _lexemForType[LexemType.ConstantEndTokenize]; lengthOut = 12; return true; } + if (LineIsKeyword(src, start + 1, lineEnd, "endmacro")) { lexemOut = _lexemForType[LexemType.ConstantEnd]; lengthOut = 9; return true; } + if (LineIsKeyword(src, start + 1, lineEnd, "tokenize")) { lexemOut = _lexemForType[LexemType.ConstantTokenize]; lengthOut = 9; return true; } + if (LineIsKeyword(src, start + 1, lineEnd, "macro")) { lexemOut = _lexemForType[LexemType.ConstantBegin]; lengthOut = 6; return true; } + var constSub = src.Substring(start, lineEnd - start); + var constMatch = _rxConstant.Match(constSub); + if (constMatch.Success) + { + matchOut = constMatch; + lexemOut = _lexemForType[LexemType.Constant]; + lengthOut = constMatch.Length; + return true; + } + // Bare '#' — VariableReal with empty identifier prefix (matches original regex behaviour) + lexemOut = _lexemForType[LexemType.VariableReal]; lengthOut = 1; return true; + } + + case ' ': + case '\t': + case '\r': + case '\n': + { + int end = start + 1; + while (end < lineEnd && (src[end] == ' ' || src[end] == '\t' || src[end] == '\r' || src[end] == '\n')) end++; + lexemOut = _lexemForType[LexemType.WhiteSpace]; + lengthOut = end - start; + return true; + } + + default: + { + if (c == '$') + { lexemOut = _lexemForType[LexemType.VariableString]; lengthOut = 1; return true; } + + if (c >= '0' && c <= '9') + { + char next = start + 1 < lineEnd ? src[start + 1] : '\0'; + if (c == '0' && (next == 'x' || next == 'X')) + { + int end = start + 2; + while (end < lineEnd && IsHexDigit(src[end])) end++; + if (end > start + 2) { lexemOut = _lexemForType[LexemType.LiteralHex]; lengthOut = end - start; return true; } + return false; + } + if (c == '0' && (next == 'c' || next == 'C')) + { + int end = start + 2; + while (end < lineEnd && src[end] >= '0' && src[end] <= '7') end++; + if (end > start + 2) { lexemOut = _lexemForType[LexemType.LiteralOctal]; lengthOut = end - start; return true; } + return false; + } + int iEnd = start + 1; + while (iEnd < lineEnd && char.IsDigit(src[iEnd])) iEnd++; + if (iEnd < lineEnd && src[iEnd] == '.') + { + int rEnd = iEnd + 1; + while (rEnd < lineEnd && char.IsDigit(src[rEnd])) rEnd++; + lexemOut = _lexemForType[LexemType.LiteralReal]; lengthOut = rEnd - start; return true; + } + if (iEnd < lineEnd && src[iEnd] == '$') + { lexemOut = _lexemForType[LexemType.VariableString]; lengthOut = iEnd - start + 1; return true; } + if (iEnd < lineEnd && src[iEnd] == '#') + { lexemOut = _lexemForType[LexemType.VariableReal]; lengthOut = iEnd - start + 1; return true; } + lexemOut = _lexemForType[LexemType.LiteralInt]; lengthOut = iEnd - start; return true; + } + + if (char.IsLetter(c) || c == '_') + { + int idEnd = start + 1; + while (idEnd < lineEnd && IsWordChar(src[idEnd])) idEnd++; + + if (idEnd < lineEnd && src[idEnd] == '$') + { lexemOut = _lexemForType[LexemType.VariableString]; lengthOut = idEnd - start + 1; return true; } + if (idEnd < lineEnd && src[idEnd] == '#') + { lexemOut = _lexemForType[LexemType.VariableReal]; lengthOut = idEnd - start + 1; return true; } + + var ident = src.Substring(start, idEnd - start); + if (!_keywords.TryGetValue(ident, out var kwType)) + { + int identLen = idEnd - start; + if (identLen == 3 && string.Compare(src, start, "max", 0, 3, StringComparison.OrdinalIgnoreCase) == 0) + { + int p = idEnd; + while (p < lineEnd && (src[p] == ' ' || src[p] == '\t')) p++; + if (p > idEnd && LineIsKeyword(src, p, lineEnd, "cycles")) + { lexemOut = _lexemForType[LexemType.KeywordMaxCycles]; lengthOut = p + 6 - start; return true; } + } + else if (identLen == 4 && string.Compare(src, start, "call", 0, 4, StringComparison.OrdinalIgnoreCase) == 0) + { + int p = idEnd; + while (p < lineEnd && (src[p] == ' ' || src[p] == '\t')) p++; + if (p > idEnd && LineIsKeyword(src, p, lineEnd, "count")) + { lexemOut = _lexemForType[LexemType.KeywordCallCount]; lengthOut = p + 5 - start; return true; } + } + lexemOut = _lexemForType[LexemType.VariableGeneral]; lengthOut = idEnd - start; return true; + } + + if (kwType == LexemType.KeywordRem) + { lexemOut = _lexemForType[LexemType.KeywordRem]; lengthOut = lineEnd - start; return true; } + + if (kwType == LexemType.KeywordTypeDoubleFloat) + { + if (idEnd < lineEnd && src[idEnd] == ' ') + { + if (LineIsKeyword(src, idEnd + 1, lineEnd, "float")) + { lexemOut = _lexemForType[LexemType.KeywordTypeDoubleFloat]; lengthOut = idEnd + 6 - start; return true; } + if (LineIsKeyword(src, idEnd + 1, lineEnd, "integer")) + { lexemOut = _lexemForType[LexemType.KeywordTypeDoubleInteger]; lengthOut = idEnd + 8 - start; return true; } + } + } + + lexemOut = _lexemForType[kwType]; lengthOut = idEnd - start; return true; + } + + return false; + } + } + } + public LexerResults TokenizeWithErrors(string input, CommandCollection commands=default) { var tokens = new List(); @@ -309,9 +679,8 @@ void AddToken(Token t) all.Add(t); } - void FlushEos(ref bool requestEoS, int requestEoSCharNumber, string[] lines, int lineNumber, Lexem eolLexem, TokenFlags flags) + void FlushEos(ref bool requestEoS, int requestEoSCharNumber, int[] lineEndsArr, int[] lineStartsArr, int lineNumber, Lexem eolLexem, TokenFlags flags) { - if (requestEoS) { requestEoS = false; @@ -319,21 +688,19 @@ void FlushEos(ref bool requestEoS, int requestEoSCharNumber, string[] lines, int var previousToken = all.LastOrDefault(); var cn = previousToken == null ? requestEoSCharNumber - : lines[previousToken.lineNumber].Length + 1; // synthetic index. + : lineEndsArr[previousToken.lineNumber] - lineStartsArr[previousToken.lineNumber] + 1; // synthetic index var ln = previousToken == null ? lineNumber : previousToken.lineNumber; AddToken(new Token { - charNumber = cn , + charNumber = cn, lexem = eolLexem, lineNumber = ln, caseInsensitiveRaw = "\n", flags = flags - }); } - } void AddComment(Token t) @@ -345,32 +712,42 @@ void AddComment(Token t) var errors = new List(); - var constantTable = new Dictionary(comparer: StringComparer.InvariantCultureIgnoreCase); + var constantTable = new Dictionary(comparer: StringComparer.InvariantCultureIgnoreCase); - var lexems = Lexems.ToList(); - lexems.Sort((a, b) => + // Build line boundary index without allocating per-line strings. + int lineCount = 1; + for (int ci = 0; ci < input.Length; ci++) if (input[ci] == '\n') lineCount++; + var lineStarts = new int[lineCount]; + var lineEnds = new int[lineCount]; { - var prioCompare = a.priority.CompareTo(b.priority); - return prioCompare; - // if (prioCompare == 0) - // { - // - // } - }); - - var lines = input.Split(new string[]{"\n"}, StringSplitOptions.None); + int li = 0; + lineStarts[0] = 0; + for (int ci = 0; ci < input.Length; ci++) + { + if (input[ci] == '\n') + { + lineEnds[li] = ci; // end of this line (exclusive, points at \n) + lineStarts[++li] = ci + 1; + } + } + lineEnds[lineCount - 1] = input.Length; + } var eolLexem = new Lexem(LexemType.EndStatement, null); Token remBlockToken = null; - var remBlockSb = new StringBuilder(); var requestEoS = false; var requestEoSCharNumber = 0; - for (var lineNumber = 0; lineNumber < lines.Length; lineNumber++) + for (var lineNumber = 0; lineNumber < lineCount; lineNumber++) { - - var line = lines[lineNumber]; - if (string.IsNullOrEmpty(line)) + // src/lineStart allow the mutation path (constant substitution) to swap in a + // local string without touching the original input or the line-index arrays. + string src = input; + int lineStart = lineStarts[lineNumber]; + int lineLen = lineEnds[lineNumber] - lineStart; + int lineEnd = lineStart + lineLen; // absolute end of line content in src + + if (lineLen == 0) { if (remBlockToken != null) { @@ -380,7 +757,9 @@ void AddComment(Token t) charNumber = 0, caseInsensitiveRaw = Environment.NewLine, raw = Environment.NewLine, - lexem = remBlockToken.lexem + type = remBlockToken.type, + lexemFlags = remBlockToken.lexemFlags + // lexem = remBlockToken.lexem }); } } @@ -392,27 +771,23 @@ void AddComment(Token t) charNumber = 0, lineNumber = lineNumber, lexem = new Lexem(LexemType.KeywordRem, null), - }; } var charNumberMacroOffset = 0; var macroUntilCharNumber = -1; - for (var charNumber = 0; charNumber < line.Length; charNumber = charNumber) + for (var charNumber = 0; charNumber < lineLen; charNumber = charNumber) { var foundMatch = false; - var sub = line.Substring(charNumber); - var subStr = sub.ToLowerInvariant(); - var isStillMacro = charNumber+charNumberMacroOffset < macroUntilCharNumber; + var isStillMacro = charNumber + charNumberMacroOffset < macroUntilCharNumber; var flags = TokenFlags.None; if (isStillMacro) { flags |= TokenFlags.IsConstant; } - - - if (remBlockToken == null && subStr.StartsWith("remstart")) + + if (remBlockToken == null && CharPositionStartsWith(src, lineStart + charNumber, lineEnd, "remstart")) { // we are remmin' remBlockToken = new Token @@ -422,21 +797,21 @@ void AddComment(Token t) lineNumber = lineNumber, }; continue; - } - if (remBlockToken != null && subStr.StartsWith("remend")) + } + if (remBlockToken != null && CharPositionStartsWith(src, lineStart + charNumber, lineEnd, "remend")) { // we are done remmin' for now. - remBlockToken.raw = line.Substring(remBlockToken.charNumber, + remBlockToken.raw = src.Substring(lineStart + remBlockToken.charNumber, (charNumber - remBlockToken.charNumber) + "remend".Length); remBlockToken.caseInsensitiveRaw = remBlockToken.raw.ToLowerInvariant(); - - + + AddComment(remBlockToken); remBlockToken = null; charNumber += "remend".Length; continue; - } + } if (remBlockToken != null) { // we are still remmin' @@ -445,10 +820,9 @@ void AddComment(Token t) } Token bestToken = null; - MatchCollection bestMatches = null; + Match bestMatch = null; - var hadRemCandidate = false; - var isStringParse = subStr.Length > 0 && subStr[0] == '"'; + var isStringParse = charNumber < lineLen && src[lineStart + charNumber] == '"'; if (isStringParse) { @@ -461,39 +835,36 @@ void AddComment(Token t) var matchedEnd = false; int strIndex = 0; var charOffset = 0; - var strBuffer = new StringBuilder(); - strBuffer.Append('"'); + _strBuffer.Clear(); + _strBuffer.Append('"'); for (strIndex = charNumber + 1; - strIndex < line.Length; - strIndex++) + strIndex < lineLen; + strIndex++) { - var strChar = line[strIndex]; + var strChar = src[lineStart + strIndex]; switch (strChar) { case '"': - // exit the loop, the string's bounds are found. strIndex++; - strBuffer.Append('"'); + _strBuffer.Append('"'); matchedEnd = true; break; case '\\': - // there must be a second character - if (strIndex == line.Length - 1) + if (strIndex == lineLen - 1) { // this is the last character in the line, but it cannot be. //throw new InvalidOperationException(); // TODO: replace with lexer error } - // move forwards strIndex++; charOffset++; - strBuffer.Append(line[strIndex]); + _strBuffer.Append(src[lineStart + strIndex]); break; default: - strBuffer.Append(strChar); + _strBuffer.Append(strChar); break; } @@ -502,7 +873,7 @@ void AddComment(Token t) if (!matchedEnd) { - var text = line.Substring(charNumber); + var text = src.Substring(lineStart + charNumber, lineLen - charNumber); errors.Add(new ParseError(new Token { raw = text, @@ -510,11 +881,10 @@ void AddComment(Token t) lineNumber = lineNumber, charNumber = charNumber, }, ErrorCodes.LexerStringNeedsEnd, text)); - } - var insensitiveRaw = line.Substring(charNumber, strIndex - charNumber); - var stringLiteralSubStr = strBuffer.ToString(); + var insensitiveRaw = src.Substring(lineStart + charNumber, strIndex - charNumber); + var stringLiteralSubStr = _strBuffer.ToString(); bestToken = new Token { caseInsensitiveRaw = insensitiveRaw.ToLowerInvariant(), @@ -523,44 +893,25 @@ void AddComment(Token t) lineNumber = lineNumber, charNumber = charNumber + charNumberMacroOffset, flags = flags - }; foundMatch = true; charNumber += charOffset; } - - for (var lexemId = 0; lexemId < lexems.Count && !isStringParse; lexemId++) - { - var lexem = lexems[lexemId]; - var matches = lexem.regex.Matches(subStr); - if (matches.Count == 1) - { - foundMatch = true; - - var token = new Token - { - caseInsensitiveRaw = matches[0].Value, - raw = sub.Substring(matches[0].Index, matches[0].Length), - lexem = lexem, - lineNumber = lineNumber, - charNumber = charNumber + charNumberMacroOffset, - flags = flags - - }; - - if (bestToken == null || token.Length > bestToken.Length) - { - bestToken = token; - bestMatches = matches; - } - - // break; - } - else if (matches.Count > 1) + if (!isStringParse && TryMatchManual(src, lineStart + charNumber, lineEnd, out var bestLexem, out var bestLength, out var manualMatch)) + { + bestMatch = manualMatch; + foundMatch = true; + var rawStr = src.Substring(lineStart + charNumber, bestLength); + bestToken = new Token { - throw new Exception("Token exception! Too many matches!"); - } + caseInsensitiveRaw = rawStr.ToLowerInvariant(), + raw = rawStr, + lexem = bestLexem, + lineNumber = lineNumber, + charNumber = charNumber + charNumberMacroOffset, + flags = flags + }; } if (bestToken != null) @@ -568,7 +919,7 @@ void AddComment(Token t) switch (bestToken.type) { case LexemType.KeywordRem: - FlushEos(ref requestEoS, requestEoSCharNumber, lines, lineNumber, eolLexem, flags); + FlushEos(ref requestEoS, requestEoSCharNumber, lineEnds, lineStarts, lineNumber, eolLexem, flags); AddComment(bestToken); break; case LexemType.WhiteSpace: @@ -585,10 +936,9 @@ void AddComment(Token t) break; case LexemType.Constant: // replace all instances of string... - var toRemoveMatch = bestMatches[0].Groups[1]; - // var toRemove = bestMatches[0].Groups[1].Value; + var toRemoveMatch = bestMatch.Groups[1]; var toRemove = bestToken.raw.Substring(toRemoveMatch.Index, toRemoveMatch.Length); - var toAdd = bestMatches[0].Groups[2].Value; + var toAdd = bestMatch.Groups[2].Value; macroTokens.Add(bestToken); all.Add(bestToken); @@ -600,12 +950,16 @@ void AddComment(Token t) break; case LexemType.VariableGeneral when constantTable.TryGetValue(bestToken.caseInsensitiveRaw, out var replacement): - var prefix = line.Substring(0, charNumber); - var suffix = line.Substring(charNumber + bestToken.Length); + var prefix = src.Substring(lineStart, charNumber); + var suffix = src.Substring(lineStart + charNumber + bestToken.Length, lineLen - charNumber - bestToken.Length); var replacementLine = prefix + replacement + suffix; - charNumberMacroOffset += line.Length - replacementLine.Length; - line = replacementLine; + charNumberMacroOffset += lineLen - replacementLine.Length; + // Switch src to the mutated line string; reset lineStart to 0 within it. + src = replacementLine; + lineStart = 0; + lineLen = replacementLine.Length; + lineEnd = lineLen; macroUntilCharNumber = charNumber + bestToken.Length; bestToken.lexem = new Lexem(LexemType.Constant); @@ -615,7 +969,7 @@ when constantTable.TryGetValue(bestToken.caseInsensitiveRaw, out var replacement break; default: - FlushEos(ref requestEoS, requestEoSCharNumber, lines, lineNumber, eolLexem, flags); + FlushEos(ref requestEoS, requestEoSCharNumber, lineEnds, lineStarts, lineNumber, eolLexem, flags); // if (requestEoS) // { // requestEoS = false; @@ -649,56 +1003,53 @@ when constantTable.TryGetValue(bestToken.caseInsensitiveRaw, out var replacement if (!foundMatch) { + var errText = src.Substring(lineStart + charNumber, lineLen - charNumber); errors.Add(new ParseError(new Token { - raw = sub, - caseInsensitiveRaw = sub.ToLowerInvariant(), + raw = errText, + caseInsensitiveRaw = errText.ToLowerInvariant(), lineNumber = lineNumber, charNumber = charNumber, - }, ErrorCodes.LexerUnmatchedText, sub)); - - charNumber += sub.Length; - // throw new Exception($"Token exception! No match for {subStr} at {lineNumber}:{charNumber}"); + }, ErrorCodes.LexerUnmatchedText, errText)); + + charNumber = lineLen; } } - if (remBlockToken != null) { // commit - remBlockToken.raw = line.Substring(remBlockToken.charNumber); + remBlockToken.raw = src.Substring(lineStart + remBlockToken.charNumber, lineLen - remBlockToken.charNumber); remBlockToken.caseInsensitiveRaw = remBlockToken.raw.ToLowerInvariant(); AddComment(remBlockToken); } - - var previousTokenWasNotEoS = tokens.Count > 0 - ? tokens[tokens.Count - 1].type != LexemType.EndStatement + + var previousTokenWasNotEoS = tokens.Count > 0 + ? tokens[tokens.Count - 1].type != LexemType.EndStatement : false; - var previousTokenWasNotArgSplitter = tokens.Count > 0 - ? tokens[tokens.Count - 1].type != LexemType.ArgSplitter + var previousTokenWasNotArgSplitter = tokens.Count > 0 + ? tokens[tokens.Count - 1].type != LexemType.ArgSplitter : true; - var previousTokenWasNotTokenize = tokens.Count > 0 - ? tokens[tokens.Count - 1].type != LexemType.ConstantTokenize + var previousTokenWasNotTokenize = tokens.Count > 0 + ? tokens[tokens.Count - 1].type != LexemType.ConstantTokenize : true; - - // if the next token is an arg splitter, than we don't want an EoS either... + if (previousTokenWasNotEoS && previousTokenWasNotArgSplitter && previousTokenWasNotTokenize) { requestEoS = true; - requestEoSCharNumber = line.Length; + requestEoSCharNumber = lineLen; } - } - + if (requestEoS) { requestEoS = false; var previousToken = all.LastOrDefault(); var cn = previousToken == null ? requestEoSCharNumber - : lines[previousToken.lineNumber].Length + 1; // synthetic index. + : lineEnds[previousToken.lineNumber] - lineStarts[previousToken.lineNumber] + 1; // synthetic index var ln = previousToken == null - ? lines.Length - 1 + ? lineCount - 1 : previousToken.lineNumber; AddToken(new Token { @@ -721,10 +1072,10 @@ when constantTable.TryGetValue(bestToken.caseInsensitiveRaw, out var replacement constantTable = constantTable }; - // add the runtime commands in. - HandleCommandNames(lines, results, runtimeCommandTree); + // add the runtime commands in. + HandleCommandNames(input, lineStarts, results, runtimeCommandTree); - HandleMacros2(lines, results, commands); + HandleMacros2(results, commands); return results; } @@ -749,13 +1100,13 @@ class TokenizeBlock } - void HandleCommandNames(string[] lines, LexerResults results, CommandNameTree tree) + void HandleCommandNames(string input, int[] lineStarts, LexerResults results, CommandNameTree tree) { - HandleCommandNames(lines, results.tokens, tree); - HandleCommandNames(lines, results.combinedTokens, tree); - HandleCommandNames(lines, results.allTokens, tree); + HandleCommandNames(input, lineStarts, results.tokens, tree); + HandleCommandNames(input, lineStarts, results.combinedTokens, tree); + HandleCommandNames(input, lineStarts, results.allTokens, tree); } - void HandleCommandNames(string[] lines, List tokens, CommandNameTree tree) + void HandleCommandNames(string input, int[] lineStarts, List tokens, CommandNameTree tree) { /* * The goal is to find token spans that match the command names, @@ -767,6 +1118,10 @@ void HandleCommandNames(string[] lines, List tokens, CommandNameTree tree for (var i = 0; i < tokens.Count; i++) { var token = tokens[i]; + // Don't rewrite a token that's already been tagged as a + // language keyword. Words like `len` collide with legacy + // host commands but the keyword wins. + if (token.type == LexemType.KeywordLen) continue; var curr = tree; var j = i; while (tokens[j].caseInsensitiveRaw != null && curr.sub.TryGetValue(tokens[j].caseInsensitiveRaw, out var next)) @@ -790,7 +1145,7 @@ void HandleCommandNames(string[] lines, List tokens, CommandNameTree tree var firstChar = first.charNumber; var lastChar = last.EndCharNumber; - var raw = lines[first.lineNumber].Substring(firstChar, lastChar - firstChar); + var raw = input.Substring(lineStarts[first.lineNumber] + firstChar, lastChar - firstChar); // var raw = string.Join(" ", subset.Select(x => x.raw)); tokens[i] = new Token @@ -817,7 +1172,7 @@ void HandleCommandNames(string[] lines, List tokens, CommandNameTree tree } } - void HandleMacros2(string[] lines, LexerResults current, CommandCollection commands) + void HandleMacros2(LexerResults current, CommandCollection commands) { var stream = new TokenStream(current.tokens); // var macroCommandNames = commands.Commands.Where(c => c.usage.HasFlag(FadeBasicCommandUsage.Macro)).Select(c => c.name).ToList(); @@ -1326,8 +1681,8 @@ void InsertToken(int macroBlockIndex, Token t) } var neighborToken = current.tokens[index]; var isNextToCompilerToken = neighborToken.flags.HasFlag(TokenFlags.IsCompileTime); - var bothConcatable = neighborToken.lexem.flags.HasFlag(LexemFlags.MacroConcatable) && - t.lexem.flags.HasFlag(LexemFlags.MacroConcatable); + var bothConcatable = neighborToken.lexemFlags.HasFlag(LexemFlags.MacroConcatable) && + t.lexemFlags.HasFlag(LexemFlags.MacroConcatable); if (bothConcatable) { @@ -1641,13 +1996,24 @@ public override string ToString() public int charNumber; public string raw; public string caseInsensitiveRaw; + public LexemType type = LexemType.EOF; + public LexemFlags lexemFlags; + public TokenFlags flags = TokenFlags.None; public int Length => caseInsensitiveRaw?.Length ?? 0; public int EndCharNumber => charNumber + Length; - public LexemType type => lexem?.type ?? LexemType.EOF; + public string Location => $"{lineNumber}:{charNumber}"; - public TokenFlags flags = TokenFlags.None; - public Lexem lexem; + + + public Lexem lexem + { + set + { + type = value.type; + lexemFlags = value.flags; + } + } public static long GetTokenDistance(Token a, Token b) { @@ -1680,7 +2046,7 @@ public static bool AreLocationsEqual(Token a, Token b) return a.lineNumber == b.lineNumber && a.charNumber == b.charNumber; } - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(lineNumber), ref lineNumber); op.IncludeField(nameof(charNumber), ref charNumber); @@ -1711,6 +2077,13 @@ public class TokenStream } : _tokens[Index]; + public Token Peek2 => Index + 1 >= _maxIndex + ? new Token + { + lexem = new Lexem(LexemType.EOF, null) + } + : _tokens[Index + 1]; + public List PeekUntilEoS => PeekUntil(LexemType.EndStatement); public List PeekUntil(LexemType type) { @@ -1794,6 +2167,27 @@ public void Restore(int index) Current = _tokens[index]; } + /// + /// Returns a single-line source-text reconstruction of tokens in the range + /// [startInclusive, endExclusive). Tokens are joined by single spaces, which + /// loses exact original whitespace but produces readable output for things + /// like assertion failure messages. + /// + public string GetSourceText(int startInclusive, int endExclusive) + { + if (startInclusive >= endExclusive) return ""; + if (startInclusive < 0) startInclusive = 0; + if (endExclusive > _tokens.Count) endExclusive = _tokens.Count; + var sb = new StringBuilder(); + for (var i = startInclusive; i < endExclusive; i++) + { + if (i > startInclusive) sb.Append(' '); + var raw = _tokens[i].raw ?? _tokens[i].caseInsensitiveRaw ?? ""; + sb.Append(raw); + } + return sb.ToString(); + } + public List CreatePatchToken(LexemType type, string s, int offset=0) { var copyToken = _tokens[Math.Min(_tokens.Count - 1, Index + offset)]; diff --git a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs index b6a434d..e3238c3 100644 --- a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs +++ b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs @@ -110,6 +110,22 @@ static PortableSemanticTokenType ClassifyLexemType(Token token) case LexemType.KeywordExit: case LexemType.KeywordDefer: case LexemType.KeywordEndDefer: + case LexemType.KeywordTest: + case LexemType.KeywordEndTest: + case LexemType.KeywordAbstract: + case LexemType.KeywordFrom: + case LexemType.KeywordRunto: + case LexemType.KeywordEndRunto: + case LexemType.KeywordAssert: + case LexemType.KeywordMock: + case LexemType.KeywordEndMock: + case LexemType.KeywordMocks: + case LexemType.KeywordExitMock: + case LexemType.KeywordForbid: + case LexemType.KeywordClear: + case LexemType.KeywordCallCount: + case LexemType.KeywordMaxCycles: + case LexemType.KeywordLen: return PortableSemanticTokenType.Keyword; case LexemType.KeywordType: diff --git a/FadeBasic/FadeBasic/Parser.cs b/FadeBasic/FadeBasic/Parser.cs index 9e5e91d..7c48fe9 100644 --- a/FadeBasic/FadeBasic/Parser.cs +++ b/FadeBasic/FadeBasic/Parser.cs @@ -28,7 +28,7 @@ public class ParserException : Exception public Token End { get; } public ParserException(string message, Token start, Token end = null) - : base($"Parse Exception: {message} at {start.Location}-{end?.Location}({start.lexem.type})") + : base($"Parse Exception: {message} at {start.Location}-{end?.Location}({start.type})") { Message = message; Start = start; @@ -75,6 +75,7 @@ public class Scope public Stack localVariables = new Stack(); public TokenTable<(SymbolTable, string)> positionedVariables = new TokenTable<(SymbolTable, string)>(); public Stack currentFunctionName = new Stack(); + public Stack currentRegionName = new Stack(); public Dictionary functionSymbolTable = new Dictionary(); public Dictionary functionTable = new Dictionary(); public Dictionary> functionReturnTypeTable = new Dictionary>(); @@ -82,7 +83,23 @@ public class Scope List delayedTypeChecks = new List(); private int allowExitCounter; - + + public bool IsInsideTest { get; set; } + + // Reference to the program's CommandCollection, plumbed in by + // AddScopeRelatedErrors so visitors like the mock-body type-check + // can look up command metadata without re-threading commands + // through every signature. + public CommandCollection commands; + + // Names that were preloaded from the parent program into a test scope + // (top-level locals + function-internal locals/params, see + // ScopeErrorVisitor.AddScopeRelatedErrors). They're in the local table + // so the base checker can resolve cross-scope references, but a test's + // own `local ` declaration is allowed to shadow them rather than + // erroring with SymbolAlreadyDeclared. + public HashSet borrowedFromParent = new HashSet(StringComparer.OrdinalIgnoreCase); + public Scope() { localVariables.Push(new SymbolTable()); @@ -203,7 +220,8 @@ public void EnforceOperatorTypes(BinaryOperandExpression expr) if (expr.operationType == OperationType.EqualTo) { - // the expression has a VOID type + // Comparison result is an int (BASIC uses int as boolean). + expr.ParsedType = TypeInfo.FromVariableType(VariableType.Integer); return; } @@ -587,15 +605,23 @@ public void AddAssignment(AssignmentStatement assignment, EnsureTypeContext ctx, public void AddDeclaration(DeclarationStatement declStatement, EnsureTypeContext ctx) { - + var table = GetVariables(declStatement.scopeType); if (table.ContainsKey(declStatement.variable)) { - // this is an error; we cannot declare a variable twice in the same scope. - declStatement.Errors.Add(new ParseError(declStatement.StartToken, ErrorCodes.SymbolAlreadyDeclared)); - - // don't do anything with this. - return; + // A parent-program name preloaded into this test scope is + // allowed to be legitimately shadowed by the test's own + // declaration. Drop the borrowed symbol and proceed. + if (borrowedFromParent.Remove(declStatement.variable)) + { + table.Remove(declStatement.variable); + } + else + { + // Real duplicate in the same scope -> hard error. + declStatement.Errors.Add(new ParseError(declStatement.StartToken, ErrorCodes.SymbolAlreadyDeclared)); + return; + } } switch (declStatement.type) @@ -781,10 +807,49 @@ public void ValidateCommandArgs(CommandInfo command, List args, { var arg = args[argIndex]; var descriptor = command.args[argMap[argIndex]]; - - + arg.EnsureVariablesAreDefined(this, ctx); + // Special case: a single array-typed expression at a + // `params` arg position spreads the array onto the stack + // at compile time. Accept it here without the per-element + // type check; the compiler emits SPREAD_ARRAY. Mixing array + // with inline values at the same params position is an + // error. + if (descriptor.isParams && arg.ParsedType.IsArray) + { + // Count how many args map to this same descriptor index. + var sameDescriptorCount = 0; + for (var j = 0; j < argMap.Count; j++) + { + if (argMap[j] == argMap[argIndex]) sameDescriptorCount++; + } + if (sameDescriptorCount > 1) + { + arg.Errors.Add(new ParseError(arg, + ErrorCodes.ParamsCannotMixArrayWithInline)); + continue; + } + if (arg.ParsedType.rank != 1) + { + arg.Errors.Add(new ParseError(arg, + ErrorCodes.ParamsArrayMustBeRankOne)); + continue; + } + // `params object[]` (TypeCodes.ANY) accepts any element + // type — same tolerance the inline-arg path already + // grants below. Without this, `print x$` where x$ is a + // string array trips a 0262 mismatch. + if (descriptor.typeCode != TypeCodes.ANY + && arg.ParsedType.type != ConvertTypeCodeToVariableType(descriptor.typeCode)) + { + arg.Errors.Add(new ParseError(arg, + ErrorCodes.ParamsArrayElementTypeMismatch)); + continue; + } + continue; + } + if (TypeInfo.TryGetFromTypeCode(descriptor.typeCode, out var guessType)) { this.EnforceTypeAssignment(arg, arg.ParsedType, guessType, false, out _); @@ -803,8 +868,20 @@ public void ValidateCommandArgs(CommandInfo command, List args, err.message = err.message.Substring(0, err.message.Length - replace.Length ) + "any"; } } - + + } + } + + // Helper used by params-array validation: a TypeCode (the byte form + // commands use) → the AST VariableType. Returns Void for unmapped + // codes, which won't match a real array element type so still errors. + private static VariableType ConvertTypeCodeToVariableType(byte typeCode) + { + if (TypeInfo.TryGetFromTypeCode(typeCode, out var info)) + { + return info.type; } + return VariableType.Void; } public void AddCommand(CommandInfo command, List args, List argMap, EnsureTypeContext ctx) @@ -943,6 +1020,7 @@ public ProgramNode ParseProgram(ParseOptions options = null) if (options == null) options = ParseOptions.Default; var program = new ProgramNode(_stream.Current); + program.commands = _commands; program.Errors.AddRange(_stream.Errors); while (!_stream.IsEof) { @@ -955,12 +1033,11 @@ public ProgramNode ParseProgram(ParseOptions options = null) case TypeDefinitionStatement typeStatement: program.typeDefinitions.Add(typeStatement); break; + case TestNode testNode: + program.tests.Add(testNode); + break; case LabelDeclarationNode labelStatement: - program.labels.Add(new LabelDefinition - { - statementIndex = program.statements.Count + 1, - node = labelStatement - }); + program.labels.Add(labelStatement); program.statements.Add(labelStatement); break; default: @@ -1036,7 +1113,7 @@ private IStatementNode ParseStatementThatStartsWithScope(Token scopeToken) typeReference.Errors.Insert(0, error); } var declStatement = new DeclarationStatement(scopeToken, new VariableRefNode(next), typeReference); - if (_stream.Peek.lexem.type == LexemType.OpEqual) + if (_stream.Peek.type == LexemType.OpEqual) { if (initializer != null) { @@ -1617,6 +1694,18 @@ IStatementNode Inner() return ParseFunction(token); case LexemType.KeywordExitFunction: return ParseExitFunction(token); + case LexemType.KeywordTest: + return ParseTest(token, isAbstract: false); + case LexemType.KeywordAbstract: + return ParseAbstractTest(token); + case LexemType.KeywordRunto: + return ParseRunto(token); + case LexemType.KeywordAssert: + return ParseAssert(token); + case LexemType.KeywordMock: + return ParseMock(token); + case LexemType.KeywordClear: + return ParseClearMock(token); case LexemType.KeywordEnd: return new EndProgramStatement(token); case LexemType.KeywordExit: @@ -1631,16 +1720,30 @@ IStatementNode Inner() return ParseDimStatement(token); case LexemType.KeywordReDimArray: return ParseRedimStatement(token); + case LexemType.KeywordLen: + { + // `len()` at statement level — value is + // discarded but the form is still legal (matches + // older `len(...)` host-command tests). Put the + // token back so the standard expression parser + // picks it up via its KeywordLen case. + _stream.Restore(_stream.Save() - 1); + if (TryParseExpression(out var lenAsExpr)) + { + return new ExpressionStatement(lenAsExpr); + } + return new NoOpStatement(); + } case LexemType.VariableReal: case LexemType.VariableString: case LexemType.VariableGeneral: if (token.type == LexemType.VariableReal && token.Length == 1) { - // this is the special tokenize block case. + // this is the special tokenize block case. return ParseTokenization(token); } - + var reference = ParseVariableReference(token); var secondToken = _stream.Peek; @@ -1685,7 +1788,7 @@ IStatementNode Inner() }; var maybeEqual = _stream.Peek; - if (maybeEqual.lexem.type == LexemType.OpEqual) + if (maybeEqual.type == LexemType.OpEqual) { // ah, there is an assignment happening here too! _stream.Advance(); // discard the equal sign. @@ -2386,6 +2489,160 @@ private FunctionStatement ParseFunction(Token functionToken) }; } + private static bool IsNameToken(Token token) + { + return token.type == LexemType.VariableGeneral + || token.type == LexemType.VariableReal + || token.type == LexemType.VariableString; + } + + private IStatementNode ParseAbstractTest(Token abstractToken) + { + if (_stream.Peek.type != LexemType.KeywordTest) + { + var node = new TestNode + { + name = "_", + startToken = abstractToken, + endToken = abstractToken, + isAbstract = true, + // empty test body so downstream visitors that recurse into + // testProgram don't NRE on this error-recovery node. + testProgram = new ProgramNode(abstractToken) { endToken = abstractToken }, + }; + node.Errors.Add(new ParseError(abstractToken, ErrorCodes.AbstractRequiresTest)); + return node; + } + var testToken = _stream.Advance(); // consume `test` + return ParseTest(testToken, isAbstract: true, abstractToken: abstractToken); + } + + private TestNode ParseTest(Token testToken, bool isAbstract, Token abstractToken = default) + { + var errors = new List(); + var startToken = isAbstract ? abstractToken : testToken; + + // parse the name + var nameToken = _stream.Peek; + if (!IsNameToken(nameToken)) + { + nameToken = _stream.CreatePatchToken(LexemType.VariableGeneral, "_")[0]; + errors.Add(new ParseError(testToken, ErrorCodes.TestMissingName)); + } + else + { + _stream.Advance(); + } + + // optional from clause + string fromParent = null; + Token fromParentToken = default; + if (_stream.Peek.type == LexemType.KeywordFrom) + { + _stream.Advance(); // consume `from` + var parentToken = _stream.Peek; + if (!IsNameToken(parentToken)) + { + errors.Add(new ParseError(parentToken, ErrorCodes.TestFromMissingParent)); + } + else + { + _stream.Advance(); + fromParent = parentToken.caseInsensitiveRaw; + fromParentToken = parentToken; + } + } + + // now parse the body until endtest + var statements = new List(); + var labels = new List(); + var functions = new List(); + var looking = true; + while (looking) + { + var nextToken = _stream.Peek; + switch (nextToken.type) + { + case LexemType.EOF: + errors.Add(new ParseError(testToken, ErrorCodes.TestMissingEndTest)); + looking = false; + break; + + case LexemType.EndStatement: + _stream.Advance(); + break; + + case LexemType.KeywordEndTest: + _stream.Advance(); + looking = false; + break; + + case LexemType.KeywordTest: + case LexemType.KeywordAbstract: + { + var illegalErr = new ParseError(nextToken, ErrorCodes.TestDefinedInsideTest); + errors.Add(illegalErr); + // recover: skip until matching endtest + var depth = 1; + _stream.Advance(); + while (depth > 0 && _stream.Peek.type != LexemType.EOF) + { + var t = _stream.Advance(); + if (t.type == LexemType.KeywordTest || t.type == LexemType.KeywordAbstract) depth++; + else if (t.type == LexemType.KeywordEndTest) depth--; + } + break; + } + + case LexemType.KeywordFunction: + { + var fnToken = _stream.Advance(); + var fn = ParseFunction(fnToken); + fn.region = nameToken.raw; + functions.Add(fn); + // statements.Add(fn); + break; + } + + default: + { + var member = ParseStatement(statements); + if (member is LabelDeclarationNode lbl) + { + labels.Add(lbl); + statements.Add(member); + } + else + { + statements.Add(member); + } + break; + } + } + } + + // TODO: are you allowed to define custom types in a test? + var testProgram = new ProgramNode(nameToken) + { + statements = statements, + functions = functions, + labels = labels, + endToken = _stream.Current, + }; + return new TestNode + { + testProgram = testProgram, + Errors = errors, + name = nameToken.raw, + nameToken = nameToken, + isAbstract = isAbstract, + fromParent = fromParent, + fromParentToken = fromParentToken, + startToken = startToken, + endToken = _stream.Current + }; + } + private SwitchStatement ParseSwitchStatement(Token switchToken) { var expression = ParseWikiExpression(); @@ -2873,7 +3130,7 @@ private MacroSubstitutionExpression ParseSubstitution(Token token, bool withinTo private MacroTokenizeStatement ParseTokenization(Token token) { - var isShortcut = token.lexem.type == LexemType.VariableReal; + var isShortcut = token.type == LexemType.VariableReal; var searching = true; var errors = new List(); @@ -3041,7 +3298,7 @@ private GotoStatement ParseGoto(Token gotoToken) { case LexemType.VariableGeneral: return new GotoStatement(gotoToken, next); - + default: var patchToken = _stream.CreatePatchToken(LexemType.VariableGeneral, "_")[0]; var statement = new GotoStatement(gotoToken, patchToken); @@ -3049,6 +3306,431 @@ private GotoStatement ParseGoto(Token gotoToken) return statement; } } + + private AssertStatement ParseAssert(Token assertToken) + { + // Capture source text by snapshotting stream indices around the + // expression parse, then slicing the consumed tokens. + var startIdx = _stream.Save(); + if (!TryParseExpression(out var expr)) + { + var stmt = new AssertStatement(assertToken, assertToken, null, ""); + stmt.Errors.Add(new ParseError(assertToken, ErrorCodes.AssertMissingExpression)); + return stmt; + } + var endIdx = _stream.Save(); + var sourceText = _stream.GetSourceText(startIdx, endIdx); + + var stmtNode = new AssertStatement(assertToken, expr.EndToken, expr, sourceText); + + // Optional second arg: `assert , ` where is a + // string expression (literal or variable). Surfaced in failure reports. + if (_stream.Peek.type == LexemType.ArgSplitter) + { + _stream.Advance(); // consume comma + if (TryParseExpression(out var reasonExpr)) + { + stmtNode.reason = reasonExpr; + stmtNode.endToken = reasonExpr.EndToken; + } + else + { + stmtNode.Errors.Add(new ParseError(assertToken, ErrorCodes.AssertReasonMissingExpression)); + } + } + + return stmtNode; + } + + private RuntoStatement ParseRunto(Token runtoToken) + { + // Forms: + // inline: `runto labelName` + // inline-stack: `runto labelName max cycles N` (clauses on same line) + // block: `runto labelName \n max cycles N \n endrunto` + // Clauses on the same line (separated by `:` or whitespace) bind to + // the runto without requiring `endrunto`. If the user crosses a newline + // and continues with another clause or writes `endrunto`, that's the + // block form and must close with `endrunto`. + var labelToken = _stream.Peek; + if (labelToken.type != LexemType.VariableGeneral) + { + var patchToken = _stream.CreatePatchToken(LexemType.VariableGeneral, "_")[0]; + var stmt = new RuntoStatement(runtoToken, runtoToken, patchToken); + stmt.Errors.Add(new ParseError(runtoToken, ErrorCodes.RuntoMissingLabel)); + return stmt; + } + _stream.Advance(); // consume label + + var statement = new RuntoStatement(runtoToken, labelToken, labelToken); + Token endToken = labelToken; + + // Phase 1: parse clauses on the same line. Colon separators (`:`) + // between the label and clauses, and between clauses, are honored; + // a real newline ends the inline form. + while (IsColonEndStatement(_stream.Peek)) _stream.Advance(); + while (IsRuntoClauseStart(_stream.Peek.type)) + { + ParseRuntoClause(statement, ref endToken); + while (IsColonEndStatement(_stream.Peek)) _stream.Advance(); + } + + // Phase 2: detect block form. If the next non-EndStatement token is + // a clause or `endrunto`, this is the multi-line block form and must + // be closed with `endrunto`. + var lookAhead = PeekPastEndStatements(); + bool hasBlockBody = lookAhead.type == LexemType.KeywordEndRunto + || IsRuntoClauseStart(lookAhead.type); + + if (!hasBlockBody) + { + statement.endToken = endToken; + return statement; + } + + // Consume the EndStatements before clauses. + while (_stream.Peek.type == LexemType.EndStatement) _stream.Advance(); + + // Parse clauses until endrunto or a hard boundary (EOF / endtest). + var looking = true; + while (looking) + { + var next = _stream.Peek; + switch (next.type) + { + case LexemType.EOF: + case LexemType.KeywordEndTest: + case LexemType.KeywordTest: + case LexemType.KeywordAbstract: + statement.Errors.Add(new ParseError(runtoToken, ErrorCodes.RuntoMissingEndRunto)); + looking = false; + break; + + case LexemType.EndStatement: + _stream.Advance(); + break; + + case LexemType.KeywordEndRunto: + endToken = _stream.Advance(); + looking = false; + break; + + default: + if (IsRuntoClauseStart(next.type)) + { + ParseRuntoClause(statement, ref endToken); + } + else + { + // Unknown clause; skip token to avoid infinite loop. + _stream.Advance(); + } + break; + } + } + + statement.endToken = endToken; + return statement; + } + + private static bool IsRuntoClauseStart(LexemType type) + { + return type == LexemType.KeywordMaxCycles; + } + + private void ParseRuntoClause(RuntoStatement statement, ref Token endToken) + { + var head = _stream.Peek; + switch (head.type) + { + case LexemType.KeywordMaxCycles: + { + _stream.Advance(); // consume `max cycles` + if (TryParseExpression(out var cyclesExpr)) + { + statement.maxCyclesExpression = cyclesExpr; + endToken = cyclesExpr.EndToken; + } + else + { + statement.Errors.Add(new ParseError(head, ErrorCodes.RuntoMaxCyclesMissingValue)); + } + break; + } + default: + // Caller is expected to gate on IsRuntoClauseStart. + _stream.Advance(); + break; + } + } + + private MockStatement ParseMock(Token mockToken) + { + // Block-only form: + // mock + // returns ' optional, sets return value + // forbid [] ' optional, fails the test if called + // endmock + // + // An empty body (`mock cmd\nendmock`) installs a void mock — the + // real command is suppressed. There is no inline / stacked / bare + // form: every mock requires its `endmock`. Frequency clauses + // (`once`, `times`, `always`) are gone — a mock stays installed + // until `clear mock ` (or `clear mocks`) removes it. + // + // The lexer's command-name pass merges multi-word command names + // into a single CommandWord token, so the name is one token. + var stmt = new MockStatement(mockToken, mockToken); + + var nameToken = _stream.Peek; + if (nameToken.type == LexemType.CommandWord) + { + _stream.Advance(); + stmt.commandName = nameToken.caseInsensitiveRaw; + stmt.commandNameToken = nameToken; + stmt.endToken = nameToken; + } + else + { + stmt.Errors.Add(new ParseError(mockToken, ErrorCodes.MockMissingCommandName)); + return stmt; + } + + // Optional parameter-name list — `mock find pattern, list` binds + // the command's args to locals named `pattern` and `list` inside + // the body. Two surface forms are accepted: + // • bare: `mock find pattern, list` + // • parens: `mock find(pattern, list)` + // Names are space- or comma-separated. A newline ends the bare + // form; the close paren ends the parens form. Names can be any + // variable token shape (general identifier, `s$` for string, + // `f#` for float) — the param's TYPE comes from the command's + // metadata, not the suffix; the suffix is just naming style. + var inParens = _stream.Peek.type == LexemType.ParenOpen; + if (inParens) _stream.Advance(); + while (IsMockParamToken(_stream.Peek.type) + || _stream.Peek.type == LexemType.ArgSplitter) + { + if (_stream.Peek.type == LexemType.ArgSplitter) + { + _stream.Advance(); + continue; + } + var paramToken = _stream.Advance(); + stmt.parameters.Add(new VariableRefNode(paramToken)); + stmt.endToken = paramToken; + } + if (inParens) + { + if (_stream.Peek.type == LexemType.ParenClose) + { + stmt.endToken = _stream.Advance(); + } + else + { + stmt.Errors.Add(new ParseError(_stream.Peek, + ErrorCodes.MockParamsMissingCloseParen)); + } + } + + // Drain end-of-statement separators between the name/params and the body. + while (_stream.Peek.type == LexemType.EndStatement) _stream.Advance(); + + // Body: `endtest`, `test`, `abstract`, and EOF are hard boundaries + // that emit MockMissingEndMock without consuming the boundary + // token — the surrounding test-body parser still sees its + // `endtest`. + var looking = true; + while (looking) + { + var next = _stream.Peek; + switch (next.type) + { + case LexemType.EOF: + case LexemType.KeywordEndTest: + case LexemType.KeywordTest: + case LexemType.KeywordAbstract: + stmt.Errors.Add(new ParseError(mockToken, ErrorCodes.MockMissingEndMock)); + looking = false; + break; + + case LexemType.EndStatement: + _stream.Advance(); + break; + + case LexemType.KeywordEndMock: + { + stmt.endToken = next; + _stream.Advance(); + // Optional fall-through return expression — `endmock + // ` matches `endfunction `. When the body + // reaches its closing `endmock` without an earlier + // `exitmock`, this expression becomes the return. + if (!IsMockBodyTerminator(_stream.Peek.type)) + { + var saved = _stream.Save(); + if (TryParseExpression(out var endExpr)) + { + stmt.endmockExpression = endExpr; + stmt.endToken = endExpr.EndToken; + } + else + { + _stream.Restore(saved); + } + } + looking = false; + break; + } + + case LexemType.KeywordExitMock: + { + var head = _stream.Advance(); + var rs = new MockExitMockStatement(head, head); + if (TryParseExpression(out var expr)) + { + rs.expression = expr; + rs.endToken = expr.EndToken; + } + else + { + rs.Errors.Add(new ParseError(head, ErrorCodes.MockReturnsMissingExpression)); + } + stmt.body.Add(rs); + break; + } + + case LexemType.KeywordForbid: + { + var head = _stream.Advance(); + var fs = new MockForbidStatement(head, head); + // Optional reason expression (string-typed). Same + // shape as `assert , "reason"`. We attempt + // to parse it; if the next token isn't expression- + // shaped, fall through with no reason. + if (!IsMockBodyTerminator(_stream.Peek.type)) + { + var saved = _stream.Save(); + if (TryParseExpression(out var reasonExpr)) + { + fs.reason = reasonExpr; + fs.endToken = reasonExpr.EndToken; + } + else + { + _stream.Restore(saved); + } + } + stmt.body.Add(fs); + break; + } + + default: + { + // Any test-block-legal statement is accepted inside + // the mock body (locals, ifs, asserts, static + // commands, plain assignments — including those that + // target a ref parameter for write-through). Defer + // to the generic statement parser; it'll surface its + // own errors for anything truly malformed. + var parsed = ParseStatement(stmt.body); + if (parsed != null) + { + stmt.body.Add(parsed); + } + break; + } + } + } + + return stmt; + } + + // True for tokens that can't start a `forbid` reason expression — + // used to decide whether to try parsing one or fall through. + private static bool IsMockBodyTerminator(LexemType type) + { + return type == LexemType.EndStatement + || type == LexemType.KeywordEndMock + || type == LexemType.KeywordExitMock + || type == LexemType.KeywordForbid + || type == LexemType.EOF + || type == LexemType.KeywordEndTest + || type == LexemType.KeywordTest; + } + + // True for tokens that can appear as a mock parameter name. We + // accept the three Fade variable lexem types — general identifiers + // (no suffix), `s$` style string names, and `f#` style real names. + // The param's actual type comes from the command metadata, not the + // suffix on the name; this is purely about which lexer tokens we + // accept as identifier-like. + private static bool IsMockParamToken(LexemType type) + { + return type == LexemType.VariableGeneral + || type == LexemType.VariableString + || type == LexemType.VariableReal; + } + + // True when the token is a colon-induced EndStatement (same line) + // rather than a newline-induced one. The lexer synthesizes newline + // EndStatements with empty/null `raw`; colon ones carry `raw = ":"`. + // Used by ParseRunto for its inline-clause form. + private static bool IsColonEndStatement(Token token) + { + return token != null + && token.type == LexemType.EndStatement + && token.raw == ":"; + } + + private ClearMockStatement ParseClearMock(Token clearToken) + { + // `clear mock ` or `clear mocks` + var stmt = new ClearMockStatement(clearToken, clearToken); + + var next = _stream.Peek; + if (next.type == LexemType.KeywordMocks) + { + _stream.Advance(); + stmt.commandName = null; // means "clear all" + stmt.endToken = next; + return stmt; + } + if (next.type == LexemType.KeywordMock) + { + _stream.Advance(); + var nameToken = _stream.Peek; + if (nameToken.type == LexemType.CommandWord) + { + _stream.Advance(); + stmt.commandName = nameToken.caseInsensitiveRaw; + stmt.commandNameToken = nameToken; + stmt.endToken = nameToken; + } + else + { + stmt.Errors.Add(new ParseError(clearToken, ErrorCodes.MockMissingCommandName)); + } + return stmt; + } + + stmt.Errors.Add(new ParseError(clearToken, ErrorCodes.ClearMockMissingTarget)); + return stmt; + } + + // Helper: peek past any EndStatement tokens to see what's next, without + // permanently advancing the stream. + private Token PeekPastEndStatements() + { + var saved = _stream.Save(); + while (_stream.Peek.type == LexemType.EndStatement) + { + _stream.Advance(); + } + var result = _stream.Peek; + _stream.Restore(saved); + return result; + } private GoSubStatement ParseGoSub(Token gotoToken) { var next = _stream.Advance(); @@ -3456,6 +4138,63 @@ private bool TryParseWikiTerm(out IExpressionNode outputExpression, out ProgramR recovery = null; switch (token.type) { + case LexemType.KeywordLen: + { + // `len()` — returns array element count or string + // character count as an int. Parens are required for + // clarity (matches the BASIC family's usual `LEN(x)` + // form). The inner expression's type (array or string) + // determines element size at compile time. + var lenTok = _stream.Advance(); + if (_stream.Peek.type != LexemType.ParenOpen) + { + var badLen = new LenExpression(lenTok, lenTok, null); + badLen.Errors.Add(new ParseError(lenTok, ErrorCodes.LenMissingParens)); + outputExpression = badLen; + break; + } + _stream.Advance(); // consume `(` + if (!TryParseExpression(out var lenInner)) + { + var badLen = new LenExpression(lenTok, lenTok, null); + badLen.Errors.Add(new ParseError(lenTok, ErrorCodes.LenMissingExpression)); + outputExpression = badLen; + break; + } + if (_stream.Peek.type != LexemType.ParenClose) + { + var badLen = new LenExpression(lenTok, lenInner.EndToken, lenInner); + badLen.Errors.Add(new ParseError(lenTok, ErrorCodes.LenMissingCloseParen)); + outputExpression = badLen; + break; + } + var closeTok = _stream.Advance(); + var lenExpr = new LenExpression(lenTok, closeTok, lenInner); + lenExpr.ParsedType = TypeInfo.Int; + outputExpression = lenExpr; + break; + } + case LexemType.KeywordCallCount: + { + // `call count ` is a single keyword followed by + // a command name. The lexer matches `call[ \t]+count` as + // one token — so `call` alone (e.g., a user variable + // named `call`) stays a VariableGeneral. + var ccTok = _stream.Advance(); + var cmdTok = _stream.Peek; + if (cmdTok.type != LexemType.CommandWord) + { + var bad = new CallCountExpression(ccTok, ccTok, null); + bad.Errors.Add(new ParseError(ccTok, ErrorCodes.CallCountMissingCommand)); + outputExpression = bad; + break; + } + _stream.Advance(); // consume command name + var ccExpr = new CallCountExpression(ccTok, cmdTok, cmdTok); + ccExpr.ParsedType = TypeInfo.Int; + outputExpression = ccExpr; + break; + } case LexemType.ConstantBracketOpen: var open = _stream.Advance(); outputExpression = ParseSubstitution(open, withinTokenization); diff --git a/FadeBasic/FadeBasic/Sdk/CooperativePump.cs b/FadeBasic/FadeBasic/Sdk/CooperativePump.cs new file mode 100644 index 0000000..e0370e9 --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/CooperativePump.cs @@ -0,0 +1,592 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using FadeBasic.Json; +using FadeBasic.Sdk; +using FadeBasic.Virtual; +using FadeSdk = FadeBasic.Sdk.Fade; + +namespace FadeBasic.Sdk +{ + // Host-agnostic cooperative scheduler for the Fade VM. + // + // Both runtime hosts (FadeBasic.Export.Web's iframe, + // WebRuntime.MonoGame's Game1.Update loop, and future hosts) + // share this state + these methods. The host is responsible for: + // + // 1. Setting `CommandsAccessor` so compile-from-source paths + // can fetch the host's active CommandCollection. + // 2. Wiring `StandardCommands.WaitImpl` to call + // `OnCooperativeWait(ms)` — sets the wait deadline + suspends. + // 3. Wiring `HostBridge.SuspendVm` to call + // `OnHostReplyWait()` — flags waiting + suspends. + // 4. Calling `RunStartFromSource` / `RunStartFromBytecode` / + // `RunTestsStart` / `RunTick` / `StopRun` / `DepositResult*` + // from whatever JS-interop surface the host has. + // 5. Driving `RunTick` repeatedly via the host's scheduler + // (setTimeout in the web template, requestAnimationFrame + // via Game1.Update in monogame, etc.). + // + // State is static — there's one VM running per host at a time. + // The host is responsible for not nesting runs. + public static class CooperativePump + { + // ─── Active run state ──────────────────────────────────────── + public static VirtualMachine RunVm { get; set; } + private static string _runError; + private static bool _waitingForHostReply; + // Public so debug-session drivers can reset + read it the same + // way RunTick does. WaitImpl writes via OnCooperativeWait; + // the pump consumer clears at the start of each tick and + // includes the post-tick value in its status so the JS pump + // can schedule the next tick after the delay. + public static int PendingWaitMs { get; set; } + private static bool _runStopRequested; + + // ─── Cooperative test-runner state ─────────────────────────── + private static bool _testRunActive; + private static FadeRuntimeContext _testCtx; + private static List _testQueue; + private static int _testIndex; + private static List _testResults; + private static Stopwatch _testRunSw; + private static Stopwatch _currentTestSw; + + // ─── Host wiring ───────────────────────────────────────────── + public static Func CommandsAccessor { get; set; } + private static CommandCollection GetCommands() => + CommandsAccessor?.Invoke() ?? throw new InvalidOperationException( + "CooperativePump.CommandsAccessor not set — host must wire this before any compile-from-source op."); + + // Library commands call HostBridge.SuspendVm; the host wires + // it to this method. Identical wiring on every host. + public static void OnHostReplyWait() + { + _waitingForHostReply = true; + RunVm?.Suspend(); + } + + // Library commands (StandardCommands.WaitImpl) call this when + // `wait ms` fires during a cooperative run. Host wires its + // WaitImpl to delegate here. + public static void OnCooperativeWait(int ms) + { + PendingWaitMs = ms; + _waitEndsAtTickMs = NowMs + ms; + RunVm?.Suspend(); + } + + // Deadline (in ms, same epoch as NowMs) for the most recent + // cooperative wait. Hosts that drive the VM per-frame (e.g. + // Game1.Update under requestAnimationFrame) check IsBusyWaiting + // before ticking — saves the setTimeout dance the Export.Web + // pump uses, since rAF already provides per-frame cadence. + private static long _waitEndsAtTickMs; + private static long NowMs => DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond; + + // True when the pump shouldn't be advanced this frame: either + // we're waiting on a host-reply (prompt$ etc.) or a wait-ms + // deadline hasn't elapsed. + public static bool IsBusyWaiting() + { + if (_waitingForHostReply) return true; + if (NowMs < _waitEndsAtTickMs) return true; + return false; + } + + // ─── Run entry points (compile-from-source / bytecode) ────── + // RunStart (entry DLL bytes) is host-specific and stays in the + // host class — assembly loading varies per runtime context. + + public static string RunStartFromSource(string source) + { + try + { + var commands = GetCommands(); + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + return new PumpStartResult { ok = false, compileError = errors.ToDisplay() }.Jsonify(); + RunVm = ctx.Machine; + ResetPerRunState(); + return new PumpStartResult { ok = true }.Jsonify(); + } + catch (Exception ex) + { + return new PumpStartResult { ok = false, error = DescribeException(ex) }.Jsonify(); + } + } + + public static byte[] CompileToBytecode(string source) + { + try + { + CommandCollection commands; + try { commands = GetCommands(); } catch { return Array.Empty(); } + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out _)) + return Array.Empty(); + return ctx.Machine.program; + } + catch + { + return Array.Empty(); + } + } + + public static string CompileToBytecodeStatus(string source) + { + try + { + CommandCollection commands; + try { commands = GetCommands(); } + catch (Exception ex) { return new PumpStartResult { ok = false, error = ex.Message }.Jsonify(); } + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + return new PumpStartResult { ok = false, compileError = errors.ToDisplay() }.Jsonify(); + return new PumpStartResult { ok = true, byteCount = ctx.Machine.program.Length }.Jsonify(); + } + catch (Exception ex) + { + return new PumpStartResult { ok = false, error = DescribeException(ex) }.Jsonify(); + } + } + + public static string RunStartFromBytecode(byte[] bytecode) + { + try + { + if (bytecode == null || bytecode.Length == 0) + throw new Exception("RunStartFromBytecode: empty bytecode"); + var commands = GetCommands(); + RunVm = new VirtualMachine(bytecode) + { + hostMethods = HostMethodTable.FromCommandCollection(commands), + }; + ResetPerRunState(); + return new PumpStartResult { ok = true }.Jsonify(); + } + catch (Exception ex) + { + return new PumpStartResult { ok = false, error = DescribeException(ex) }.Jsonify(); + } + } + + // Called by the host's RunStart (which knows how to load the + // entry DLL) after it has built the VM. Host hands us the VM, + // we install it as the run-pump's current VM. + public static void RunStartWithVm(VirtualMachine vm) + { + RunVm = vm; + ResetPerRunState(); + } + + private static void ResetPerRunState() + { + _runError = null; + _waitingForHostReply = false; + PendingWaitMs = 0; + _waitEndsAtTickMs = 0; + _runStopRequested = false; + _testRunActive = false; + } + + // ─── Run tick ───────────────────────────────────────────────── + public static string RunTick(int budget) + { + if (RunVm == null && _testRunActive) + return BuildTestRunCompleteJson(stopped: false); + + if (RunVm == null) + return new PumpTickResult { complete = true }.Jsonify(); + + if (_runError != null && !_testRunActive) + return new PumpTickResult { complete = true, error = _runError }.Jsonify(); + + if (_runStopRequested) + { + _runStopRequested = false; + RunVm = null; + if (_testRunActive) + return BuildTestRunCompleteJson(stopped: true); + return new PumpTickResult { complete = true, error = "stopped" }.Jsonify(); + } + + PendingWaitMs = 0; + Exception testException = null; + try + { + RunVm.Execute3(budget); + } + catch (Exception ex) + { + if (_testRunActive) testException = ex; + else _runError = DescribeException(ex); + } + + if (_testRunActive) + { + var vmFinished = RunVm.instructionIndex >= RunVm.program.Length + || testException != null; + if (vmFinished && _testQueue != null && _testCtx != null + && _testIndex >= 0 && _testIndex < _testQueue.Count) + { + _currentTestSw?.Stop(); + var entry = _testQueue[_testIndex]; + var result = FadeTestExecutor.BuildResultFromVm( + RunVm, + entry, + _currentTestSw?.Elapsed ?? TimeSpan.Zero, + _testCtx.Compiler.DebugData, + testException); + _testResults?.Add(result); + var progress = BuildTestResult(result); + + if (!AdvanceTest()) + return BuildTestRunCompleteJson(stopped: false, lastProgress: progress); + + var nextName = _testQueue[_testIndex].name; + return new PumpTickResult + { + testProgress = progress, + testStarting = new PumpTestStarting { name = nextName }, + }.Jsonify(); + } + } + + var complete = _runError != null + || RunVm.instructionIndex >= RunVm.program.Length; + + return new PumpTickResult + { + complete = complete, + suspended = !complete && RunVm.isSuspendRequested, + waitMs = PendingWaitMs, + waitingForHostReply = _waitingForHostReply, + error = _runError, + }.Jsonify(); + } + + // ─── Stop ───────────────────────────────────────────────────── + public static string StopRun() + { + _runStopRequested = true; + _waitingForHostReply = false; + PendingWaitMs = 0; + RunVm?.Suspend(); + return "true"; + } + + // ─── Tests ──────────────────────────────────────────────────── + public static string RunTestsStart(string source, string testName) + { + _runStopRequested = false; + _testRunActive = false; + _testQueue = null; + _testResults = null; + _runError = null; + + try + { + var commands = GetCommands(); + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + return new PumpStartResult { ok = false, compileError = errors.ToDisplay() }.Jsonify(); + + var selectAll = string.IsNullOrWhiteSpace(testName); + var queue = new List(); + foreach (var t in ctx.Compiler.TestManifest) + { + if (t.isAbstract) continue; + if (!selectAll && !string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)) continue; + queue.Add(t); + if (!selectAll) break; + } + + _testCtx = ctx; + _testQueue = queue; + _testResults = new List(); + _testIndex = -1; + _testRunSw = Stopwatch.StartNew(); + _testRunActive = true; + + AdvanceTest(); + return new PumpStartResult { ok = true }.Jsonify(); + } + catch (Exception ex) + { + return new PumpStartResult { ok = false, error = DescribeException(ex) }.Jsonify(); + } + } + + private static bool AdvanceTest() + { + if (_testQueue == null || _testCtx == null) { RunVm = null; return false; } + _testIndex++; + if (_testIndex >= _testQueue.Count) { RunVm = null; return false; } + var entry = _testQueue[_testIndex]; + var vm = new VirtualMachine(_testCtx.Machine.program, entry.entryPointAddress) + { + hostMethods = _testCtx.Compiler.methodTable, + isTestExecution = true, + }; + RunVm = vm; + _runError = null; + _waitingForHostReply = false; + PendingWaitMs = 0; + _currentTestSw = Stopwatch.StartNew(); + return true; + } + + private static string BuildTestRunCompleteJson(bool stopped, PumpTestResult lastProgress = null) + { + _testRunActive = false; + _testRunSw?.Stop(); + var results = _testResults ?? new List(); + var passed = 0; var failed = 0; + foreach (var r in results) { if (r.passed) passed++; else failed++; } + return new PumpTickResult + { + complete = true, + testProgress = lastProgress, + testFinal = new PumpTestFinal + { + passed = passed, + failed = failed, + duration = _testRunSw?.Elapsed.TotalMilliseconds ?? 0, + results = BuildTestResults(results), + }, + error = stopped ? "Stopped" : null, + }.Jsonify(); + } + + // ─── Deposit-result entry points ────────────────────────────── + public static string DepositResultString(string value) + { + if (RunVm == null) return "false"; + if (!_waitingForHostReply) return "false"; + value ??= ""; + var vm = RunVm; + if (vm.stack.ptr < 9) { _waitingForHostReply = false; return "false"; } + _ = vm.stack.Pop(); + vm.stack.PopArraySpan(8, out var oldPtrSpan); + var oldPtr = VmPtr.FromBytes(oldPtrSpan); + vm.heap.TryDecrementRefCount(oldPtr); + var size = value.Length * 4; + var span = new byte[size]; + for (var i = 0; i < value.Length; i++) + { + var data = (uint)value[i]; + var b = BitConverter.GetBytes(data); + span[i * 4 + 0] = b[0]; + span[i * 4 + 1] = b[1]; + span[i * 4 + 2] = b[2]; + span[i * 4 + 3] = b[3]; + } + vm.heap.AllocateString(size, out var newPtr); + vm.heap.WriteSpan(newPtr, size, span); + var ptrBytes = VmPtr.GetBytes(ref newPtr); + VmUtil.PushSpan(ref vm.stack, ptrBytes, TypeCodes.STRING); + _waitingForHostReply = false; + return "true"; + } + + private static bool BeginDeposit() => RunVm != null && _waitingForHostReply; + private static string EndDeposit(bool ok) + { + _waitingForHostReply = false; + return ok ? "true" : "false"; + } + + private static bool SwapPrimitiveTop(byte typeCode, byte[] newBytes) + { + if (RunVm == null) return false; + if (newBytes == null) return false; + var size = TypeCodes.GetByteSize(typeCode); + if (newBytes.Length != size) return false; + if (RunVm.stack.ptr < 1 + size) return false; + RunVm.stack.ptr -= 1 + size; + VmUtil.PushSpan(ref RunVm.stack, newBytes, typeCode); + return true; + } + + public static string DepositResultInt(int value) => + EndDeposit(BeginDeposit() && SwapPrimitiveTop(TypeCodes.INT, BitConverter.GetBytes(value))); + public static string DepositResultReal(float value) => + EndDeposit(BeginDeposit() && SwapPrimitiveTop(TypeCodes.REAL, BitConverter.GetBytes(value))); + public static string DepositResultBool(bool value) => + EndDeposit(BeginDeposit() && SwapPrimitiveTop(TypeCodes.BOOL, BitConverter.GetBytes(value))); + public static string DepositResultByte(byte value) => + EndDeposit(BeginDeposit() && SwapPrimitiveTop(TypeCodes.BYTE, new[] { value })); + public static string DepositResultWord(int value) => + EndDeposit(BeginDeposit() && SwapPrimitiveTop(TypeCodes.WORD, BitConverter.GetBytes((ushort)value))); + public static string DepositResultDword(int value) => + EndDeposit(BeginDeposit() && SwapPrimitiveTop(TypeCodes.DWORD, BitConverter.GetBytes((uint)value))); + public static string DepositResultDint(long value) => + EndDeposit(BeginDeposit() && SwapPrimitiveTop(TypeCodes.DINT, BitConverter.GetBytes(value))); + public static string DepositResultDfloat(double value) => + EndDeposit(BeginDeposit() && SwapPrimitiveTop(TypeCodes.DFLOAT, BitConverter.GetBytes(value))); + public static string DepositResultVoid() => EndDeposit(BeginDeposit()); + + // ─── Result shaping ────────────────────────────────────────── + public static string SerializeTestResult(FadeTestResult r) => BuildTestResult(r).Jsonify(); + + private static PumpTestResult BuildTestResult(FadeTestResult r) + { + var frames = new List(); + if (r.failureFrames != null) + { + foreach (var f in r.failureFrames) + { + frames.Add(new PumpTestFrame + { + functionName = f.functionName, + lineNumber = f.lineNumber, + charNumber = f.charNumber, + instructionIndex = f.instructionIndex, + }); + } + } + return new PumpTestResult + { + name = r.testName, + passed = r.passed, + duration = r.duration.TotalMilliseconds, + failureMessage = r.failureMessage, + failureReason = r.failureReason, + failureSourceText = r.failureSourceText, + failureInstructionIndex = r.failureInstructionIndex, + failureFrames = frames, + }; + } + + private static List BuildTestResults(List results) + { + var list = new List(results.Count); + foreach (var r in results) list.Add(BuildTestResult(r)); + return list; + } + + // ─── Diagnostics ───────────────────────────────────────────── + private static string DescribeException(Exception ex) + { + var sb = new System.Text.StringBuilder(); + var current = ex; + var depth = 0; + while (current != null && depth < 6) + { + if (sb.Length > 0) sb.Append("\n → "); + sb.Append(current.GetType().FullName).Append(": ").Append(current.Message); + if (!string.IsNullOrEmpty(current.StackTrace) && depth == 0) + sb.Append('\n').Append(current.StackTrace); + current = current.InnerException; + depth++; + } + return sb.ToString(); + } + } + + // ─── JSON result types ──────────────────────────────────────────────── + // Used only for pump → JS serialization via IJsonable. + + internal class PumpStartResult : IJsonable + { + public bool ok; + public string error; + public string compileError; + public int byteCount; + + public void ProcessJson(ref T op) where T : IJsonOperation + { + op.IncludeField("ok", ref ok); + op.IncludeField("error", ref error); + op.IncludeField("compileError", ref compileError); + op.IncludeField("byteCount", ref byteCount); + } + } + + internal class PumpTickResult : IJsonable + { + public bool complete; + public bool suspended; + public int waitMs; + public bool waitingForHostReply; + public string error; + public PumpTestResult testProgress; + public PumpTestStarting testStarting; + public PumpTestFinal testFinal; + + public void ProcessJson(ref T op) where T : IJsonOperation + { + op.IncludeField("complete", ref complete); + op.IncludeField("suspended", ref suspended); + op.IncludeField("waitMs", ref waitMs); + op.IncludeField("waitingForHostReply", ref waitingForHostReply); + op.IncludeField("error", ref error); + op.IncludeField("testProgress", ref testProgress); + op.IncludeField("testStarting", ref testStarting); + op.IncludeField("testFinal", ref testFinal); + } + } + + internal class PumpTestStarting : IJsonable + { + public string name; + + public void ProcessJson(ref T op) where T : IJsonOperation + { + op.IncludeField("name", ref name); + } + } + + internal class PumpTestResult : IJsonable + { + public string name; + public bool passed; + public double duration; + public string failureMessage; + public string failureReason; + public string failureSourceText; + public int failureInstructionIndex; + public List failureFrames = new List(); + + public void ProcessJson(ref T op) where T : IJsonOperation + { + op.IncludeField("name", ref name); + op.IncludeField("passed", ref passed); + op.IncludeField("duration", ref duration); + op.IncludeField("failureMessage", ref failureMessage); + op.IncludeField("failureReason", ref failureReason); + op.IncludeField("failureSourceText", ref failureSourceText); + op.IncludeField("failureInstructionIndex", ref failureInstructionIndex); + op.IncludeField("failureFrames", ref failureFrames); + } + } + + internal class PumpTestFrame : IJsonable + { + public string functionName; + public int lineNumber; + public int charNumber; + public int instructionIndex; + + public void ProcessJson(ref T op) where T : IJsonOperation + { + op.IncludeField("functionName", ref functionName); + op.IncludeField("lineNumber", ref lineNumber); + op.IncludeField("charNumber", ref charNumber); + op.IncludeField("instructionIndex", ref instructionIndex); + } + } + + internal class PumpTestFinal : IJsonable + { + public int passed; + public int failed; + public double duration; + public List results = new List(); + + public void ProcessJson(ref T op) where T : IJsonOperation + { + op.IncludeField("passed", ref passed); + op.IncludeField("failed", ref failed); + op.IncludeField("duration", ref duration); + op.IncludeField("results", ref results); + } + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Fade.cs b/FadeBasic/FadeBasic/Sdk/Fade.cs index ceffb06..2351409 100644 --- a/FadeBasic/FadeBasic/Sdk/Fade.cs +++ b/FadeBasic/FadeBasic/Sdk/Fade.cs @@ -37,9 +37,9 @@ public static List GetFadeFilesFromProject(string csProjPath) public static bool TryCreateFromProject( - string csProjPath, - CommandCollection availableCommands, - out FadeRuntimeContext context, + string csProjPath, + CommandCollection availableCommands, + out FadeRuntimeContext context, out FadeErrors errors) { context = null; @@ -91,10 +91,10 @@ public static bool TryCreateFromProject( return TryCreateFromString(sourceMap.fullSource, commandCollection, out context, out errors, sourceMap); } - + public static bool TryCreateFromString( - string src, - CommandCollection commands, + string src, + CommandCollection commands, out FadeRuntimeContext context, out FadeErrors errors, SourceMap map=null) @@ -159,11 +159,12 @@ public string ToDisplay() } } - public class FadeRuntimeContext : ILaunchable + public partial class FadeRuntimeContext : ITestLaunchable { byte[] ILaunchable.Bytecode => Machine.program; CommandCollection ILaunchable.CommandCollection => CommandCollection; DebugData ILaunchable.DebugData => Compiler.DebugData; + IReadOnlyList ITestLaunchable.TestManifest => Compiler.TestManifest; private DebugSession _session; private static Lexer _lexer = new Lexer(); @@ -758,8 +759,8 @@ private bool TryFindVariable(string name, out DebugVariable variable, out Virtua return false; } - public static bool TryFromSource(string src, - CommandCollection commands, + public static bool TryFromSource(string src, + CommandCollection commands, out FadeRuntimeContext context, out FadeErrors errors, SourceMap map = null) @@ -803,6 +804,12 @@ public static bool TryFromSource(string src, }); compiler.Compile(program); + // Resolve test manifest source locations to per-file paths via the + // source map, so multi-`.fbasic` projects surface the right + // originating file in IDE Test Explorer (Stage 11H). No-op when + // no source map was supplied (single-string SDK callers). + FadeBasic.Launch.LaunchUtil.ApplySourceMap(compiler.TestManifest, map); + var vm = new VirtualMachine(compiler.Program); vm.hostMethods = compiler.methodTable; diff --git a/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs b/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs new file mode 100644 index 0000000..bd43ae6 --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using FadeBasic.Launch; +using FadeBasic.Virtual; + +namespace FadeBasic.Sdk +{ + public static class FadeTestExecutor + { + // Run a single test entry against pre-built bytecode + host method table. + // Used both by the SDK (FadeRuntimeContext.RunTest) and the console-app + // launcher when handling `--fade-test=name`. Each call gets a fresh VM, + // so test state is isolated. + public static FadeTestResult RunTest( + byte[] bytecode, + HostMethodTable hostMethods, + TestManifestEntry entry) + { + return RunTest(bytecode, hostMethods, entry, debugData: null); + } + + // DebugData-aware overload: when supplied, the failure result includes + // source-located stack frames built from the VM's methodStack snapshot + // at the moment of failure. Call this overload from any caller that + // has the program's DebugData (e.g., ILaunchable.DebugData). + // + // `onVmCreated` (optional) fires once with the freshly-constructed VM + // before Execute3 begins. Hosts that need to observe (or cancel) the + // running VM — e.g. the web runtime's Stop button — register here. + // Returning is enough; no synchronization required, the callback runs + // on the same thread that drives Execute3. + public static FadeTestResult RunTest( + byte[] bytecode, + HostMethodTable hostMethods, + TestManifestEntry entry, + DebugData debugData, + System.Action onVmCreated = null) + { + if (entry.isAbstract) + { + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = $"Test `{entry.name}` is abstract and cannot be run directly." + }; + } + + var sw = Stopwatch.StartNew(); + var vm = new VirtualMachine(bytecode, entry.entryPointAddress) + { + hostMethods = hostMethods, + // Test-mode: a failed assert (here or in main-program code reached + // via `runto`) records a TestFailure instead of throwing. + isTestExecution = true + }; + onVmCreated?.Invoke(vm); + try + { + vm.Execute3(0); // infinite budget! + } + catch (VirtualRuntimeException rex) + { + // The VM threw a structured runtime error. Resolve the + // call-stack snapshot it carries into source-located frames + // when DebugData is available, so the failure pane shows + // where the crash actually happened (not just "VM threw"). + sw.Stop(); + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "VM threw: " + rex.Message, + failureInstructionIndex = rex.Error.insIndex, + failureFrames = BuildFrames(rex.Error.insIndex, rex.Error.callStack, debugData), + duration = sw.Elapsed + }; + } + catch (Exception ex) + { + sw.Stop(); + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "VM threw: " + ex.Message, + duration = sw.Elapsed + }; + } + sw.Stop(); + + if (vm.assertionFailure != null) + { + var reason = vm.assertionFailure.reason; + var hasReason = !string.IsNullOrEmpty(reason); + var msg = hasReason + ? $"assert failed: {vm.assertionFailure.sourceText} — {reason}" + : $"assert failed: {vm.assertionFailure.sourceText}"; + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = msg, + failureSourceText = vm.assertionFailure.sourceText, + failureReason = reason, + failureInstructionIndex = vm.assertionFailure.instructionIndex, + failureFrames = BuildFrames( + vm.assertionFailure.instructionIndex, + vm.assertionFailure.callStack, + debugData), + duration = sw.Elapsed + }; + } + + return new FadeTestResult + { + testName = entry.name, + passed = true, + duration = sw.Elapsed + }; + } + + // Build a FadeTestResult from a VM that's already finished + // running (or been interrupted) — same result shapes RunTest + // returns, but for hosts that drive Execute3 themselves and + // want to reuse the result-extraction logic. + // + // `runtimeException`: pass if Execute3 threw (test crashed). + // Otherwise pass null; the VM's assertionFailure / completion + // is inspected to decide pass vs. assert-failed. + public static FadeTestResult BuildResultFromVm( + VirtualMachine vm, + TestManifestEntry entry, + System.TimeSpan elapsed, + DebugData debugData, + System.Exception runtimeException = null) + { + if (runtimeException is VirtualRuntimeException rex) + { + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "VM threw: " + rex.Message, + failureInstructionIndex = rex.Error.insIndex, + failureFrames = BuildFrames(rex.Error.insIndex, rex.Error.callStack, debugData), + duration = elapsed, + }; + } + if (runtimeException != null) + { + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "VM threw: " + runtimeException.Message, + duration = elapsed, + }; + } + if (vm.assertionFailure != null) + { + var reason = vm.assertionFailure.reason; + var hasReason = !string.IsNullOrEmpty(reason); + var msg = hasReason + ? $"assert failed: {vm.assertionFailure.sourceText} — {reason}" + : $"assert failed: {vm.assertionFailure.sourceText}"; + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = msg, + failureSourceText = vm.assertionFailure.sourceText, + failureReason = reason, + failureInstructionIndex = vm.assertionFailure.instructionIndex, + failureFrames = BuildFrames( + vm.assertionFailure.instructionIndex, + vm.assertionFailure.callStack, + debugData), + duration = elapsed, + }; + } + return new FadeTestResult + { + testName = entry.name, + passed = true, + duration = elapsed, + }; + } + + // Resolve a VM call-stack snapshot into source-located frames using + // DebugData. Returns an empty list when DebugData is null (best-effort: + // callers fall back to entry.sourceLine in that case). + // + // Generic over the error source: both TestFailure (assert in test mode) + // and VirtualRuntimeError (any runtime crash) carry the same shape + // (instructionIndex + callStack), so this helper takes the two raw + // pieces rather than either struct. + // + // Walk strategy mirrors DebugSession.GetFrames2: + // 1. The "innermost" frame's source location is the IP at failure. + // 2. For each entry in methodStack (top-down), the function name + // comes from insToFunction[toIns], and the NEXT frame's source + // location comes from the call site (fromIns - 1). + public static List BuildFrames( + int instructionIndex, + JumpHistoryData[] callStack, + DebugData debugData) + { + var frames = new List(); + if (debugData == null) return frames; + callStack = callStack ?? System.Array.Empty(); + + var indexMap = new IndexCollection(debugData.statementTokens); + + // Start with the failure site itself. + if (!indexMap.TryFindClosestTokenBeforeIndex(instructionIndex, out var currentToken)) + { + return frames; + } + + // Walk the snapshotted methodStack. callStack[0] is innermost. + for (var i = 0; i < callStack.Length; i++) + { + var frame = callStack[i]; + var functionName = ""; + if (debugData.insToFunction.TryGetValue(frame.toIns, out var fnToken)) + { + functionName = fnToken.token?.raw ?? functionName; + } + frames.Add(new FadeStackFrame + { + functionName = functionName, + lineNumber = currentToken.token.lineNumber, + charNumber = currentToken.token.charNumber, + instructionIndex = instructionIndex + }); + // Resolve the next frame's location to the call site of this + // frame (fromIns - 1, matching DebugSession.GetFrames2). + if (!indexMap.TryFindClosestTokenBeforeIndex(frame.fromIns - 1, out currentToken)) + { + return frames; + } + } + + // Outermost frame: code that wasn't inside any function call — + // either the test body itself or main-program code reached via + // runto. Function name is left empty; consumers can substitute + // their own label (e.g., the test name). + frames.Add(new FadeStackFrame + { + functionName = string.Empty, + lineNumber = currentToken.token.lineNumber, + charNumber = currentToken.token.charNumber, + instructionIndex = callStack.Length > 0 + ? callStack[callStack.Length - 1].fromIns - 1 + : instructionIndex + }); + return frames; + } + } + + /// + /// A single source-located frame in an assertion-failure stack trace. + /// Built from the VM's methodStack snapshot + DebugData by the test runner. + /// + public class FadeStackFrame + { + // Name of the function the frame is inside, or "" for the outermost + // (test body / main-program) frame. + public string functionName; + // Source line in the same coordinate space the rest of the compiler + // uses (0-based, as emitted by the lexer). Consumers that need to + // display 1-based line numbers should add 1. Source-map resolution + // for multi-file projects happens upstream of the runner. + public int lineNumber; + public int charNumber; + public int instructionIndex; + } + + public class FadeTestResult + { + public string testName; + public bool passed; + // Null when passed. + public string failureMessage; + // Captured assertion text from the failing `assert` (when an assert tripped). + public string failureSourceText; + // Optional reason string supplied via `assert , ""`. Null + // or empty when the user didn't provide one. + public string failureReason; + // IP at the moment of failure; useful for source-mapping when DebugData + // is available. -1 if not applicable. + public int failureInstructionIndex = -1; + // Source-located stack frames at the moment of failure (innermost first, + // outermost last). Empty when DebugData wasn't available at run time; + // callers should fall back to entry.sourceLine in that case. + public List failureFrames = new List(); + public TimeSpan duration; + } + + public class FadeTestRunResult + { + public List tests = new List(); + public int passedCount; + public int failedCount; + public bool AllPassed => failedCount == 0 && tests.Count > 0; + public TimeSpan duration; + } + + public partial class FadeRuntimeContext + { + // Concrete tests (skips abstract fixtures). + public IEnumerable Tests + { + get + { + foreach (var t in Compiler.TestManifest) + { + if (!t.isAbstract) yield return t; + } + } + } + + public FadeTestResult RunTest(string testName) + { + foreach (var t in Compiler.TestManifest) + { + if (string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)) + { + if (t.isAbstract) + { + return new FadeTestResult + { + testName = testName, + passed = false, + failureMessage = $"Test `{testName}` is abstract and cannot be run directly." + }; + } + return RunTest(t); + } + } + return new FadeTestResult + { + testName = testName, + passed = false, + failureMessage = $"No test named `{testName}` was found in the program." + }; + } + + public FadeTestResult RunTest(TestManifestEntry entry) + { + return FadeTestExecutor.RunTest( + Machine.program, + Compiler.methodTable, + entry, + Compiler.DebugData); + } + + public FadeTestRunResult RunAllTests() + { + var run = new FadeTestRunResult(); + var sw = Stopwatch.StartNew(); + foreach (var t in Compiler.TestManifest) + { + if (t.isAbstract) continue; + var r = RunTest(t); + run.tests.Add(r); + if (r.passed) run.passedCount++; + else run.failedCount++; + } + sw.Stop(); + run.duration = sw.Elapsed; + return run; + } + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/DefaultFadeTestHost.cs b/FadeBasic/FadeBasic/Sdk/Testing/DefaultFadeTestHost.cs new file mode 100644 index 0000000..a84dc74 --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/DefaultFadeTestHost.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using FadeBasic.Sdk; + +namespace FadeBasic.Testing +{ + /// + /// Stateless default host. Each test gets a fresh VirtualMachine + /// via with no host-side reset. + /// Used when the consumer hasn't tagged any class with + /// . + /// + public sealed class DefaultFadeTestHost : IFadeTestHost + { + public Task InitializeAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + + public Task BeforeAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + + public Task RunTestAsync(FadeTestRunContext ctx, CancellationToken ct) + => ctx.RunDefaultAsync(ct); + + public Task AfterAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + + public ValueTask DisposeAsync() => default; + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/FadeManagedIdentifier.cs b/FadeBasic/FadeBasic/Sdk/Testing/FadeManagedIdentifier.cs new file mode 100644 index 0000000..8ad723f --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/FadeManagedIdentifier.cs @@ -0,0 +1,29 @@ +using System.Text; + +namespace FadeBasic.Testing +{ + /// + /// Shared coercion of arbitrary strings (file basenames, assembly names) + /// into C#-shaped identifiers. IDE Test Explorers (Rider, VS Code C# Dev + /// Kit, Visual Studio) parse TestCase.ManagedType as a dotted path + /// of valid identifiers; emitting raw .fbasic basenames with dashes + /// or dots in them produces a broken tree. Both the VSTest adapter and + /// the LSP-based discovery path call into this helper so the tree groups + /// identically across IDEs. + /// + public static class FadeManagedIdentifier + { + public static string ToManagedIdentifier(string raw) + { + if (string.IsNullOrEmpty(raw)) return "Tests"; + var sb = new StringBuilder(raw.Length); + foreach (var c in raw) + { + sb.Append(char.IsLetterOrDigit(c) ? c : '_'); + } + // C# identifiers cannot start with a digit. + if (sb.Length > 0 && char.IsDigit(sb[0])) sb.Insert(0, '_'); + return sb.Length == 0 ? "Tests" : sb.ToString(); + } + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostAttribute.cs b/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostAttribute.cs new file mode 100644 index 0000000..0e21f96 --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace FadeBasic.Testing +{ + /// + /// Tag a class implementing with this attribute + /// to have discover and use it + /// automatically when running under dotnet test. If multiple classes + /// in the entry assembly carry this attribute, the test app will fail with + /// a clear error listing all candidates — exactly one is permitted. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class FadeTestHostAttribute : Attribute + { + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostResolver.cs b/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostResolver.cs new file mode 100644 index 0000000..2a4d3ce --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostResolver.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace FadeBasic.Testing +{ + /// + /// Locates an implementation for the test app + /// to use. Resolution order: + /// + /// The instance passed into (if non-null). + /// A class in the entry assembly tagged + /// that implements . + /// Fallback to . + /// + /// + public static class FadeTestHostResolver + { + // Test-only seam. Set via OverrideForTests; checked first by Resolve so + // unit tests of the VSTest executor can inject a fake host without + // dragging in the [FadeTestHost] attribute discovery path. + private static IFadeTestHost? _testOverride; + + public static IFadeTestHost Resolve(IFadeTestHost? explicitHost) + { + if (explicitHost != null) return explicitHost; + if (_testOverride != null) return _testOverride; + + var discovered = DiscoverFromAttributes(); + if (discovered != null) return discovered; + + return new DefaultFadeTestHost(); + } + + /// + /// Test seam — install a fake host that returns + /// when no explicit host is passed. Returns an + /// that clears the override on disposal so each test owns its own + /// scope. NOT for production code. + /// + public static IDisposable OverrideForTests(IFadeTestHost host) + { + _testOverride = host; + return new TestOverrideScope(); + } + + private sealed class TestOverrideScope : IDisposable + { + public void Dispose() => _testOverride = null; + } + + public static IFadeTestHost? DiscoverFromAttributes() + { + var entry = Assembly.GetEntryAssembly(); + var candidates = new List(); + + // Try the entry assembly first. Under `dotnet run` this is the + // user's app and usually carries the host, so we keep authoring- + // intent priority over anything transitively referenced. + if (entry != null) CollectHostCandidates(entry, candidates); + + // Fall through to all loaded assemblies when the entry didn't + // contain a [FadeTestHost]. Two cases this catches: + // 1. EntryAssembly is null (some test-host scenarios). + // 2. Under `dotnet test`, EntryAssembly is vstest's testhost.dll; + // the launchable carrying the host is loaded into a separate + // collectible ALC by FadeTestLaunchableLoader. AppDomain + // enumerates assemblies across all ALCs. + if (candidates.Count == 0) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm == entry) continue; + CollectHostCandidates(asm, candidates); + } + } + + if (candidates.Count == 0) return null; + if (candidates.Count > 1) + { + var names = string.Join(", ", candidates.Select(c => c.FullName)); + throw new InvalidOperationException( + $"Multiple [FadeTestHost] classes found: {names}. Exactly one is permitted per test app."); + } + + var hostType = candidates[0]; + try + { + return (IFadeTestHost)Activator.CreateInstance(hostType)!; + } + catch (MissingMethodException) + { + throw new InvalidOperationException( + $"[FadeTestHost] class `{hostType.FullName}` must have a public parameterless constructor."); + } + } + + private static void CollectHostCandidates(Assembly asm, List sink) + { + Type[] types; + try { types = asm.GetTypes(); } + catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray()!; } + + foreach (var t in types) + { + if (t == null) continue; + if (t.IsAbstract || t.IsInterface) continue; + if (!typeof(IFadeTestHost).IsAssignableFrom(t)) continue; + if (t.GetCustomAttribute() == null) continue; + sink.Add(t); + } + } + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs b/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs new file mode 100644 index 0000000..43bbc0a --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Virtual; + +namespace FadeBasic.Testing +{ + /// + /// Extensibility point for downstream consumers that want to control the + /// "around" of every Fade test — typically: spin up a Game/graphics-device + /// once, reset host-side state between tests, decide how the VM is driven + /// (synchronous, frame-stepped, debugger-attached). The default implementation + /// () just delegates to + /// . + /// + /// + /// Lifecycle, in order: + /// + /// — once per process, expensive setup. + /// — once per run. + /// For each test: . + /// . + /// . + /// + /// + public interface IFadeTestHost + { + Task InitializeAsync(FadeTestSessionContext ctx, CancellationToken ct); + + Task BeforeAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct); + + /// + /// Run a single test. Implementations typically: + /// (1) reset host-side state, (2) build/reuse a VM at + /// ctx.Entry.entryPointAddress, (3) drive the VM, (4) translate + /// the outcome to a . Hosts that only want + /// to wrap default behavior should call ctx.RunDefaultAsync(ct). + /// + Task RunTestAsync(FadeTestRunContext ctx, CancellationToken ct); + + Task AfterAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct); + + ValueTask DisposeAsync(); + } + + /// + /// Information passed to per-session host hooks. The launchable carries the + /// bytecode + commands; is MTP's service provider + /// (logger, output device, etc.), null when running outside MTP (unit + /// tests of the host). + /// + public sealed class FadeTestSessionContext + { + public ITestLaunchable Launchable { get; } + public IServiceProvider? Services { get; } + + public FadeTestSessionContext(ITestLaunchable launchable, IServiceProvider? services) + { + Launchable = launchable; + Services = services; + } + } + + /// + /// Information passed to per-test host hooks. + /// + public sealed class FadeTestRunContext + { + public ITestLaunchable Launchable { get; } + public TestManifestEntry Entry { get; } + public HostMethodTable HostMethods { get; } + + public FadeTestRunContext(ITestLaunchable launchable, TestManifestEntry entry, HostMethodTable hostMethods) + { + Launchable = launchable; + Entry = entry; + HostMethods = hostMethods; + } + + /// + /// Convenience for hosts that only need to wrap default execution. + /// Returns the same result the would + /// produce for this test. + /// + public Task RunDefaultAsync(CancellationToken ct) + { + // FadeTestExecutor.RunTest is synchronous today. Wrap it; once + // cooperative cancellation lands inside the VM, this becomes an + // actual async call. + ct.ThrowIfCancellationRequested(); + var result = FadeTestExecutor.RunTest( + Launchable.Bytecode, HostMethods, Entry, Launchable.DebugData); + return Task.FromResult(result); + } + } +} diff --git a/FadeBasic/FadeBasic/TokenFormatter.cs b/FadeBasic/FadeBasic/TokenFormatter.cs index e0af1a7..18f8470 100644 --- a/FadeBasic/FadeBasic/TokenFormatter.cs +++ b/FadeBasic/FadeBasic/TokenFormatter.cs @@ -93,6 +93,9 @@ enum LexemFlags [LexemType.KeywordSelect] = LexemFlags.PUSH_INDENT, [LexemType.KeywordCase] = LexemFlags.PUSH_INDENT, [LexemType.KeywordCaseDefault] = LexemFlags.PUSH_INDENT, + [LexemType.KeywordTest] = LexemFlags.PUSH_INDENT, + [LexemType.ConstantBegin] = LexemFlags.PUSH_INDENT, + [LexemType.ConstantTokenize] = LexemFlags.PUSH_INDENT, [LexemType.KeywordElse] = LexemFlags.PUSH_AND_POP_INDENT, @@ -105,6 +108,9 @@ enum LexemFlags [LexemType.KeywordUntil] = LexemFlags.POP_INDENT, [LexemType.KeywordEndCase] = LexemFlags.POP_INDENT, [LexemType.KeywordEndSelect] = LexemFlags.POP_INDENT, + [LexemType.KeywordEndTest] = LexemFlags.POP_INDENT, + [LexemType.ConstantEnd] = LexemFlags.POP_INDENT, + [LexemType.ConstantEndTokenize] = LexemFlags.POP_INDENT, // TODO: add other keywords }; @@ -120,7 +126,7 @@ public static List Format(List tokens, TokenFormatSettin var clone = new List(); foreach (var t in tokens) { - if (t.lexem.type == LexemType.EndStatement) continue; + if (t.type == LexemType.EndStatement) continue; clone.Add(t); } diff --git a/FadeBasic/FadeBasic/Virtual/Compiler.cs b/FadeBasic/FadeBasic/Virtual/Compiler.cs index 047dcd8..c7e4ee0 100644 --- a/FadeBasic/FadeBasic/Virtual/Compiler.cs +++ b/FadeBasic/FadeBasic/Virtual/Compiler.cs @@ -19,7 +19,7 @@ public class CompiledVariable : IJsonable, IJsonableSerializationCallbacks private string registerAddressSerializer; public bool isGlobal; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(byteSize), ref byteSize); op.IncludeField(nameof(typeCode), ref typeCode); @@ -53,7 +53,7 @@ public class CompiledArrayVariable : IJsonable, IJsonableSerializationCallbacks public bool isGlobal; public byte[] rankSizeRegisterAddresses; // an array where the index is the rank, and the value is the ptr to a register whose value holds the size of the rank public byte[] rankIndexScalerRegisterAddresses; // an array where the index is the rank, and the value is the ptr to a register whose value holds the multiplier factor for the rank's indexing - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(byteSize), ref byteSize); op.IncludeField(nameof(typeCode), ref typeCode); @@ -82,7 +82,7 @@ public class CompiledType : IJsonable public int typeId; public int byteSize; public Dictionary fields = new Dictionary(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(typeName), ref typeName); op.IncludeField(nameof(typeId), ref typeId); @@ -97,7 +97,7 @@ public struct CompiledTypeMember : IJsonable public byte TypeCode; public CompiledType Type; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(Offset), ref Offset); op.IncludeField(nameof(Length), ref Length); @@ -109,9 +109,60 @@ public void ProcessJson(IJsonOperation op) public struct LabelReplacement { public int InstructionIndex; + // Region-prefixed label key (see Compiler.MakeLabelKey). Built + // at emit time from the current label region + the user-written + // label name so two tests / two functions with same-named labels + // resolve independently. Runto replacements use the main-body + // region prefix regardless of where the `runto X` was written. public string Label; } + public class TestManifestEntry : IJsonable + { + public string name; + public int entryPointAddress; + public bool isAbstract; + public string fromParent; // null if no parent + + // sourceLine/sourceChar are reported in the ORIGINATING file's coordinate + // space (1-based line numbers as the user sees them). The compiler + // initially stamps these in the concatenated-source space; a post-compile + // pass remaps them via SourceMap when one is available. The originating + // file path goes in ; null/empty means the + // file is unknown (no source map provided), and consumers should treat + // line/char as best-effort positions only. + public int sourceLine; + public int sourceChar; + public string sourceFilePath; + + public void ProcessJson(ref T op) where T : IJsonOperation + { + op.IncludeField(nameof(name), ref name); + op.IncludeField(nameof(entryPointAddress), ref entryPointAddress); + op.IncludeField(nameof(isAbstract), ref isAbstract); + op.IncludeField(nameof(fromParent), ref fromParent); + op.IncludeField(nameof(sourceLine), ref sourceLine); + op.IncludeField(nameof(sourceChar), ref sourceChar); + op.IncludeField(nameof(sourceFilePath), ref sourceFilePath); + } + } + + /// + /// Serializable wrapper around the compiler's test manifest. Used by + /// LaunchUtil.PackTestManifest / UnpackTestManifest to bake + /// the manifest into the generated launchable so console-app builds can + /// support --fade-test=name at runtime. + /// + public class TestManifest : IJsonable + { + public List entries = new List(); + + public void ProcessJson(ref T op) where T : IJsonOperation + { + op.IncludeField(nameof(entries), ref entries); + } + } + public struct FunctionCallReplacement { public int InstructionIndex; @@ -230,7 +281,7 @@ public class InternedScopeMetadata : IJsonableSerializationCallbacks public ulong maxRegisterSize; private string maxRegisterSizeSerializer; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(scopeIndex), ref scopeIndex); op.IncludeField(nameof(maxRegisterSizeSerializer), ref maxRegisterSizeSerializer); @@ -255,7 +306,7 @@ public class InternedData : IJsonable, IJsonableSerializationCallbacks // public Dictionary scopeMetaDatas = new Dictionary(); public ulong maxRegisterAddress; private string maxRegisterAddressSerializer; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(types), ref types); op.IncludeField(nameof(functions), ref functions); @@ -279,7 +330,7 @@ public class InternedString : IJsonable public string value; public int[] indexReferences; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(value), ref value); op.IncludeField(nameof(indexReferences), ref indexReferences); @@ -294,7 +345,7 @@ public class InternedFunction : IJsonable public int typeCode; public int typeId; public List parameters = new List(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(name), ref name); op.IncludeField(nameof(insIndex), ref insIndex); @@ -312,7 +363,7 @@ public class InternedFunctionParameter : IJsonable public int typeCode; public int typeId; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(name), ref name); op.IncludeField(nameof(index), ref index); @@ -327,7 +378,7 @@ public class InternedType : IJsonable public int byteSize; public int typeId; public Dictionary fields = new Dictionary(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(name), ref name); op.IncludeField(nameof(typeId), ref typeId); @@ -343,7 +394,7 @@ public class InternedField : IJsonable public string typeName; public int typeId; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(offset), ref offset); op.IncludeField(nameof(length), ref length); @@ -376,12 +427,59 @@ public class Compiler private Dictionary _commandToPtr = new Dictionary(); private Stack> _exitInstructionIndexes = new Stack>(); private Stack> _skipInstructionIndexes = new Stack>(); + private readonly Stack> _jumpIndexPool = new Stack>(); + + private List RentJumpList() + { + if (_jumpIndexPool.Count > 0) { var l = _jumpIndexPool.Pop(); l.Clear(); return l; } + return new List(); + } + + private void ReturnJumpList(List list) => _jumpIndexPool.Push(list); private List _labelReplacements = new List(); + // Keyed by region-prefixed label name. Region is empty for main + // body, "test:" inside a test body, "fn:" inside a + // function body. Each region has its own label namespace so two + // tests / two functions can share label names without collision. private Dictionary _labelToInstructionIndex = new Dictionary(); + // The region currently being compiled. Compile(LabelDeclarationNode) + // builds the key from this region; Compile(GotoStatement) / + // Compile(GoSubStatement) stamp the region-prefixed key into the + // emitted replacement so resolution stays scoped. + private string _currentLabelRegion = ""; + + // Compose a label dictionary key from a region + user-written + // label name. The `::` separator can't appear in either piece + // (region names are compiler-generated, label names are restricted + // to identifier characters) so the encoding is unambiguous. + private static string MakeLabelKey(string region, string label) + => (region ?? "") + "::" + label; + + // For each `runto label` call site, record where in the bytecode the + // PUSH int placeholder lives so we can patch the resolved post-yield + // address (label_addr + 2) at the end of compilation. + private List _runtoReplacements = new List(); + + // Set of label names that any test references via `runto`. These get a + // RUNTO_YIELD opcode emitted right after the label's NOOP. Labels that + // aren't runto targets carry no overhead. + private HashSet _runtoTargetLabels = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Manifest of compiled tests: name → entry-point address. Recorded as + // each test body is compiled. Surfaced via the public Manifest property + // and (later) emitted into the interned-data section. + private List _testManifest = new List(); + public IReadOnlyList TestManifest => _testManifest; private List _functionCallReplacements = new List(); private Dictionary _functionTable = new Dictionary(); + + // For each `assert` failure-branch emit site, record the buffer index of + // the placeholder PUSH int that should be patched with the assert-unwind + // trampoline's address. The trampoline is emitted once near program end; + // these get patched after that emission completes. + private List _assertTrampolinePatches = new List(); private InternedData data = new InternedData(); @@ -433,38 +531,64 @@ public void Compile(ProgramNode program) // push a temporary value that will be replaced later. // this value represents the ins-ptr where the interned-data lives. - var value = BitConverter.GetBytes(0); - for (var i = 0 ; i < value.Length; i ++) - { - _buffer.Add(value[i]); - } - + AppendInt32(_buffer, 0); + + // Pre-pass: collect every label name referenced by a `runto` anywhere in + // the test corpus. These labels get a RUNTO_YIELD opcode emitted right + // after their NOOP. Labels not referenced get no overhead, and run builds + // with no tests skip this pass entirely (set stays empty). + CollectRuntoTargets(program); + foreach (var typeDef in program.typeDefinitions) { Compile(typeDef); } - + foreach (var statement in program.statements) { - + Compile(statement); } - // prevent the execution from ever going to the functions. GOTO statements _should_ be illegal to jump into a function's scope. + // prevent the execution from ever going to the functions. GOTO statements _should_ be illegal to jump into a function's scope. CompileEnd(); - + foreach (var function in program.functions) { Compile(function); } + // Tests are compiled as additional, runnable bytecode regions after the + // program's functions but before interned data. Each test gets its own + // entry point recorded in the manifest. A test instance is launched via + // `new VirtualMachine(program, manifest.entryPointAddress)`. + // + // Two-phase emission so `from`-chains work without duplicating body + // bytecode: phase 1 lays down each test's body region (statements + + // RETURN, then any test-scoped functions) and records its start + // address; phase 2 lays down each test's launcher region (a flat + // sequence of JUMP_HISTORY → ancestor-body, ..., JUMP_HISTORY → self- + // body, HALT) and stamps the manifest with the launcher address. + // Running a test = jump to its launcher, which GOSUBs through the + // full chain in order, sharing the VM's scope/registers/mock-table. + var testBodyAddresses = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var test in program.tests) + { + CompileTestBody(test, testBodyAddresses); + } + foreach (var test in program.tests) + { + CompileTestLauncher(test, testBodyAddresses, program.tests); + } + + // Emit the assert-unwind trampoline after tests, before interned + // data. ASSERT_FAIL sites pushed a placeholder for its address; the + // call below patches every site once the real address is known. + CompileAssertUnwindTrampoline(); + { // handle interned data - { // replace the jump ptr at index=0 to tell us where the data lives. - var internLocationBytes = BitConverter.GetBytes(_buffer.Count); - for (var i = 0; i < internLocationBytes.Length; i++) - { - _buffer[0 + i] = internLocationBytes[i]; - } + { // replace the jump ptr at index=0 to tell us where the data lives. + PatchInt32(_buffer, 0, _buffer.Count); } PushInternedData(); @@ -472,26 +596,190 @@ public void Compile(ProgramNode program) CompileJumpReplacements(); } + private void CollectRuntoTargets(ProgramNode program) + { + void Visit(IAstVisitable node) + { + if (node is RuntoStatement runto) + { + _runtoTargetLabels.Add(runto.targetLabel); + } + } + foreach (var test in program.tests) + { + test.Visit(Visit); + } + } + + // Phase 1: emit a test's body region (statements + RETURN), then any + // test-scoped functions. Records the body's start address in + // `bodyAddresses` so phase 2 launchers can GOSUB to it. Manifest + // entry is added in phase 2 (so it points at the launcher, not the + // body) — but we tag the body's start instruction for debugger + // function-name resolution here. + private void CompileTestBody(TestNode test, + Dictionary bodyAddresses) + { + var bodyStart = _buffer.Count; + if (test.name != null) + { + bodyAddresses[test.name] = bodyStart; + } + + // Set the label region for this test so two tests with same- + // named labels don't collide at jump-replacement time. Restore + // on the way out — nested compile of test-scoped functions + // below will set their own regions over this. + var prevRegion = _currentLabelRegion; + _currentLabelRegion = "test:" + (test.name ?? ""); + + // Compile the test body. The dispatch in Compile(IStatementNode) skips + // FunctionStatement nodes — they're emitted separately below — so the + // body's own function declarations don't pollute the test's entry-point + // bytecode region. + foreach (var statement in test.testProgram.statements) + { + Compile(statement); + } + + // RETURN instead of HALT: when invoked via the launcher's + // JUMP_HISTORY, this returns control so the next ancestor-or- + // self body in the chain can run. + // + // DEFER drains here are intentionally OMITTED. A `from`-child + // is semantically a continuation of its parent — parent's + // teardown (defer) statements should fire at the END of the + // chain, after the child's body, not at parent's RETURN. + // Defers register on the shared deferredJumps stack; the + // launcher's CompileEnd() drains them once after every body + // in the chain has run. Standalone tests get identical + // behavior — the launcher's drain runs after a single body. + _buffer.Add(OpCodes.RETURN); + + // Restore the label region. Test-scoped functions emitted below + // re-set their own region inside Compile(FunctionStatement). + _currentLabelRegion = prevRegion; + + // Now compile any test-scoped functions. They live alongside program + // functions in the bytecode blob and register themselves in the + // shared _functionTable, which means the test body can call them by + // name. (Stage 6 narrows visibility via the from-chain in a follow-up + // pass; for v1 they're globally addressable, which is permissive.) + foreach (var function in test.testProgram.functions) + { + Compile(function); + } + } + + // Phase 2: emit a test's launcher region — what the manifest's + // entryPointAddress points to. Walks the from-chain (root → self, + // skipping any test whose body we don't have, which covers + // chain-broken cases the visitor already errored on) and emits a + // JUMP_HISTORY → body for each, finishing with a HALT. + // + // Each ancestor's body ends with RETURN, popping the launcher's + // pushed return frame so the next JUMP_HISTORY fires. State + // (registers, mock table, runto position) flows naturally between + // segments because they share the same VM context. + private void CompileTestLauncher(TestNode test, + Dictionary bodyAddresses, + List allTests) + { + var launcherStart = _buffer.Count; + _testManifest.Add(new TestManifestEntry + { + name = test.name, + entryPointAddress = launcherStart, + isAbstract = test.isAbstract, + fromParent = test.fromParent, + sourceLine = test.startToken?.lineNumber ?? 0, + sourceChar = test.startToken?.charNumber ?? 0 + }); + + var chain = ResolveTestFromChain(test, allTests); + foreach (var member in chain) + { + if (!bodyAddresses.TryGetValue(member.name ?? "", out var bodyAddr)) + { + // No body recorded — likely a cycle-broken test the + // visitor already flagged. Skip it to avoid an unresolved + // GOSUB target. + continue; + } + AddPushInt(_buffer, bodyAddr); + _buffer.Add(OpCodes.JUMP_HISTORY_LAUNCH); + } + CompileEnd(); + } + + // Walk a test's from-chain from root to self. Bail with just + // [self] if we detect a cycle so we don't loop forever; the + // visitor's TestFromParentCycle error tells the user what's wrong. + // Unknown parents are similarly cut off — the chain stops where + // the name fails to resolve. + private List ResolveTestFromChain(TestNode test, + List allTests) + { + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var t in allTests) + { + if (t.name != null) byName[t.name] = t; + } + + var chain = new List { test }; + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + if (test.name != null) visited.Add(test.name); + + var cursor = test; + while (cursor.fromParent != null + && byName.TryGetValue(cursor.fromParent, out var parent)) + { + if (parent.name != null && !visited.Add(parent.name)) + { + // Cycle — bail. The chain we have so far isn't useful + // (we'd loop), so prefer "self only" semantics. + return new List { test }; + } + chain.Add(parent); + cursor = parent; + } + chain.Reverse(); // root first + return chain; + } + public void CompileJumpReplacements() { - + // replace all label instructions... foreach (var replacement in _labelReplacements) { - // TODO: look up in labelTable if (!_labelToInstructionIndex.TryGetValue(replacement.Label, out var location)) { throw new Exception("Compiler: unknown label location " + replacement.Label); } - var locationBytes = BitConverter.GetBytes(location); - for (var i = 0; i < locationBytes.Length; i++) + PatchInt32(_buffer, replacement.InstructionIndex + 2, location); + } + + // replace all runto target placeholders. The runto target address is + // the byte AFTER the label's RUNTO_YIELD opcode (= label_addr + 2). + // RUNTO_YIELD checks `runtoStack.Peek().target == instructionIndex`, + // and instructionIndex at that point is post-RUNTO_YIELD, so we need + // to bake `label_addr + 2` into the PUSH int placeholder. Runto + // always targets MAIN-BODY labels regardless of where `runto X` + // was written, so the lookup uses the main-body region prefix. + foreach (var replacement in _runtoReplacements) + { + var mainKey = MakeLabelKey("", replacement.Label); + if (!_labelToInstructionIndex.TryGetValue(mainKey, out var location)) { - // offset by 2, because of the opcode, and the type code - _buffer[replacement.InstructionIndex + 2 + i] = locationBytes[i]; + throw new Exception("Compiler: unknown runto target label " + replacement.Label); } + + var postYieldAddr = location + 2; // skip the NOOP and the RUNTO_YIELD + PatchInt32(_buffer, replacement.InstructionIndex + 2, postYieldAddr); } - + // replace all function instrunctions foreach (var replacement in _functionCallReplacements) { @@ -500,11 +788,7 @@ public void CompileJumpReplacements() throw new Exception("Compiler: unknown function location " + replacement.FunctionName); } - var locationBytes = BitConverter.GetBytes(location); - for (var i = 0; i < locationBytes.Length; i++) - { - _buffer[replacement.InstructionIndex + 2 + i] = locationBytes[i]; - } + PatchInt32(_buffer, replacement.InstructionIndex + 2, location); } } @@ -716,6 +1000,24 @@ public void Compile(IStatementNode statement) case FunctionReturnStatement returnStatement: Compile(returnStatement); break; + case RuntoStatement runtoStatement: + Compile(runtoStatement); + break; + case AssertStatement assertStatement: + Compile(assertStatement); + break; + case MockStatement mockStatement: + Compile(mockStatement); + break; + case MockExitMockStatement mockReturnsStatement: + Compile(mockReturnsStatement); + break; + case MockForbidStatement mockForbidStatement: + Compile(mockForbidStatement); + break; + case ClearMockStatement clearMockStatement: + Compile(clearMockStatement); + break; case ExpressionStatement expressionStatement: Compile(expressionStatement); break; @@ -779,11 +1081,7 @@ void HandleDeferExit() _buffer.Add(OpCodes.DISCARD_TYPED); // fix the end value - var exitAddrBytes = BitConverter.GetBytes(loopEndAddress); - for (var i = 0; i < exitAddrBytes.Length; i++) - { - _buffer[replaceExitIndex + 2 + i] = exitAddrBytes[i]; - } + PatchInt32(_buffer, replaceExitIndex + 2, loopEndAddress); } void CompilePopScope() { @@ -826,18 +1124,9 @@ void Compile(DeferStatement deferStatement) // this location is the place the defer should jump execution to. var exitAddr = _buffer.Count; _buffer.Add(OpCodes.NOOP); - var exitAddrBytes = BitConverter.GetBytes(exitAddr); - for (var i = 0; i < exitAddrBytes.Length; i++) - { - _buffer[exitAddrIndex + 2 + i] = exitAddrBytes[i]; - } - + PatchInt32(_buffer, exitAddrIndex + 2, exitAddr); // fix up the defer add - var deferAddrBytes = BitConverter.GetBytes(deferAddrValue); - for (var i = 0; i < deferAddrBytes.Length; i++) - { - _buffer[deferAddrIndex + 2 + i] = deferAddrBytes[i]; - } + PatchInt32(_buffer, deferAddrIndex + 2, deferAddrValue); } void Compile(MacroTokenizeStatement tokenizeStatement) @@ -938,14 +1227,8 @@ void Compile(MacroTokenizeStatement tokenizeStatement) // this is the end of the tokenization substitutions block, so execution can safely jump here. var exitAddr = _buffer.Count; - var exitAddrBytes = BitConverter.GetBytes(exitAddr); foreach (var exitIns in replacementIndexes) - { - for (var i = 0; i < exitAddrBytes.Length; i++) - { - _buffer[exitIns + 2 + i] = exitAddrBytes[i]; - } - } + PatchInt32(_buffer, exitIns + 2, exitAddr); } void CompileAsInvocation(ArrayIndexReference expr) @@ -1016,7 +1299,13 @@ private void Compile(FunctionStatement functionStatement) // push a new scope CompilePushScope(); - + + // Labels inside this function get their own region — two + // functions can share label names without resolving to each + // other's body. Restored at function end. + var prevRegion = _currentLabelRegion; + _currentLabelRegion = "fn:" + functionStatement.name; + // now, we need to pull values off the stack and put them into variable declarations... // foreach (var arg in functionStatement.parameters) for (var i = functionStatement.parameters.Count - 1; i >= 0; i --) // read in reverse order due to stack @@ -1065,12 +1354,13 @@ private void Compile(FunctionStatement functionStatement) // at the end of the function, we need to jump home // pop a scope CompilePopScope(); - + // and then jump home _buffer.Add(OpCodes.RETURN); - + + _currentLabelRegion = prevRegion; } - + private void Compile(ExitLoopStatement exitLoopStatement) { // immediately jump to the exit... @@ -1167,34 +1457,15 @@ private void Compile(SwitchStatement switchStatement) // now do all the address replacements.... for (var i = 0; i < switchStatement.cases.Count; i++) { - var indexes = pairInsIndexes[i]; var caseAddr = caseAddrValues[i]; - var caseAddrBytes = BitConverter.GetBytes(caseAddr); - foreach (var index in indexes) - { - for (var j = 0; j < caseAddrBytes.Length; j++) - { - _buffer[index + 2 + j] = caseAddrBytes[j]; - } - } - } - - // replace the default address at the start of the function - var defaultAddrBytes = BitConverter.GetBytes(defaultAddr); - for (var i = 0; i < defaultAddrBytes.Length; i++) - { - _buffer[defaultInsIndex + 2 + i] = defaultAddrBytes[i]; + foreach (var index in pairInsIndexes[i]) + PatchInt32(_buffer, index + 2, caseAddr); } - // replace all the individual case statement's references to the jump exit - var exitAddrBytes = BitConverter.GetBytes(exitAddr); + PatchInt32(_buffer, defaultInsIndex + 2, defaultAddr); + foreach (var exitIns in exitInsIndexes) - { - for (var i = 0; i < exitAddrBytes.Length; i++) - { - _buffer[exitIns + 2 + i] = exitAddrBytes[i]; - } - } + PatchInt32(_buffer, exitIns + 2, exitAddr); } private void Compile(ForStatement forStatement) @@ -1265,51 +1536,35 @@ private void Compile(ForStatement forStatement) // keep track of the first index of the success var successJumpValue = _buffer.Count; - _exitInstructionIndexes.Push(new List()); - _skipInstructionIndexes.Push(new List()); + _exitInstructionIndexes.Push(RentJumpList()); + _skipInstructionIndexes.Push(RentJumpList()); foreach (var successStatement in forStatement.statements) { Compile(successStatement); } var exitStatementIndexes = _exitInstructionIndexes.Pop(); var skipStatementIndexes = _skipInstructionIndexes.Pop(); - - // This is the location where Step updates and evaluation happens + + // This is the location where Step updates and evaluation happens // (important as skip should jump here, not to the very start) var stepLoopValue = _buffer.Count; - + // now to update the value of x, we need to add the stepExpr to it. Compile(stepAssignment); // NOTE: there could be a bug here, because we are looping on a deterministic math operation, but simulating the interpolated variable - + // jump back to the start AddPushInt(_buffer, forLoopValue); _buffer.Add(OpCodes.JUMP); - + var endJumpValue = _buffer.Count; - - // now go back and fill in the success ptr - var successJumpBytes = BitConverter.GetBytes(successJumpValue); - var endJumpBytes = BitConverter.GetBytes(endJumpValue); - var stepLoopBytes = BitConverter.GetBytes(stepLoopValue); - - for (var i = 0; i < successJumpBytes.Length; i++) - { - // offset by 2, because of the opcode, and the type code - _buffer[successJumpIndex + 2 + i] = successJumpBytes[i]; - _buffer[exitJumpIndex + 2 + i] = endJumpBytes[i]; - _buffer[lteExitJumpIndex + 2 + i] = endJumpBytes[i]; - - foreach (var index in exitStatementIndexes) - { - _buffer[index + 2 + i] = endJumpBytes[i]; - } - - // Update skip instructions to jump to the increment/evaluation part - foreach (var index in skipStatementIndexes) - { - _buffer[index + 2 + i] = stepLoopBytes[i]; - } - } + + PatchInt32(_buffer, successJumpIndex + 2, successJumpValue); + PatchInt32(_buffer, exitJumpIndex + 2, endJumpValue); + PatchInt32(_buffer, lteExitJumpIndex + 2, endJumpValue); + foreach (var index in exitStatementIndexes) PatchInt32(_buffer, index + 2, endJumpValue); + foreach (var index in skipStatementIndexes) PatchInt32(_buffer, index + 2, stepLoopValue); + ReturnJumpList(exitStatementIndexes); + ReturnJumpList(skipStatementIndexes); } @@ -1320,8 +1575,8 @@ private void Compile(DoLoopStatement doLoopStatement) // keep track of the first index of the success var successJumpValue = _buffer.Count; - _exitInstructionIndexes.Push(new List()); - _skipInstructionIndexes.Push(new List()); + _exitInstructionIndexes.Push(RentJumpList()); + _skipInstructionIndexes.Push(RentJumpList()); foreach (var successStatement in doLoopStatement.statements) { Compile(successStatement); @@ -1335,26 +1590,11 @@ private void Compile(DoLoopStatement doLoopStatement) var endJumpValue = _buffer.Count; _buffer.Add(OpCodes.NOOP); - - // now go back and fill in the success ptr - var successJumpBytes = BitConverter.GetBytes(successJumpValue); - var endJumpBytes = BitConverter.GetBytes(endJumpValue); - var whileLoopBytes = BitConverter.GetBytes(whileLoopValue); - - for (var i = 0; i < successJumpBytes.Length; i++) - { - // offset by 2, because of the opcode, and the type code - foreach (var index in exitStatementIndexes) - { - _buffer[index + 2 + i] = endJumpBytes[i]; - } - - // Update skip instructions to jump back to the beginning of the loop - foreach (var index in skipStatementIndexes) - { - _buffer[index + 2 + i] = whileLoopBytes[i]; - } - } + + foreach (var index in exitStatementIndexes) PatchInt32(_buffer, index + 2, endJumpValue); + foreach (var index in skipStatementIndexes) PatchInt32(_buffer, index + 2, whileLoopValue); + ReturnJumpList(exitStatementIndexes); + ReturnJumpList(skipStatementIndexes); } @@ -1363,57 +1603,33 @@ private void Compile(RepeatUntilStatement repeatStatement) // first, keep track of the start of the while loop var startValue = _buffer.Count; - _exitInstructionIndexes.Push(new List()); - _skipInstructionIndexes.Push(new List()); - + _exitInstructionIndexes.Push(RentJumpList()); + _skipInstructionIndexes.Push(RentJumpList()); + foreach (var successStatement in repeatStatement.statements) { Compile(successStatement); } var exitStatementIndexes = _exitInstructionIndexes.Pop(); var skipStatementIndexes = _skipInstructionIndexes.Pop(); - + // keep track of where the skip should go var skipJumpValue = _buffer.Count; - - // compile the condition expression + Compile(repeatStatement.condition); - // cast the expression to an int _buffer.Add(OpCodes.CAST); _buffer.Add(TypeCodes.INT); - - // the semantics of the word, "until", mean we flip the condition value _buffer.Add(OpCodes.NOT); - - // then, insert the starting address AddPushInt(_buffer, startValue); - - // then, maybe jump to the start? _buffer.Add(OpCodes.JUMP_GT_ZERO); - - // if we didn't jump, then we are done! - + var endJumpValue = _buffer.Count; _buffer.Add(OpCodes.NOOP); - - // now go back and fill in the jump addresses - var endJumpBytes = BitConverter.GetBytes(endJumpValue); - var skipValueBytes = BitConverter.GetBytes(skipJumpValue); - - for (var i = 0; i < endJumpBytes.Length; i++) - { - // offset by 2, because of the opcode, and the type code - foreach (var index in exitStatementIndexes) - { - _buffer[index + 2 + i] = endJumpBytes[i]; - } - - // Update skip instructions to jump back to the beginning of the loop - foreach (var index in skipStatementIndexes) - { - _buffer[index + 2 + i] = skipValueBytes[i]; - } - } + + foreach (var index in exitStatementIndexes) PatchInt32(_buffer, index + 2, endJumpValue); + foreach (var index in skipStatementIndexes) PatchInt32(_buffer, index + 2, skipJumpValue); + ReturnJumpList(exitStatementIndexes); + ReturnJumpList(skipStatementIndexes); } @@ -1458,138 +1674,900 @@ private void Compile(WhileStatement whileStatement) // keep track of the first index of the success var successJumpValue = _buffer.Count; - _exitInstructionIndexes.Push(new List()); - _skipInstructionIndexes.Push(new List()); + _exitInstructionIndexes.Push(RentJumpList()); + _skipInstructionIndexes.Push(RentJumpList()); foreach (var successStatement in whileStatement.statements) { - Compile(successStatement); + Compile(successStatement); + } + var exitStatementIndexes = _exitInstructionIndexes.Pop(); + var skipStatementIndexes = _skipInstructionIndexes.Pop(); + + // at the end of the successful statements, we need to jump back to the start + AddPushInt(_buffer, whileLoopValue); + _buffer.Add(OpCodes.JUMP); + + var endJumpValue = _buffer.Count; + _buffer.Add(OpCodes.NOOP); + + PatchInt32(_buffer, successJumpIndex + 2, successJumpValue); + PatchInt32(_buffer, exitJumpIndex + 2, endJumpValue); + foreach (var index in exitStatementIndexes) PatchInt32(_buffer, index + 2, endJumpValue); + foreach (var index in skipStatementIndexes) PatchInt32(_buffer, index + 2, whileLoopValue); + ReturnJumpList(exitStatementIndexes); + ReturnJumpList(skipStatementIndexes); + } + + private void Compile(IfStatement ifStatement) + { + /* + * + * PUSH addr of Success: + * JUMP_GT_ZERO + * PUSH addr of Else + * JUMP + * Success: + * positive-if-statements + * JUMP Final: + * Else: + * else-if-statements + * Final: + */ + + // first, compile the evaluation of the condition + Compile(ifStatement.condition); + + // cast the expression to an int + _buffer.Add(OpCodes.CAST); + _buffer.Add(TypeCodes.INT); + + // then, put a fake value in for the if-statement success jump... We'll fix it later. + var successJumpIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + + // then, do the jump-gt-zero + _buffer.Add(OpCodes.JUMP_GT_ZERO); + + // if we didn't jump, then we need to load up the ELSE block + var elseJumpIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + + // and then jump to the else block + _buffer.Add(OpCodes.JUMP); + + // now it is time to start compiling the actual statements... + + // keep track of the first index of the success + var successJumpValue = _buffer.Count; + + foreach (var successStatement in ifStatement.positiveStatements) + { + Compile(successStatement); + } + + _dbg?.AddFakeDebugToken(_buffer.Count - 1, ifStatement.endToken); + + // at the end of the successful statements, we need to jump to the end + var endJumpIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP); + + + // this is where the else statements begin + var elseJumpValue = _buffer.Count; + // _buffer.Add(OpCodes.NOOP); + foreach (var elseStatement in ifStatement.negativeStatements) + { + Compile(elseStatement); + } + + // _dbg?.AddFakeDebugToken(_buffer.Count - 1, ifStatement.endToken); + + var endJumpValue = _buffer.Count; + // _buffer.Add(OpCodes.NOOP); + + PatchInt32(_buffer, successJumpIndex + 2, successJumpValue); + PatchInt32(_buffer, elseJumpIndex + 2, elseJumpValue); + PatchInt32(_buffer, endJumpIndex + 2, endJumpValue); + } + + private void Compile(LabelDeclarationNode labelStatement) + { + // take note of instruction number... + _labelToInstructionIndex[MakeLabelKey(_currentLabelRegion, labelStatement.label)] = _buffer.Count; + _buffer.Add(OpCodes.NOOP); + // Emit RUNTO_YIELD only for labels that some test targets via `runto`. + // In `dotnet run` builds where no tests exist, this set is empty and + // there is zero per-label overhead. + if (_runtoTargetLabels.Contains(labelStatement.label)) + { + _buffer.Add(OpCodes.RUNTO_YIELD); + } + } + + private void Compile(RuntoStatement runtoStatement) + { + // Stack at RUNTO dispatch: [..., maxCycles, target]. Target is on top + // so the VM's existing pop order is preserved. Absent `max cycles` + // clause -> push int.MaxValue as the unbounded sentinel. + if (runtoStatement.maxCyclesExpression != null) + { + Compile(runtoStatement.maxCyclesExpression); + } + else + { + AddPushInt(_buffer, int.MaxValue); + } + + // Target placeholder, patched in CompileJumpReplacements with the + // post-yield address (label_addr + 2). + _runtoReplacements.Add(new LabelReplacement + { + InstructionIndex = _buffer.Count, + Label = runtoStatement.targetLabel + }); + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.RUNTO); + } + + private void Compile(AssertStatement assertStatement) + { + // Layout: + // ; pushes int (0 = false, !0 = true) + // PUSH int ; placeholder for skip-on-pass target + // JUMP_GT_ZERO ; if value > 0, jump past failure block + // ; short-circuit: only runs on failure + // + // PUSH int ; address the VM jumps to in test mode + // ASSERT_FAIL ; pops trampoline addr, source text, reason + // :skipAddr (continue normally) + + Compile(assertStatement.condition); + + // Placeholder for the skip address; we patch it after emitting the + // failure branch so we know where the post-failure code starts. + var skipAddrIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP_GT_ZERO); + + // Failure branch — only reached when the condition is zero. Because + // this comes after JUMP_GT_ZERO, the reason expression is evaluated + // lazily: a side-effecting reason (a function call, etc.) only runs + // when the assertion actually fails. + // + // Push the optional reason string first so it sits below the source + // text on the stack, then push the source text, then fail. Literal + // strings are interned via the standard literal-string compile path; + // variable references compile to a heap-pointer push. + if (assertStatement.reason != null) + { + Compile(assertStatement.reason); + } + else + { + Compile(new LiteralStringExpression(assertStatement.startToken, "")); + } + Compile(new LiteralStringExpression(assertStatement.startToken, assertStatement.sourceText ?? "")); + + // Push the trampoline address as a placeholder; ASSERT_FAIL reads it + // off the stack and (in test mode) jumps there to drain defers. We + // patch the real address after the trampoline is emitted. + var trampolinePatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _assertTrampolinePatches.Add(trampolinePatchIndex); + + _buffer.Add(OpCodes.ASSERT_FAIL); + + // Patch the skip address to point at the byte right after the failure + // branch. AddPushInt emits 2 prefix bytes (opcode + type) before the + // 4-byte int payload, so the int value lives at skipAddrIndex+2. + PatchInt32(_buffer, skipAddrIndex + 2, _buffer.Count); + } + + /// + /// Emit the one-time "assert unwind trampoline" used when an assert + /// fails inside a test. The trampoline drains the current scope's + /// defers (LIFO), then walks up the scope stack, draining each scope's + /// defers in turn, until only the global scope is left. Then it halts. + /// + /// All assert failure sites push the trampoline's address onto the + /// stack and ASSERT_FAIL (in test mode) sets instructionIndex to it. + /// In non-test mode ASSERT_FAIL discards the address and crashes the + /// VM via TriggerRuntimeError instead, so the trampoline never runs. + /// + private void CompileAssertUnwindTrampoline() + { + // Skip emission entirely if no assert sites exist. + if (_assertTrampolinePatches.Count == 0) return; + + var trampolineStart = _buffer.Count; + + // ── Drain loop for the current scope's defers ──────────────── + // Mirrors HandleDeferExit, but the return address pushed onto the + // data stack is `trampolineStart` itself, so a deferred body + // returns here and we pop the next defer. + AddPushInt(_buffer, trampolineStart); + _buffer.Add(OpCodes.POP_DEFER); // pushes addr or 0 + _buffer.Add(OpCodes.DUPE); + var drainEndPatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP_ZERO); // if addr==0, jump out of drain + _buffer.Add(OpCodes.JUMP); // else jump to defer body + + // ── after_drain: defer stack for current scope is empty ────── + var afterDrainAddr = _buffer.Count; + // Stack here is [trampolineStart, 0] from the JUMP_ZERO path. + _buffer.Add(OpCodes.DISCARD_TYPED); + _buffer.Add(OpCodes.DISCARD_TYPED); + + // Decide whether to pop another scope. Halt when only global is left. + _buffer.Add(OpCodes.PUSH_SCOPE_DEPTH); // depth + AddPushInt(_buffer, 1); // 1 + _buffer.Add(OpCodes.GT); // pushes (depth > 1) ? 1 : 0 + + var popAndLoopPatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); // address placeholder + _buffer.Add(OpCodes.JUMP_GT_ZERO); // if depth > 1, loop back via pop_and_loop + + // ── halt: only global scope remains; halt VM via overshoot ─── + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP); + + // ── pop_and_loop: pop one scope, jump back to trampolineStart ─ + var popAndLoopAddr = _buffer.Count; + _buffer.Add(OpCodes.POP_SCOPE); + AddPushInt(_buffer, trampolineStart); + _buffer.Add(OpCodes.JUMP); + + // Back-patch the two forward references inside the trampoline. + PatchAddress(drainEndPatchIndex, afterDrainAddr); + PatchAddress(popAndLoopPatchIndex, popAndLoopAddr); + + // Back-patch every assert site's trampoline-address placeholder. + foreach (var siteIndex in _assertTrampolinePatches) + { + PatchAddress(siteIndex, trampolineStart); + } + } + + // Helper: write a 4-byte int into the body of an `AddPushInt(buffer, _)` + // placeholder (which lays out [opcode, typecode, b0, b1, b2, b3]). + private void PatchAddress(int placeholderIndex, int value) + { + PatchInt32(_buffer, placeholderIndex + 2, value); + } + + // Emit CALL_COUNT with an inline 4-byte command id, pushing that + // command's invocation count onto the data stack. + private void EmitCallCountInline(int commandId) + { + _buffer.Add(OpCodes.CALL_COUNT); + AppendInt32(_buffer, commandId); + } + + private void Compile(ReturnStatement _) + { + _buffer.Add(OpCodes.RETURN); + } + + private List ResolveMockCommandIds(string commandName) + { + // A mock targets every overload sharing the given name. Iterate the + // method table (which includes every overload) and gather the ids + // of those whose name matches case-insensitively. + var ids = new List(); + var methods = methodTable.methods; + for (var i = 0; i < methods.Length; i++) + { + if (string.Equals(methods[i].name, commandName, + StringComparison.OrdinalIgnoreCase)) + { + ids.Add(i); + } + } + return ids; + } + + private void Compile(MockStatement mockStatement) + { + var allCommandIds = ResolveMockCommandIds(mockStatement.commandName); + if (allCommandIds.Count == 0) + { + // Unknown command — the lexer would normally have caught this + // (CommandWord token doesn't form). Skip silently here. + return; + } + + // Filter to overloads whose non-VmArg arg count matches the + // user-named param count. When the user gives zero names, the + // mock applies to every overload (the body's prelude pops every + // arg via DISCARD_TYPED, so any arg count is handled). + // + // Filtering is necessary because a single mock body's prelude + // is tied to one specific overload's signature — different + // overloads with different arg counts need different prelude + // bytecode, which is what the per-overload loop below emits. + var matchingIds = new List(); + foreach (var id in allCommandIds) + { + if (mockStatement.parameters.Count == 0) + { + matchingIds.Add(id); + continue; + } + var methodArgs = methodTable.methods[id].args ?? System.Array.Empty(); + var realCount = 0; + for (var ai = 0; ai < methodArgs.Length; ai++) + { + if (!methodArgs[ai].isVmArg) realCount++; + } + if (realCount == mockStatement.parameters.Count) + { + matchingIds.Add(id); + } + } + if (matchingIds.Count == 0) + { + // The visitor surfaces this as a validation error; the + // compiler just bails on emitting any bytecode. + return; + } + + // Per-overload: emit a separate body block tailored to that + // overload's signature, then install it for that overload's + // method id. Bodies share source statements but get independent + // register allocations because each body pushes its own + // CompilePushScope before binding args. + foreach (var commandId in matchingIds) + { + CompileMockBodyForOverload(mockStatement, commandId); + } + } + + // Emit one mock-body block + install op for a single overload. + private void CompileMockBodyForOverload(MockStatement mockStatement, int commandId) + { + var argMethod = methodTable.methods[commandId]; + + // Skip-over JUMP so normal execution flows past this body. + var skipBodyPatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP); + + var bodyStart = _buffer.Count; + // Register in DebugData so call-stack frames inside this body + // get a sensible function name (the mocked command's name). + if (mockStatement.commandNameToken != null) + { + _dbg?.AddFunction(bodyStart, mockStatement.commandNameToken); + } + CompilePushScope(); + + // Build the list of real (non-VmArg) arg indices for this overload. + var commandArgs = argMethod.args ?? System.Array.Empty(); + var realArgIndices = new List(); + for (var ai = 0; ai < commandArgs.Length; ai++) + { + if (!commandArgs[ai].isVmArg) realArgIndices.Add(ai); + } + + // Per-ref-param bookkeeping used to emit writebacks at every + // body exit. Independent per overload. + var prevRefMap = _activeMockRefBindings; + _activeMockRefBindings = new List(); + + // Args were pushed LIFO; pop in reverse to bind to user-named + // positions left-to-right. If the user gave no param names, + // DISCARD_TYPED keeps the stack clean. + for (var i = realArgIndices.Count - 1; i >= 0; i--) + { + var argInfo = commandArgs[realArgIndices[i]]; + var paramIndex = i; + if (paramIndex < mockStatement.parameters.Count) + { + var paramRef = mockStatement.parameters[paramIndex]; + if (argInfo.isParams) + { + // `params object[]` (ANY) named in the mock body + // is rejected by the visitor (MockParamsObjectArrayUnnamable) + // because the gathered array would need mixed-type + // element storage. Skip the binding here so we + // don't crash on SIZE_TABLE[ANY]; the visitor's + // ParseError is what the user sees. + if (argInfo.typeCode == TypeCodes.ANY) + { + // Drain count + values off the stack so later + // bindings line up. + _buffer.Add(OpCodes.DISCARD_TYPED); // count + // We don't know how many were pushed at compile + // time, so we can't drain values without a + // loop. Bail — the program won't run correctly, + // but the user has the validation error to fix. + continue; + } + // Params arg. The caller pushed `[values..., count]` + // on the stack. Materialize a Fade single-dimensional + // array from those, bind it to a body-local that the + // user can index and pass to `len`. Shape mirrors + // `dim xs(N)` so existing array-access machinery + // works without further changes. + var paramsArrayVar = scope.CreateArray( + paramRef.variableName, rankLength: 1, + typeCode: argInfo.typeCode, isGlobal: false); + // CreateArray just sizes the rank-arrays; we still + // need to allocate distinct register slots for the + // rank size and scaler — mirrors what the regular + // `dim` codegen does (Compile(DeclarationStatement)). + paramsArrayVar.rankSizeRegisterAddresses[0] = scope.AllocateRegister(); + paramsArrayVar.rankIndexScalerRegisterAddresses[0] = scope.AllocateRegister(); + + // 1) DUPE the count so we can stash it in the + // rank-size register before GATHER consumes it. + _buffer.Add(OpCodes.DUPE); + PushStore(_buffer, paramsArrayVar.rankSizeRegisterAddresses[0], isGlobal: false); + + // 2) Rank-0 scaler is 1 for a 1-D array. + AddPushInt(_buffer, 1); + PushStore(_buffer, paramsArrayVar.rankIndexScalerRegisterAddresses[0], isGlobal: false); + + // 3) GATHER pops count + values and leaves a fresh + // PTR_HEAP on top. Store it into the array's + // main register. + _buffer.Add(OpCodes.GATHER_ARRAY); + _buffer.Add(argInfo.typeCode); + PushStorePtr(_buffer, paramsArrayVar.registerAddress, isGlobal: false); + continue; + } + if (argInfo.isRef) + { + // Ref param. Hidden ptr reg + user-visible value reg. + var hiddenPtrName = "$$mockptr_" + paramRef.variableName; + var hiddenRef = new VariableRefNode(paramRef.startToken, hiddenPtrName); + var hiddenDecl = new DeclarationStatement + { + variableNode = hiddenRef, + scopeType = DeclarationScopeType.Local, + type = new TypeReferenceNode(VariableType.Integer, paramRef.startToken) + }; + Compile(hiddenDecl); + scope.TryGetVariable(hiddenPtrName, out var hiddenPtrVar); + + _buffer.Add(OpCodes.STORE_REF); + AddPushULongNoTypeCode(_buffer, hiddenPtrVar.registerAddress); + + VmUtil.TryGetVariableType(argInfo.typeCode, out var valueType); + var valueDecl = new DeclarationStatement + { + variableNode = paramRef, + scopeType = DeclarationScopeType.Local, + type = new TypeReferenceNode(valueType, paramRef.startToken) + }; + Compile(valueDecl); + scope.TryGetVariable(paramRef.variableName, out var valueVar); + + _buffer.Add(OpCodes.LOAD_REF); + AddPushULongNoTypeCode(_buffer, hiddenPtrVar.registerAddress); + _buffer.Add(OpCodes.CAST); + _buffer.Add(argInfo.typeCode); + CompileAssignmentLeftHandSide(paramRef); + + _activeMockRefBindings.Add(new MockRefBinding + { + paramName = paramRef.variableName, + valueRegAddr = valueVar.registerAddress, + ptrRegAddr = hiddenPtrVar.registerAddress, + argTypeCode = argInfo.typeCode + }); + } + else + { + VmUtil.TryGetVariableType(argInfo.typeCode, out var paramType); + var fakeDecl = new DeclarationStatement + { + variableNode = paramRef, + scopeType = DeclarationScopeType.Local, + type = new TypeReferenceNode(paramType, paramRef.startToken) + }; + Compile(fakeDecl); + _buffer.Add(OpCodes.CAST); + _buffer.Add(argInfo.typeCode); + CompileAssignmentLeftHandSide(paramRef); + } + } + else + { + _buffer.Add(OpCodes.DISCARD_TYPED); + } + } + + // Active-mock context for nested compile of body statements. + var prevReturnTc = _activeMockReturnTypeCode; + var prevCmdName = _activeMockCommandName; + var prevHostId = _activeMockHostMethodId; + var prevParamBindings = _activeMockParamBindings; + var prevBypassIds = _activeMockBypassIds; + _activeMockReturnTypeCode = argMethod.returnType; + _activeMockCommandName = argMethod.name; + _activeMockHostMethodId = commandId; + // All overloads of the mocked command name route to real + // inside this body — gather their ids once. Compile of + // CommandStatement/CommandExpression checks this set. + _activeMockBypassIds = new HashSet( + ResolveMockCommandIds(mockStatement.commandName)); + + // Build the ordered param-binding table used by + // PassthroughExpression: one entry per real (non-VmArg) arg, + // in declaration order, paired with the mock's body-local + // name (null when the mock didn't name that position). + _activeMockParamBindings = new List(); + for (var ri = 0; ri < realArgIndices.Count; ri++) + { + var argInfo = commandArgs[realArgIndices[ri]]; + var paramName = (ri < mockStatement.parameters.Count) + ? mockStatement.parameters[ri].variableName + : null; + _activeMockParamBindings.Add(new MockParamBinding + { + paramName = paramName, + argTypeCode = argInfo.typeCode, + isRef = argInfo.isRef, + isParams = argInfo.isParams + }); } - var exitStatementIndexes = _exitInstructionIndexes.Pop(); - var skipStatementIndexes = _skipInstructionIndexes.Pop(); - - // at the end of the successful statements, we need to jump back to the start - AddPushInt(_buffer, whileLoopValue); - _buffer.Add(OpCodes.JUMP); - var endJumpValue = _buffer.Count; - _buffer.Add(OpCodes.NOOP); - - // now go back and fill in the success ptr - var successJumpBytes = BitConverter.GetBytes(successJumpValue); - var endJumpBytes = BitConverter.GetBytes(endJumpValue); - var whileLoopBytes = BitConverter.GetBytes(whileLoopValue); - - for (var i = 0; i < successJumpBytes.Length; i++) + foreach (var stmt in mockStatement.body) { - // offset by 2, because of the opcode, and the type code - _buffer[successJumpIndex + 2 + i] = successJumpBytes[i]; - _buffer[exitJumpIndex + 2 + i] = endJumpBytes[i]; - - foreach (var index in exitStatementIndexes) + Compile(stmt); + } + + // endmock fall-through return value. + if (mockStatement.endmockExpression != null) + { + Compile(mockStatement.endmockExpression); + if (_activeMockReturnTypeCode != 0 && _activeMockReturnTypeCode != TypeCodes.VOID) { - _buffer[index + 2 + i] = endJumpBytes[i]; + _buffer.Add(OpCodes.CAST); + _buffer.Add(_activeMockReturnTypeCode); } - - // Update skip instructions to jump back to the beginning of the loop - foreach (var index in skipStatementIndexes) + } + + // Ref-arg writebacks before scope-pop (still need the binding map). + EmitMockRefWritebacks(); + + _activeMockReturnTypeCode = prevReturnTc; + _activeMockCommandName = prevCmdName; + _activeMockRefBindings = prevRefMap; + _activeMockHostMethodId = prevHostId; + _activeMockParamBindings = prevParamBindings; + _activeMockBypassIds = prevBypassIds; + + CompilePopScope(); + _buffer.Add(OpCodes.RETURN); + + // Patch the skip-over JUMP to land past this body. + PatchAddress(skipBodyPatchIndex, _buffer.Count); + + // Install the body for this specific overload's id. + AddPushInt(_buffer, bodyStart); + AddPushInt(_buffer, commandId); + _buffer.Add(OpCodes.MOCK_INSTALL); + } + + // The active mock's return-type code, set while compiling a mock + // body. MockExitMockStatement reads this to cast the user's return + // expression to the right shape before pushing it on the stack and + // returning. Outside a mock-body compile, this is 0 (VOID). + private byte _activeMockReturnTypeCode; + + private void Compile(MockExitMockStatement returnsStatement) + { + // `exitmock expr` inside a mock body: push the value, cast it to + // the command's declared return type, emit ref-arg writebacks, + // pop the body's scope and RETURN. The writebacks read each + // ref param's value-register (last write the user did) and + // store it back to the caller's variable via the saved ptr. + if (returnsStatement.expression != null) + { + Compile(returnsStatement.expression); + if (_activeMockReturnTypeCode != 0 && _activeMockReturnTypeCode != TypeCodes.VOID) { - _buffer[index + 2 + i] = whileLoopBytes[i]; + _buffer.Add(OpCodes.CAST); + _buffer.Add(_activeMockReturnTypeCode); } } + EmitMockRefWritebacks(); + CompilePopScope(); + _buffer.Add(OpCodes.RETURN); } - - private void Compile(IfStatement ifStatement) + + private void Compile(MockForbidStatement forbidStatement) { - /* - * - * PUSH addr of Success: - * JUMP_GT_ZERO - * PUSH addr of Else - * JUMP - * Success: - * positive-if-statements - * JUMP Final: - * Else: - * else-if-statements - * Final: - */ - - // first, compile the evaluation of the condition - Compile(ifStatement.condition); - - // cast the expression to an int - _buffer.Add(OpCodes.CAST); - _buffer.Add(TypeCodes.INT); - - // then, put a fake value in for the if-statement success jump... We'll fix it later. - var successJumpIndex = _buffer.Count; - AddPushInt(_buffer, int.MaxValue); - - // then, do the jump-gt-zero - _buffer.Add(OpCodes.JUMP_GT_ZERO); - - // if we didn't jump, then we need to load up the ELSE block - var elseJumpIndex = _buffer.Count; + // `forbid [reason]` inside a mock body: shape-compatible with + // ASSERT_FAIL. The body is already running inside a pushed scope + // (set up by the mock body prelude); the trampoline drains + // defers across every live scope and halts the test. + // + // Stack at ASSERT_FAIL (bottom→top): + // reason, sourceText, trampolineAddr. + if (forbidStatement.reason != null) + { + Compile(forbidStatement.reason); + } + else + { + Compile(new LiteralStringExpression(forbidStatement.startToken, "")); + } + // Synthesize a sourceText that names the command being forbidden. + // Walk up to the enclosing MockStatement for the name; if we + // somehow have none, fall back to a generic message. + var cmdName = _activeMockCommandName ?? ""; + Compile(new LiteralStringExpression(forbidStatement.startToken, + "forbidden command was called: " + cmdName)); + var trampolinePatchIndex = _buffer.Count; AddPushInt(_buffer, int.MaxValue); + _assertTrampolinePatches.Add(trampolinePatchIndex); + _buffer.Add(OpCodes.ASSERT_FAIL); + } - // and then jump to the else block - _buffer.Add(OpCodes.JUMP); - - // now it is time to start compiling the actual statements... - - // keep track of the first index of the success - var successJumpValue = _buffer.Count; - - foreach (var successStatement in ifStatement.positiveStatements) + // Command name of the mock currently being compiled. Read by + // MockForbidStatement so the failure message names the command. + private string _activeMockCommandName; + + // Per-ref-param bookkeeping for the active mock-body compile. + // Populated by the body prelude with one entry per ref parameter, + // then read at every exit site (exitmock, endmock fall-through) to + // emit the writeback sequence. Empty when the active mock has no + // ref params (or when we're not inside a mock body). + private List _activeMockRefBindings; + + // Emit ref writebacks for every ref param in the current mock. + // Called from each body exit point: pushes nothing net onto the + // stack (each writeback loads the value reg and consumes it via + // WRITE_REF). Order is irrelevant — each binding writes to a + // distinct caller register. + private void EmitMockRefWritebacks() + { + if (_activeMockRefBindings == null) return; + foreach (var binding in _activeMockRefBindings) { - Compile(successStatement); + // Load the value-register's current value. + PushLoad(_buffer, binding.valueRegAddr, isGlobal: false); + // CAST to the ref's underlying type — defensive in case the + // user did any unusual arithmetic that widened the type. + _buffer.Add(OpCodes.CAST); + _buffer.Add(binding.argTypeCode); + // Write through the saved pointer to the caller's register. + _buffer.Add(OpCodes.WRITE_REF); + AddPushULongNoTypeCode(_buffer, binding.ptrRegAddr); } + } - _dbg?.AddFakeDebugToken(_buffer.Count - 1, ifStatement.endToken); - - // at the end of the successful statements, we need to jump to the end - var endJumpIndex = _buffer.Count; - AddPushInt(_buffer, int.MaxValue); - _buffer.Add(OpCodes.JUMP); + struct MockRefBinding + { + public string paramName; + // Body-local register holding the value the user reads/writes. + // Typed as the arg's base type (int / float / etc). + public ulong valueRegAddr; + // Hidden body-local register holding the typed caller pointer + // (PTR_REG or PTR_GLOBAL_REG). Used by WRITE_REF at writeback. + public ulong ptrRegAddr; + // The command arg's underlying TypeCode (e.g. TypeCodes.INTEGER). + public byte argTypeCode; + } + + // Per-arg binding info for the currently-compiling mock body — one + // entry per real (non-VmArg) command arg, in declaration order. + // PassthroughExpression iterates these to re-construct the call. + struct MockParamBinding + { + public string paramName; + public byte argTypeCode; + public bool isRef; + public bool isParams; + } + // Host-method id of the command currently being mocked. Read by + // PassthroughExpression to emit the CALL_HOST_REAL target. Zero + // outside a mock body. + private int _activeMockHostMethodId; + + // Ordered list of bindings for the active mock body. Index matches + // the order in which args are pushed at a normal call site (which + // is also the order CALL_HOST_REAL expects). + private List _activeMockParamBindings; + + // `passthrough` inside a mock body — re-pushes the body's currently + // bound argument values, then dispatches to the real underlying + // command via CALL_HOST_REAL (which bypasses the mock table). + // Leaves the real command's return value (if any) on the stack; + // when used as a statement, the wrapping ExpressionStatement + // emits a DISCARD_TYPED. + // + // Args are re-built from body-locals in declaration order: + // - value: LOAD + CAST + // - ref: flush user-side write through hidden ptr (LOAD val, + // CAST, WRITE_REF ), then LOAD so the + // real host can read AND write through it. + // - params: LOAD_PTR + SPREAD_ARRAY . + // + // After the call we refresh each ref param's value-reg from the + // caller (which the real command may have updated) so subsequent + // body reads see the real output. + // Set of host-method ids that, when invoked inside the current + // mock body, should dispatch to the real host (CALL_HOST_REAL) + // instead of looking up the mock table. Populated per body with + // every overload id of the mocked command name. Null outside a + // mock body. Read at the top of Compile(CommandStatement) and + // Compile(CommandExpression) for the self-recursive rewrite. + private HashSet _activeMockBypassIds; + + // Compile a CommandStatement or CommandExpression whose target + // is the mocked command. Emits the same shape as a normal call + // (push each arg, then PUSH cmd-id, then CALL_HOST_REAL), but + // ref args route through the body's bound ref-param table so + // writes land in the caller's scope. After the call, refresh + // each refreshed ref's value-reg from the caller so later body + // reads see the real-output. Caller is responsible for emitting + // the final DISCARD when used as a statement (the regular + // CommandStatement compile already handles void-return discard). + private void CompileMockedCommandSelfCall(CommandInfo command, + List args, int commandId, bool isStatement) + { + var refsRefreshed = new HashSet(StringComparer.OrdinalIgnoreCase); - // this is where the else statements begin - var elseJumpValue = _buffer.Count; - // _buffer.Add(OpCodes.NOOP); - foreach (var elseStatement in ifStatement.negativeStatements) + var argCounter = 0; + for (var i = 0; i < command.args.Length; i++) { - Compile(elseStatement); + var argDesc = command.args[i]; + if (argDesc.isVmArg) continue; + + if (argDesc.isParams) + { + // Spread shape OR inline. Mirror the regular + // CommandStatement params logic: if the only + // remaining user arg is an array-typed expression, + // compile it and SPREAD_ARRAY. Otherwise compile each + // inline arg in reverse and push the count. + var remaining = args.Count - argCounter; + if (remaining == 1 + && args[argCounter].ParsedType.IsArray + && args[argCounter].ParsedType.rank == 1) + { + Compile(args[argCounter]); + _buffer.Add(OpCodes.SPREAD_ARRAY); + _buffer.Add(argDesc.typeCode); + } + else + { + for (var j = args.Count - 1; j >= argCounter; j--) + { + Compile(args[j]); + } + AddPushInt(_buffer, args.Count - argCounter); + } + break; + } + + if (argCounter >= args.Count) + { + if (argDesc.isOptional) + { + AddPush(_buffer, new byte[] { }, TypeCodes.VOID); + continue; + } + throw new Exception( + "Compiler: self-recursive mock call missing required arg"); + } + + var userExpr = args[argCounter]; + + if (argDesc.isRef) + { + // Visitor already required this to be a bound ref-param + // name. Route through the binding so the host writes + // into the caller's scope (where the original ref lives). + string refName = (userExpr is VariableRefNode vn) ? vn.variableName : null; + if (refName == null) + { + throw new Exception( + "Compiler: self-recursive mock ref arg must be a variable ref by validation"); + } + EmitMockedCallRefByBoundName(argDesc.typeCode, refName, refsRefreshed); + argCounter++; + continue; + } + + // Value arg — compile the user's expression normally, cast. + Compile(userExpr); + if (argDesc.typeCode != TypeCodes.ANY) + { + CompileCast(argDesc.typeCode); + } + argCounter++; } - // _dbg?.AddFakeDebugToken(_buffer.Count - 1, ifStatement.endToken); + // Push the host method id and dispatch to the real command. + _buffer.Add(OpCodes.PUSH); + _buffer.Add(TypeCodes.INT); + AppendInt32(_buffer, commandId); + _buffer.Add(OpCodes.CALL_HOST_REAL); - var endJumpValue = _buffer.Count; - // _buffer.Add(OpCodes.NOOP); - - // now go back and fill in the success ptr - var successJumpBytes = BitConverter.GetBytes(successJumpValue); - var elseJumpBytes = BitConverter.GetBytes(elseJumpValue); - var endJumpBytes = BitConverter.GetBytes(endJumpValue); - for (var i = 0; i < successJumpBytes.Length; i++) + // Refresh ref bindings that this call wrote through, so later + // body reads observe the real output. Untouched bindings stay + // as the user left them (preserves any pre-call user write). + if (_activeMockRefBindings != null) { - // offset by 2, because of the opcode, and the type code - //(successJumpBytes.Length -1) - i - _buffer[successJumpIndex + 2 + i] = successJumpBytes[i]; - _buffer[elseJumpIndex + 2 + i] = elseJumpBytes[i]; - _buffer[endJumpIndex + 2 + i] = endJumpBytes[i]; + foreach (var rb in _activeMockRefBindings) + { + if (!refsRefreshed.Contains(rb.paramName)) continue; + _buffer.Add(OpCodes.LOAD_REF); + AddPushULongNoTypeCode(_buffer, rb.ptrRegAddr); + _buffer.Add(OpCodes.CAST); + _buffer.Add(rb.argTypeCode); + var refNode = new VariableRefNode(null, rb.paramName); + CompileAssignmentLeftHandSide(refNode); + } } + + // For void real-commands invoked at statement position there + // is nothing on the stack to discard. For value-returning + // ones at statement position, the regular CommandStatement + // caller doesn't emit a discard either — the value is left + // on the stack. Match that behavior here (the caller stack + // hygiene is the same as a normal CALL_HOST). } - - private void Compile(LabelDeclarationNode labelStatement) + + // Flush the body-visible value-reg through the hidden ptr (so + // the real host reads the user's latest write), then push the + // ptr itself as PTR_REG / PTR_GLOBAL_REG. Records the binding + // name in `refsRefreshed` so we know which value-regs to reload + // from the caller after the call. + private void EmitMockedCallRefByBoundName(byte argTypeCode, + string boundName, HashSet refsRefreshed) { - // take note of instruction number... - _labelToInstructionIndex[labelStatement.label] = _buffer.Count; - _buffer.Add(OpCodes.NOOP); + MockRefBinding refBinding = default; + var found = false; + if (_activeMockRefBindings != null) + { + foreach (var rb in _activeMockRefBindings) + { + if (string.Equals(rb.paramName, boundName, + StringComparison.OrdinalIgnoreCase)) + { + refBinding = rb; + found = true; + break; + } + } + } + if (!found) + { + throw new Exception( + "Compiler: self-recursive mock call missing ref binding for " + boundName); + } + PushLoad(_buffer, refBinding.valueRegAddr, isGlobal: false); + _buffer.Add(OpCodes.CAST); + _buffer.Add(argTypeCode); + _buffer.Add(OpCodes.WRITE_REF); + AddPushULongNoTypeCode(_buffer, refBinding.ptrRegAddr); + PushLoad(_buffer, refBinding.ptrRegAddr, isGlobal: false); + refsRefreshed.Add(refBinding.paramName); } - private void Compile(ReturnStatement _) + private void Compile(ClearMockStatement clearMockStatement) { - _buffer.Add(OpCodes.RETURN); + if (clearMockStatement.commandName == null) + { + _buffer.Add(OpCodes.MOCK_CLEAR_ALL); + return; + } + + var commandIds = ResolveMockCommandIds(clearMockStatement.commandName); + foreach (var commandId in commandIds) + { + AddPushInt(_buffer, commandId); + _buffer.Add(OpCodes.MOCK_CLEAR); + } } private void Compile(EndProgramStatement endProgramStatement) @@ -1611,20 +2589,20 @@ private void Compile(GoSubStatement goSubStatement) _labelReplacements.Add(new LabelReplacement { InstructionIndex = _buffer.Count, - Label = goSubStatement.label + Label = MakeLabelKey(_currentLabelRegion, goSubStatement.label) }); AddPushInt(_buffer, int.MaxValue); _buffer.Add(OpCodes.JUMP_HISTORY); - + } - + private void Compile(GotoStatement gotoStatement) { // identify the instruction ID of the label _labelReplacements.Add(new LabelReplacement { InstructionIndex = _buffer.Count, - Label = gotoStatement.label + Label = MakeLabelKey(_currentLabelRegion, gotoStatement.label) }); AddPushInt(_buffer, int.MaxValue); _buffer.Add(OpCodes.JUMP); @@ -1768,7 +2746,23 @@ private void Compile(AddressExpression expression) } public void Compile(CommandStatement commandStatement) - { + { + // Inside a mock body, a call to the mocked command itself + // (any overload) dispatches to the real host via + // CALL_HOST_REAL — the mock body is transparent to its own + // command. Ref args route through the body's bound ref-param + // table (validation enforced this) so writes land in the + // caller's scope through the scope-swap in CALL_HOST_REAL. + if (_activeMockBypassIds != null + && _commandToPtr.TryGetValue( + commandStatement.command.UniqueName, out var bypassIdStmt) + && _activeMockBypassIds.Contains(bypassIdStmt)) + { + CompileMockedCommandSelfCall(commandStatement.command, + commandStatement.args, bypassIdStmt, isStatement: true); + return; + } + // TODO: save local state? // put each expression on the stack. var argCounter = 0; @@ -1778,14 +2772,39 @@ public void Compile(CommandStatement commandStatement) if (commandStatement.command.args[i].isParams) { - - // and then, compile the rest of the args + // Spread shape: exactly one remaining arg, and it's an + // array-typed expression matching the params element + // type. Compile the array (which puts its heap ptr on + // the stack), then SPREAD_ARRAY pushes each element + + // count — the same shape as the inline loop below. + var remaining = commandStatement.args.Count - argCounter; + if (remaining == 1 + && commandStatement.args[argCounter].ParsedType.IsArray + && commandStatement.args[argCounter].ParsedType.rank == 1) + { + Compile(commandStatement.args[argCounter]); + _buffer.Add(OpCodes.SPREAD_ARRAY); + // Use the array's actual element type, not the + // descriptor's — for `params object[]` (TypeCodes.ANY) + // the descriptor doesn't carry a usable byte size, + // but the source array always has a concrete element + // type the VM can size and tag per-element. + var descTc = commandStatement.command.args[i].typeCode; + var spreadTc = descTc == TypeCodes.ANY + ? VmUtil.GetTypeCode(commandStatement.args[argCounter].ParsedType.type) + : descTc; + _buffer.Add(spreadTc); + break; + } + + // Inline-list shape (existing): compile each arg in + // reverse, then push the count. for (var j = commandStatement.args.Count - 1; j >= argCounter; j --) { var argExpr2 = commandStatement.args[j]; Compile(argExpr2); } - + // first, we need to tell the program how many arguments there are left in the set // , which of course, is args - i. AddPushInt(_buffer, commandStatement.args.Count - argCounter); @@ -1852,12 +2871,7 @@ public void Compile(CommandStatement commandStatement) _buffer.Add(OpCodes.PUSH); _buffer.Add(TypeCodes.INT); - var bytes = BitConverter.GetBytes(commandAddress); - for (var i = 0 ; i < bytes.Length; i ++) - // for (var i = bytes.Length -1; i >= 0; i--) - { - _buffer.Add(bytes[i]); - } + AppendInt32(_buffer, commandAddress); _buffer.Add(OpCodes.CALL_HOST); @@ -2480,13 +3494,18 @@ public void Compile(AssignmentStatement assignmentStatement) * If it is an array, then it lives in memory. */ + // Note: assignment to a ref param inside a mock body is NOT + // special-cased here — the body's value register is a regular + // local. The writeback to the caller's variable happens at + // every body exit (exitmock + endmock fall-through) via the + // ref-binding list. if (assignmentStatement.variable is VariableRefNode leftRef && scope.TryGetVariable(leftRef.variableName, out var leftVar) && leftVar.typeCode == TypeCodes.STRUCT) { // _buffer.Add(OpCodes.BREAKPOINT); } - + // compile the rhs of the assignment... Compile(assignmentStatement.expression); CompileAssignmentLeftHandSide(assignmentStatement.variable); @@ -2599,6 +3618,60 @@ public void Compile(IExpressionNode expr) argMap = commandExpr.argMap }); break; + case LenExpression lenExpr: + { + // `len()` — push the inner expression (an array + // or string heap pointer), then LENGTH with the + // element-size byte to divide the allocation size and + // push the count. + if (lenExpr.inner == null) { AddPushInt(_buffer, 0); break; } + Compile(lenExpr.inner); + var innerType = lenExpr.inner.ParsedType; + byte elemSize; + if (innerType.type == VariableType.String) + { + // Fade chars are uint codepoints — 4 bytes each. + elemSize = TypeCodes.GetByteSize(TypeCodes.INT); + } + else + { + // Array: take the inner element type's byte size. + // ParsedType.type for an array variable is its + // element type (Integer for `dim x(...)`, etc). + var elemTc = VmUtil.GetTypeCode(innerType.type); + elemSize = TypeCodes.GetByteSize(elemTc); + } + _buffer.Add(OpCodes.LENGTH); + _buffer.Add(elemSize); + break; + } + case CallCountExpression callCountExpr: + { + // Resolve the command name to all overload ids; the count + // is per-id, but `call count ` means "across all + // overloads of ." Sum the per-id counts at runtime + // by emitting CALL_COUNT for each id and adding the + // results. For the common single-overload case this is + // just one CALL_COUNT instruction. If the name doesn't + // resolve to any command, push 0. + var ids = callCountExpr.commandName != null + ? ResolveMockCommandIds(callCountExpr.commandName) + : new List(); + if (ids.Count == 0) + { + AddPushInt(_buffer, 0); + } + else + { + EmitCallCountInline(ids[0]); + for (var i = 1; i < ids.Count; i++) + { + EmitCallCountInline(ids[i]); + _buffer.Add(OpCodes.ADD); + } + } + break; + } case LiteralStringExpression literalString: // allocate some memory for a string... var str = literalString.value; @@ -2653,12 +3726,7 @@ public void Compile(IExpressionNode expr) case LiteralRealExpression literalReal: _buffer.Add(OpCodes.PUSH); _buffer.Add(TypeCodes.REAL); - var realValue = BitConverter.GetBytes(literalReal.value); - // for (var i = realValue.Length - 1; i >= 0; i--) - for (var i = 0 ; i < realValue.Length; i ++) - { - _buffer.Add(realValue[i]); - } + AppendFloat(_buffer, literalReal.value); break; case LiteralIntExpression literalInt: // push the literal value @@ -3003,12 +4071,7 @@ public void Compile(IExpressionNode expr) { // ignore the type code for the jump... _buffer.Add(OpCodes.NOOP); - var successJumpBytes = BitConverter.GetBytes(endJumpValue); - for (var i = 0; i < successJumpBytes.Length; i++) - { - // offset by 2, because of the opcode, and the type code - _buffer[jumpIndex + 2 + i] = successJumpBytes[i]; - } + PatchInt32(_buffer, jumpIndex + 2, endJumpValue); } break; @@ -3072,53 +4135,67 @@ private static void AddPushInt(List buffer, int x) { buffer.Add(OpCodes.PUSH); buffer.Add(TypeCodes.INT); - var value = BitConverter.GetBytes(x); - for (var i = 0; i < value.Length; i++) - // for (var i = value.Length - 1; i >= 0; i--) - { - buffer.Add(value[i]); - } + AppendInt32(buffer, x); } - private static void AddPushZeros(List buffer, byte typeCode, int howManyBytesOfZero) { buffer.Add(OpCodes.PUSH_ZEROS); - buffer.Add(typeCode); - var value = BitConverter.GetBytes(howManyBytesOfZero); - for (var i = 0; i < value.Length; i++) - { - buffer.Add(value[i]); - } + buffer.Add(typeCode); + AppendInt32(buffer, howManyBytesOfZero); } - + private static void AddPushULongNoTypeCode(List buffer, ulong x) { - var value = BitConverter.GetBytes(x); - for (var i = 0 ; i < value.Length; i ++) - { - buffer.Add(value[i]); - } + AppendUInt64(buffer, x); } - + private static void AddPushUInt(List buffer, uint x, bool includeTypeCode=true) { - if (includeTypeCode) - { - buffer.Add(OpCodes.PUSH); - } - else - { - buffer.Add(OpCodes.PUSH_TYPELESS); - } + buffer.Add(includeTypeCode ? OpCodes.PUSH : OpCodes.PUSH_TYPELESS); buffer.Add(TypeCodes.INT); + AppendInt32(buffer, (int)x); + } - var value = BitConverter.GetBytes(x); - // for (var i = value.Length - 1; i >= 0; i--) - for (var i = 0 ; i < value.Length; i ++) - { - buffer.Add(value[i]); - } + private static void AppendInt32(List buffer, int value) + { + buffer.Add((byte)(value)); + buffer.Add((byte)(value >> 8)); + buffer.Add((byte)(value >> 16)); + buffer.Add((byte)(value >> 24)); + } + + private static void AppendUInt64(List buffer, ulong value) + { + buffer.Add((byte)(value)); + buffer.Add((byte)(value >> 8)); + buffer.Add((byte)(value >> 16)); + buffer.Add((byte)(value >> 24)); + buffer.Add((byte)(value >> 32)); + buffer.Add((byte)(value >> 40)); + buffer.Add((byte)(value >> 48)); + buffer.Add((byte)(value >> 56)); + } + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)] + private struct FloatIntUnion + { + [System.Runtime.InteropServices.FieldOffset(0)] public float Float; + [System.Runtime.InteropServices.FieldOffset(0)] public int Int; + } + + private static void AppendFloat(List buffer, float value) + { + var u = new FloatIntUnion { Float = value }; + AppendInt32(buffer, u.Int); + } + + private static void PatchInt32(List buffer, int index, int value) + { + buffer[index] = (byte)(value); + buffer[index + 1] = (byte)(value >> 8); + buffer[index + 2] = (byte)(value >> 16); + buffer[index + 3] = (byte)(value >> 24); } } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Virtual/DebugData.cs b/FadeBasic/FadeBasic/Virtual/DebugData.cs index c39240e..b05de74 100644 --- a/FadeBasic/FadeBasic/Virtual/DebugData.cs +++ b/FadeBasic/FadeBasic/Virtual/DebugData.cs @@ -84,7 +84,7 @@ public void AddVariable(int insIndex, CompiledVariable compiledVar) }); } - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { // op.IncludeField(nameof(points), ref points); op.IncludeField(nameof(insToVariable), ref insToVariable); @@ -105,7 +105,7 @@ public class DebugToken : IJsonable /// public int isComputed; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(insIndex), ref insIndex); op.IncludeField(nameof(token), ref token); @@ -124,7 +124,7 @@ public class DebugTokenRange : IJsonable public DebugToken startToken; public DebugToken stopToken; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(startToken), ref startToken); op.IncludeField(nameof(stopToken), ref stopToken); @@ -139,7 +139,7 @@ public class DebugMap : IJsonable public List innerMaps; public int depth; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(depth), ref depth); op.IncludeField(nameof(range), ref range); @@ -159,7 +159,7 @@ public class DebugVariable : IJsonable // public byte registerAddress; // public bool isGlobal; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(insIndex), ref insIndex); op.IncludeField(nameof(name), ref name); diff --git a/FadeBasic/FadeBasic/Virtual/HostBridge.cs b/FadeBasic/FadeBasic/Virtual/HostBridge.cs new file mode 100644 index 0000000..21c1838 --- /dev/null +++ b/FadeBasic/FadeBasic/Virtual/HostBridge.cs @@ -0,0 +1,56 @@ +using System; + +namespace FadeBasic.Virtual +{ + // Generic primitives that runtime hosts (FadeBasic.Export.Web for the + // WASM bundle, a native CLI, the MonoGame runtime, etc.) install at + // startup. Library commands (any [FadeBasicCommand] in any assembly) + // call these primitives — they never know who the host is. + // + // The design goal here is plugin extensibility WITHOUT modifying core. + // A new library that needs cooperative behavior reuses the same two + // primitives plus an arbitrary channel name; the corresponding page- + // side handler is registered by the consumer in their own index.html. + // Core, the runtime, and worker.js all stay untouched. + // + // Hooks default to null. A library command that calls a null hook + // gracefully degrades (the VM doesn't suspend, the placeholder pushed + // by the executor stays on the stack as the command's "answer"). + public static class HostBridge + { + // Fire-and-forget signal from a library command to whatever runtime + // is hosting it. The host implementation typically forwards the + // payload across some boundary — for the WASM runtime it becomes a + // postMessage from the worker to the page. + // + // `channel` is an opaque string that identifies the operation. The + // page (or whatever endpoint the host forwards to) routes on this. + // Library authors should namespace channels with their library name + // to avoid collisions (e.g. "fade-web/prompt", "my-plugin/file-pick"). + // + // Payload is a string for transport simplicity. Structured payloads + // should be JSON-encoded by the library; the page decodes them. + // + // This call does NOT block, does NOT suspend the VM, and does NOT + // return a value. A library that needs a reply pairs this with + // SuspendVm and waits for the host to deposit a result later. + public static Action PostMessage; + + // Asks the host to pause the current VM. The host knows which VM + // is currently being pumped (it owns the scheduler). After this + // returns, the calling command should also return — the next tick + // will see the suspend flag and exit cleanly, yielding control + // back to the host's event loop. + // + // Pairing pattern for a "request → reply" command: + // 1. Push a placeholder return value (the source-generated + // executor does this automatically based on the command's + // C# return type — "" for string commands, 0 for int, etc). + // 2. Call PostMessage to ask the host to do something async. + // 3. Call SuspendVm. + // 4. Return the placeholder. + // The host receives the reply later, swaps the placeholder for the + // real value via a Deposit* JSExport, and resumes the pump. + public static Action SuspendVm; + } +} diff --git a/FadeBasic/FadeBasic/Virtual/HostStackOps.cs b/FadeBasic/FadeBasic/Virtual/HostStackOps.cs new file mode 100644 index 0000000..979cca9 --- /dev/null +++ b/FadeBasic/FadeBasic/Virtual/HostStackOps.cs @@ -0,0 +1,101 @@ +using System; + +namespace FadeBasic.Virtual +{ + // Stack mutation primitives used by runtime hosts when a library + // command returned a placeholder value via the cooperative pump + // (HostBridge.PostMessage + SuspendVm) and the host now has the + // real answer to deposit. + // + // These are shared between the web runtime (FadeBasic.Export.Web) + // and any future host. They live here, in core, because the swap + // logic is pure VM mechanics — no browser, JSExport, or scheduler + // state involved. Hosts call these once the wake-up event arrives; + // tests can call them directly without referencing a host project. + // + // Each Swap* assumes the operand stack's top entry IS the placeholder + // the matching command's executor pushed — i.e. the host has just + // come back from a HostBridge.SuspendVm-induced pause and the only + // mutation since the suspend has been popping into this helper. + public static class HostStackOps + { + // Swap the placeholder string pointer on top of the operand + // stack for a freshly-allocated heap string containing `value`. + // The placeholder allocation is refcount-decremented (it was + // a length-0 string allocated by the executor) and the new + // pointer + STRING type code take its place. + // + // Stack layout when entering: + // [...prior][8 ptr bytes][typeCode=STRING] + // Layout when returning: + // [...prior][8 new ptr bytes][typeCode=STRING] + // + // We pop manually (not via VmUtil.ReadAsVmPtr) because that + // helper rejects STRING type codes — its TODO comment notes + // the gap. String allocation matches VmHeap's interned-string + // layout: 4 bytes per char (UTF-32 in-memory). + public static bool SwapTopString(VirtualMachine vm, string value) + { + if (vm == null) return false; + value ??= ""; + + if (vm.stack.ptr < 9) + { + // Not enough bytes on top to be a string placeholder. + // Surface as a no-op rather than corrupting the stack. + return false; + } + + // Pop type byte (intentionally not validated — see helper- + // level comment about coercion vs. defensive checking), + // then 8 pointer bytes. PopArraySpan returns a span backed + // by the stack buffer; VmPtr.FromBytes copies it before + // anything else can clobber. + _ = vm.stack.Pop(); + vm.stack.PopArraySpan(8, out var oldPtrSpan); + var oldPtr = VmPtr.FromBytes(oldPtrSpan); + vm.heap.TryDecrementRefCount(oldPtr); + + var size = value.Length * 4; + var span = new byte[size]; + for (var i = 0; i < value.Length; i++) + { + var data = (uint)value[i]; + var b = BitConverter.GetBytes(data); + span[i * 4 + 0] = b[0]; + span[i * 4 + 1] = b[1]; + span[i * 4 + 2] = b[2]; + span[i * 4 + 3] = b[3]; + } + vm.heap.AllocateString(size, out var newPtr); + vm.heap.WriteSpan(newPtr, size, span); + var ptrBytes = VmPtr.GetBytes(ref newPtr); + VmUtil.PushSpan(ref vm.stack, ptrBytes, TypeCodes.STRING); + return true; + } + + // Swap the placeholder primitive (int / real / bool / byte / + // word / dword / dint / dfloat) on top of the operand stack + // for `newBytes` (must be exactly TypeCodes.GetByteSize(typeCode) + // bytes). No heap interaction — the placeholder is overwritten + // in place. The caller is responsible for passing the right + // number of bytes for the given type code. + public static bool SwapTopPrimitive(VirtualMachine vm, byte typeCode, byte[] newBytes) + { + if (vm == null) return false; + if (newBytes == null) return false; + var size = TypeCodes.GetByteSize(typeCode); + if (newBytes.Length != size) return false; + if (vm.stack.ptr < 1 + size) return false; + + // Pop placeholder: type byte + value bytes. We trust the + // type code on the stack matches what the command's executor + // pushed; if it doesn't the bug is in the calling host + // (passed the wrong type for a swap), not something to + // defensively recover from here. + vm.stack.ptr -= 1 + size; + VmUtil.PushSpan(ref vm.stack, newBytes, typeCode); + return true; + } + } +} diff --git a/FadeBasic/FadeBasic/Virtual/OpCodes.cs b/FadeBasic/FadeBasic/Virtual/OpCodes.cs index 1c5ebcc..a39e920 100644 --- a/FadeBasic/FadeBasic/Virtual/OpCodes.cs +++ b/FadeBasic/FadeBasic/Virtual/OpCodes.cs @@ -127,7 +127,7 @@ public bool IsArray(out byte arrayRanks) } public bool IsString() => typeCode == TypeCodes.STRING; public bool IsStruct() => typeCode == TypeCodes.STRUCT; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(typeCode), ref typeCode); op.IncludeField(nameof(typeId), ref typeId); @@ -232,10 +232,9 @@ public static class OpCodes /// public const byte DISCARD_TYPED = 55; - /// - /// Reads a ptr value from the stack, and that ptr is used as a heap address. The result on the stack is the length (in bytes) of the allocation on the heap - /// - public const byte LENGTH = 16; + // Slot 16 was an earlier (never-wired) LENGTH opcode. Reused for + // nothing today; the actual LENGTH lives further down at byte 79 + // with a defined contract (inline elem size, pushes count). /// /// Duplicates the current value on the stack, like a type-code qualified int or word @@ -383,8 +382,181 @@ public static class OpCodes /// /// pull a value off the defer stack, and push it into the main stack. - /// If the defer stack is empty, a 0 is used. + /// If the defer stack is empty, a 0 is used. + /// + public const byte POP_DEFER = 63; + + /// + /// Begin a runto: pops a target address off the stack, pushes a runto frame + /// (target_addr, test_resume_ip), and sets instructionIndex to the saved + /// programResumeIP. Used at the start of every `runto :L` statement in test code. + /// + public const byte RUNTO = 64; + + /// + /// Yield-back marker: the compiler emits this immediately after every label + /// that is referenced by a `runto` somewhere in the test corpus. When executed, + /// checks whether the top of runtoStack targets the current address. If yes, + /// stores the program IP in programResumeIP and jumps to test_resume_ip. + /// Otherwise falls through. In `dotnet run` builds (no tests), this opcode is + /// not emitted. + /// + public const byte RUNTO_YIELD = 65; + + /// + /// Records an assertion failure on the VM and halts execution. The data + /// stack at the time of dispatch holds a string ptr pointing to the + /// captured source text of the asserted expression. The compiler emits + /// this only on the failure branch of an assert; passing assertions just + /// fall through. + /// + public const byte ASSERT_FAIL = 66; + + /// + /// Installs a void-mock for a host command. Stack at dispatch: + /// [..., commandId:int] → consumed + /// On the next CALL_HOST for that command id, the VM pops the args + /// (per CommandInfo.args metadata) but does not invoke the C# method + /// and does not push a return value. Useful for suppressing void + /// commands like `wait ms` during tests. + /// + public const byte MOCK_VOID = 67; + + /// + /// Installs a value-returning mock for a host command. Stack at + /// dispatch (top to bottom): + /// [..., commandId:int, returnValue:typed] → consumed + /// On the next CALL_HOST for that command id, args are popped and + /// the recorded return value is pushed in their place. + /// + public const byte MOCK_RETURNS = 68; + + /// + /// Installs a forbid-mock for a host command. Stack: [..., commandId:int]. + /// On dispatch, the VM records an assertion failure naming the command. + /// + public const byte MOCK_FORBID = 69; + + /// + /// Removes any mock registration for a single command. Stack: [..., commandId:int]. + /// + public const byte MOCK_CLEAR = 70; + + /// + /// Removes all mock registrations for the current VM. No stack inputs. + /// + public const byte MOCK_CLEAR_ALL = 71; + + /// + /// Pushes the current scope-stack depth onto the data stack as an int. + /// Depth 1 means only the global scope is live. Used by the assert-unwind + /// trampoline to walk up the scope chain draining defers, but is a + /// general primitive any future "unwind to scope N" feature can reuse. + /// + public const byte PUSH_SCOPE_DEPTH = 72; + + /// + /// Reads a 4-byte command id inline from the bytecode (the next 4 + /// bytes after the opcode) and pushes the current invocation count + /// for that host command onto the data stack as an int. Counts are + /// maintained on the VM regardless of whether a mock is installed, + /// so `call count cmd` works for unmocked commands too — returns 0 + /// when `cmd` was never called. + /// + public const byte CALL_COUNT = 73; + + /// + /// Installs a mock pointing at a bytecode body. Stack at dispatch + /// (top → bottom): commandId (int), bodyAddr (int). The VM records + /// the body's address; CALL_HOST for that command id will JUMP to + /// the body (pushing methodStack like a normal function call) so + /// the body runs with command args bound as locals in a new scope. + /// Replaces MOCK_VOID / MOCK_RETURNS / MOCK_FORBID for new mocks. + /// + public const byte MOCK_INSTALL = 74; + + /// + /// Writes a typed value through a PTR_REG / PTR_GLOBAL_REG pointer + /// stored in a body-local register. Reads an inline register address + /// (the body local's slot). Pops a typed value from the stack and + /// writes it through the pointer found in that local. + /// Stack at dispatch (top → bottom): typed value to write. + /// Used by ref-arg writeback at mock-body exit. + /// + public const byte WRITE_REF = 75; + + /// + /// Reads through a PTR_REG / PTR_GLOBAL_REG stored in a body-local + /// register and pushes the value at the pointed-to register onto + /// the data stack as a typed value. Reads an inline register + /// address (the body local's slot holding the ptr). + /// Used at mock-body prelude to initialize a ref param's value + /// register with the caller's current value. + /// + public const byte LOAD_REF = 76; + + /// + /// Stores a typed ref-pointer from the stack into a body-local + /// register, preserving the stack-side type code (PTR_REG or + /// PTR_GLOBAL_REG). Reads an inline register address (the slot). + /// Distinct from STORE_PTR — which reads the type code from VM + /// state — because in the mock-body prelude the VM-state typeCode + /// has been clobbered by intervening opcodes between the caller's + /// push and this store. + /// Stack at dispatch (top → bottom): typed pointer (8 bytes + type). + /// + public const byte STORE_REF = 77; + + /// + /// Spreads a Fade single-dimensional array onto the data stack in + /// the shape a `params` arg expects. Pops a heap pointer to the + /// array's contents (PTR_HEAP, 8 bytes + type code). Reads an + /// inline element type code (1 byte). Pushes each element as a + /// typed value in REVERSE order (so the host reads them back in + /// declaration order), then pushes the element count as an int. + /// + public const byte SPREAD_ARRAY = 78; + + /// + /// Pops a heap pointer (PTR_HEAP, 8 bytes + type code) and reads + /// the underlying allocation's byte length. Divides by an inline + /// element-size byte and pushes the resulting count as an int. + /// Used to implement the `len()` keyword for arrays and strings: + /// the compiler emits the element size based on the source type + /// (4 for string chars, 4/8/etc. for typed arrays). + /// + public const byte LENGTH = 79; + + /// + /// Inverse of SPREAD_ARRAY: pops a count int from the stack, then + /// pops `count` typed values, allocates a heap block of + /// count*elemSize bytes, writes the values into it, and pushes + /// a PTR_HEAP to the new allocation. Element type comes from an + /// inline byte. Used by mock-body preludes to receive a params + /// arg as a Fade array the body can iterate. + /// + public const byte GATHER_ARRAY = 80; + + /// + /// Like CALL_HOST, but bypasses the mock table entirely — always + /// dispatches to the real registered host command, even when a + /// mock is installed for that command id. Used by the + /// `passthrough` keyword inside a mock body so a mock can invoke + /// the underlying real command. Identical stack shape and inline + /// encoding to CALL_HOST. CallCount is NOT incremented (the + /// surrounding CALL_HOST already counted the outer invocation). + /// + public const byte CALL_HOST_REAL = 81; + + /// + /// Same as JUMP_HISTORY (pops destination off the stack, pushes + /// a return frame onto methodStack, jumps). Used by test + /// launchers to GOSUB into ancestor-or-self test bodies. The + /// pushed frame is tagged isLauncherFrame=true so user-facing + /// stack-trace capture skips it — launcher frames are control- + /// flow plumbing, not visible call sites. RETURN treats them + /// identically to a normal JUMP_HISTORY frame. /// - public const byte POP_DEFER = 63; + public const byte JUMP_HISTORY_LAUNCH = 82; } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs index 900e63c..9ba4e45 100644 --- a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs +++ b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs @@ -57,6 +57,12 @@ public struct JumpHistoryData { public int fromIns; public int toIns; + // True when this frame was pushed by a test launcher's GOSUB + // (JUMP_HISTORY_LAUNCH). Launcher frames are control-flow plumbing + // — they exist so a test's `from`-chain can run as a sequence of + // GOSUBs — and shouldn't appear in user-facing stack traces. The + // RETURN opcode still pops them; only stack-trace capture filters. + public bool isLauncherFrame; } public struct VirtualRuntimeError @@ -64,6 +70,11 @@ public struct VirtualRuntimeError public VirtualRuntimeErrorType type; public int insIndex; public string message; + // Snapshot of the VM's methodStack at the moment the error was raised + // (innermost frame first). Resolution to source locations is done by + // a downstream consumer that has access to DebugData. Empty when the + // error happened with no function calls in flight. + public JumpHistoryData[] callStack; } public enum VirtualRuntimeErrorType @@ -74,7 +85,8 @@ public enum VirtualRuntimeErrorType INVALID_ADDRESS, INVALID_MEMORY_COPY, CANNOT_TOKENIZE_WITHOUT_HIGHER_CONTEXT, - EXPLODE + EXPLODE, + ASSERT_FAILED } public class TokenReplacement @@ -129,10 +141,97 @@ public class VirtualMachine public List tokenReplacements; + /// + /// Stack of pending runto frames. Each frame records the target program + /// address the test asked to advance to, and the test-side instruction + /// the VM should resume at when that target is hit. + /// + public FastStack runtoStack = new FastStack(4); + + /// + /// Where the program should resume from on the next `runto`. On the very + /// first `runto`, this points at the program's main entry (instructionIndex + /// after interned-data setup, i.e. 4). On subsequent runtos, this is the + /// saved IP from the most recent RUNTO_YIELD. + /// + public int programResumeIP; + + /// + /// Set when an `assert` fails during test execution. Null means the test + /// has not failed any assertions (yet). The test runner inspects this + /// after Execute() returns to determine pass/fail. + /// + public TestFailure assertionFailure; + + /// + /// True when this VM is running a test entry point. The test runner sets + /// this before Execute so a failed `assert` (anywhere — even in + /// main-program code reached via runto) records a TestFailure and halts + /// instead of throwing a runtime exception. When false, a failed assert + /// triggers a normal VM runtime error, identical to divide-by-zero etc. + /// + public bool isTestExecution; + + public class TestFailure + { + public string sourceText; // Captured text of the asserted expression. + public int instructionIndex; // IP at the moment of failure (for source-mapping). + public string reason; // Optional reason string from `assert , ""`. Empty when not provided. + // Snapshot of methodStack at the moment of failure. Innermost frame + // first (top of stack). Each entry's fromIns is the call site of + // that frame; toIns is the function's entry address. Empty when the + // assert fired at the test entry level with no function calls in + // between. Used to build a source-mapped call stack for the failure + // report; resolution happens in the test runner, not the VM. + public JumpHistoryData[] callStack = System.Array.Empty(); + } + + /// + /// Per-VM mock registrations. Keyed by host method id (the index into + /// ). On CALL_HOST the dispatcher + /// consults this table first; if a registration exists, it pops the + /// command's args via metadata and synthesizes the mock behavior in + /// place of the real call. + /// + public Dictionary mockTable; + + /// + /// Per-VM host-call counter. Incremented on every CALL_HOST (mocked or + /// not) when is true. Read by the + /// call count <command> expression so tests can assert + /// how often a command was invoked. Keyed by host method id, same as + /// . Null until the first increment. + /// + public Dictionary hostCallCounts; + + public class MockBehavior + { + // 0 = void (skip), 1 = returns (push value), 2 = forbid (assert-fail), + // 3 = body (run bytecode block — Phase B onward). + public byte kind; + // For kind = Returns: the typed return value to push. + public byte returnTypeCode; + public byte[] returnBytes; + // For kind = Forbid: optional user-supplied reason text (empty + // when the user wrote `forbid` with no reason) and the address + // of the assert-unwind trampoline so a forbid failure can drain + // defers the same way an assert failure does. + public string forbidReason; + public int forbidTrampolineAddr; + // For kind = Body: bytecode address of the mock body. CALL_HOST + // pushes methodStack and jumps here. The body itself pushes a + // scope, binds args from the stack as locals, runs user code, + // pops scope, and RETURNs to the caller. + public int bodyAddr; + } + public VirtualMachine(IEnumerable program) : this(program.ToArray()) { } - public VirtualMachine(byte[] program) + public VirtualMachine(byte[] program) : this(program, 4) + { + } + public VirtualMachine(byte[] program, int entryPointAddress) { this.program = program; shouldThrowRuntimeException = true; @@ -140,11 +239,12 @@ public VirtualMachine(byte[] program) scopeStack = new FastStack(16); methodStack = new FastStack(16); heap = new VmHeap(128); - - instructionIndex = 4; + + instructionIndex = entryPointAddress; + programResumeIP = 4; internedDataInstructionIndex = BitConverter.ToInt32(program, 0); - + ReadInternedData(); globalScope = scope = new VirtualScope(internedData.maxRegisterAddress); scopeStack.Push(globalScope); @@ -283,6 +383,25 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp i += incrementer) { cycles++; + + // Runto max-cycles enforcement. Only the topmost frame ticks, + // so nested runtos each get their own independent budget and + // an outer frame's budget pauses while an inner runto is active. + if (runtoStack.Count > 0) + { + ref var runtoTop = ref runtoStack.buffer[runtoStack.ptr - 1]; + if (--runtoTop.cyclesRemaining < 0) + { + assertionFailure = new TestFailure + { + sourceText = "RUNTO exceeded max cycles", + instructionIndex = instructionIndex + }; + instructionIndex = int.MaxValue; + break; + } + } + var ins = Advance(); switch (ins) { @@ -390,6 +509,26 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp } stack.PushSpanAndType(new ReadOnlySpan(BitConverter.GetBytes(jumpSite)), TypeCodes.INT, TypeCodes.GetByteSize(TypeCodes.INT)); break; + case OpCodes.PUSH_SCOPE_DEPTH: + // Push current scope-stack depth as a typed int. Depth 1 = global only. + stack.PushSpanAndType( + new ReadOnlySpan(BitConverter.GetBytes(scopeStack.Count)), + TypeCodes.INT, TypeCodes.GetByteSize(TypeCodes.INT)); + break; + case OpCodes.CALL_COUNT: + { + // Inline 4-byte command id; push that command's + // host-call count as a typed int. Unknown command + // ids (never invoked) push 0. + var cmdId = BitConverter.ToInt32(program, instructionIndex); + instructionIndex += 4; + var count = 0; + hostCallCounts?.TryGetValue(cmdId, out count); + stack.PushSpanAndType( + new ReadOnlySpan(BitConverter.GetBytes(count)), + TypeCodes.INT, TypeCodes.GetByteSize(TypeCodes.INT)); + break; + } case OpCodes.PUSH_DEFER: // read the place we should jump to when the scope is popped. VmUtil.ReadAsInt(ref stack, out var a); @@ -408,6 +547,18 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp logger?.Log($"[VM] JUMP HISTORY FROM=[{instructionIndex}] TO=[{insPtr}]"); instructionIndex = insPtr; break; + case OpCodes.JUMP_HISTORY_LAUNCH: + // Identical to JUMP_HISTORY but tags the frame as + // launcher-pushed so CaptureCallStack filters it. + VmUtil.ReadAsInt(ref stack, out insPtr); + methodStack.Push(new JumpHistoryData + { + toIns = insPtr, + fromIns = instructionIndex, + isLauncherFrame = true + }); + instructionIndex = insPtr; + break; case OpCodes.RETURN: if (methodStack.ptr > 0) { @@ -636,19 +787,19 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp { heap.TryDecrementRefCount(scope.dataRegisters[addr]); } - + scope.dataRegisters[addr] = data; scope.typeRegisters[addr] = typeCode; - scope.insIndexes[addr] = instructionIndex - 1; // minus one because the instruction has already been advanced. + scope.insIndexes[addr] = instructionIndex - 1; // minus one because the instruction has already been advanced. scope.flags[addr] = VirtualScope.FLAG_PTR; - + heap.IncrementRefCount(data); - - // TODO: this is not a very good balance of efficiency... + + // TODO: this is not a very good balance of efficiency... // the sweeping is costly, and maybe it makes sense to // do it only every now and then, not on EVERY assign - heap.Sweep(); - + heap.Sweep(); + break; case OpCodes.STORE_PTR_GLOBAL: @@ -661,15 +812,15 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp { heap.TryDecrementRefCount(globalScope.dataRegisters[addr]); } - + globalScope.dataRegisters[addr] = data; globalScope.typeRegisters[addr] = typeCode; - globalScope.insIndexes[addr] = instructionIndex - 1; // minus one because the instruction has already been advanced. + globalScope.insIndexes[addr] = instructionIndex - 1; // minus one because the instruction has already been advanced. globalScope.flags[addr] = VirtualScope.FLAG_PTR | VirtualScope.FLAG_GLOBAL; - + heap.IncrementRefCount(data); - heap.Sweep(); - + heap.Sweep(); + break; case OpCodes.STORE_GLOBAL: VmUtil.ReadRegAddress(program, ref instructionIndex, out addr); @@ -767,23 +918,371 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp break; case OpCodes.READ: - + { VmUtil.ReadAsVmPtr(ref stack, out var readPtr); VmUtil.ReadAsInt(ref stack, out var readLength); - heap.Read(readPtr, readLength, out aBytes); - stack.PushSpan(aBytes, readLength); + heap.ReadSpan(readPtr, readLength, out var readSpan); + stack.PushSpan(readSpan, readLength); break; + } // case OpCodes.LENGTH: // VmUtil.ReadAsInt(ref stack, out var readLengthPtr); // heap.GetAllocationSize(readLengthPtr, out var readAllocLength); // VmUtil.PushSpan(ref stack, BitConverter.GetBytes(readAllocLength), TypeCodes.INT); // break; case OpCodes.CALL_HOST: - VmUtil.ReadAsInt(ref stack, out var hostMethodPtr); hostMethods.FindMethod(hostMethodPtr, out var method); - HostMethodUtil.Execute(method, this); - + + // Per-command invocation counting. We tally every + // CALL_HOST in test mode regardless of mock state + // so `call count ` works even before any + // mock is installed. Outside test mode the count + // is unused, so skip the dictionary work. + if (isTestExecution) + { + hostCallCounts ??= new Dictionary(); + hostCallCounts.TryGetValue(hostMethodPtr, out var prevCount); + hostCallCounts[hostMethodPtr] = prevCount + 1; + } + + if (mockTable != null && mockTable.TryGetValue(hostMethodPtr, out var mock)) + { + if (mock.kind == 3) + { + // Phase B: mock body is a bytecode block. + // The args are still on the stack — the + // body itself pops and binds them as + // locals in a fresh scope. We push the + // method-call return frame so the body's + // RETURN lands us back here. + methodStack.Push(new JumpHistoryData + { + fromIns = instructionIndex, + toIns = mock.bodyAddr + }); + instructionIndex = mock.bodyAddr; + break; + } + + // Legacy path (Phase A): pop the args off the + // stack as the real executor would, then + // synthesize the behavior. + if (method.args != null) + { + for (var ai = method.args.Length - 1; ai >= 0; ai--) + { + if (method.args[ai].isVmArg) continue; + VmUtil.ReadValueAny(this, default, out _, out _, out _, allowOptional: true); + } + } + + if (mock.kind == 1) + { + // returns: push the recorded value + VmUtil.PushSpan(ref stack, mock.returnBytes, mock.returnTypeCode); + } + else if (mock.kind == 2) + { + // Forbid: same shape as a failing assert. + // Capture the call stack, build a TestFailure + // carrying the user's reason (if supplied), + // and redirect to the unwind trampoline so + // defers in every live scope drain before + // the test runner reports the result. + // Re-entrancy guard: if a prior failure is + // already recorded (e.g., a deferred body + // re-fires forbid or assert), just halt + // and keep the first failure. + if (assertionFailure != null) + { + instructionIndex = int.MaxValue; + break; + } + assertionFailure = new TestFailure + { + sourceText = "forbidden command was called: " + method.name, + reason = mock.forbidReason ?? "", + instructionIndex = instructionIndex, + callStack = CaptureCallStack() + }; + instructionIndex = mock.forbidTrampolineAddr > 0 + ? mock.forbidTrampolineAddr + : int.MaxValue; + } + // kind == 0 (void): nothing else to do; args are gone + } + else + { + HostMethodUtil.Execute(method, this); + } + + break; + + case OpCodes.CALL_HOST_REAL: + { + // `passthrough` inside a mock body: dispatch + // to the real command, never to the mock. We + // don't bump hostCallCounts here because the + // outer CALL_HOST that routed into the mock + // already counted this invocation. + // + // Scope dance: the body's PUSH_SCOPE made the + // mock body's locals the current scope. The + // real host writes ref args via + // `vm.dataRegisters[addr]` (current scope), so + // for PTR_REG addresses that point to the + // caller's registers to land correctly, we + // need the caller's scope to BE the current + // scope during the call. Temporarily pop the + // body scope, run the host, then put it back. + // Body-local arrays remain valid because + // VirtualScope.dataRegisters is a managed + // reference and survives the by-value copy. + VmUtil.ReadAsInt(ref stack, out var realHostMethodPtr); + hostMethods.FindMethod(realHostMethodPtr, out var realMethod); + + var savedBodyScope = scopeStack.buffer[scopeStack.ptr - 1]; + scopeStack.ptr--; + scope = scopeStack.buffer[scopeStack.ptr - 1]; + + HostMethodUtil.Execute(realMethod, this); + + scopeStack.buffer[scopeStack.ptr] = savedBodyScope; + scopeStack.ptr++; + scope = savedBodyScope; + break; + } + + case OpCodes.GATHER_ARRAY: + { + // Inverse of SPREAD_ARRAY. Inline element type + // byte; stack has `[..., elemN, ..., elem1, count]` + // (count on top — same shape a `params` arg + // produces). Pops count, then pops `count` + // typed values, materializes a heap block, + // pushes the PTR_HEAP. + var gatherElemTc = Advance(); + var gatherElemSize = TypeCodes.GetByteSize(gatherElemTc); + VmUtil.ReadAsInt(ref stack, out var gatherCount); + var gatherBytes = new byte[gatherCount * gatherElemSize]; + for (var gi = 0; gi < gatherCount; gi++) + { + // Each element has [data_bytes][type_byte]; + // pop type, then data. We trust the type + // matches what the inline byte says (caller + // sets it from the params arg metadata). + stack.Pop(); // discard type code + for (var gb = gatherElemSize - 1; gb >= 0; gb--) + { + gatherBytes[gi * gatherElemSize + gb] = stack.Pop(); + } + } + var gatherFormat = new HeapTypeFormat + { + typeCode = gatherElemTc, + typeFlags = HeapTypeFormat.CreateArrayFlag(1) + }; + heap.Allocate(ref gatherFormat, gatherBytes.Length, out var gatherPtr); + heap.Write(gatherPtr, gatherBytes.Length, gatherBytes); + var gatherPtrBytes = VmPtr.GetBytes(ref gatherPtr); + VmUtil.PushSpan(ref stack, gatherPtrBytes, TypeCodes.PTR_HEAP); + break; + } + case OpCodes.LENGTH: + { + // Inline 1-byte element size. Pops a heap ptr + // (or STRING-typed heap ptr — interned strings + // are tagged STRING after their CAST), reads + // the allocation size, divides by the element + // size, pushes the count as an int. + var lenElemSize = Advance(); + stack.Pop(); // discard the type code (PTR_HEAP, STRING, etc.) + var lenPtrBytes = new byte[8]; + for (var lb = 7; lb >= 0; lb--) lenPtrBytes[lb] = stack.Pop(); + var lenPtr = VmPtr.FromBytes(lenPtrBytes); + heap.TryGetAllocationSize(lenPtr, out var lenBytes); + var lenCount = lenElemSize > 0 ? lenBytes / lenElemSize : 0; + VmUtil.PushSpan(ref stack, + BitConverter.GetBytes(lenCount), + TypeCodes.INT); + break; + } + case OpCodes.SPREAD_ARRAY: + { + // Pops a Fade-array heap ptr, then pushes each + // element as a typed value (in reverse, so the + // first element ends up second-from-top), then + // pushes the element count as an int. The + // overall stack shape after this matches what a + // `params` arg expects from the host-method + // dispatcher: [..., elemN, ..., elem1, count]. + var spreadElemTc = Advance(); + var spreadElemSize = TypeCodes.GetByteSize(spreadElemTc); + VmUtil.ReadAsVmPtr(ref stack, out var spreadPtr); + heap.TryGetAllocationSize(spreadPtr, out var spreadBytes); + var spreadCount = spreadElemSize > 0 ? spreadBytes / spreadElemSize : 0; + if (spreadCount > 0) + { + heap.ReadSpan(spreadPtr, spreadBytes, out var spreadSpan); + // Push elements LIFO so the receiver reads + // them back in declaration order — same as + // an inline `Foo(1,2,3)` call would produce. + for (var ei = spreadCount - 1; ei >= 0; ei--) + { + VmUtil.PushSpan(ref stack, spreadSpan.Slice(ei * spreadElemSize, spreadElemSize), spreadElemTc); + } + } + VmUtil.PushSpan(ref stack, + BitConverter.GetBytes(spreadCount), + TypeCodes.INT); + break; + } + case OpCodes.STORE_REF: + { + // Inline 4-byte register address (a body-local). + // Stack at dispatch (top → bottom): + // ptr type code (1 byte), 8 bytes register addr. + // Unlike STORE_PTR, the type comes from the + // stack — necessary because VM-state typeCode + // has been clobbered by intervening opcodes. + VmUtil.ReadRegAddress(program, ref instructionIndex, out var refStoreAddr); + var ptrTc = stack.Pop(); + var ptrBytes = new byte[8]; + for (var sb = 7; sb >= 0; sb--) ptrBytes[sb] = stack.Pop(); + var ptrData = BitConverter.ToUInt64(ptrBytes, 0); + scope.dataRegisters[refStoreAddr] = ptrData; + scope.typeRegisters[refStoreAddr] = ptrTc; + scope.flags[refStoreAddr] = VirtualScope.FLAG_PTR; + break; + } + case OpCodes.LOAD_REF: + { + // Inline 4-byte register address (a body-local + // holding a PTR_REG / PTR_GLOBAL_REG). Read + // through that pointer into the caller's scope + // (or global) and push the typed value found + // there. The body's PUSH_SCOPE pushed a new + // scope after CALL_HOST routed here, so the + // caller's scope sits one slot below current. + VmUtil.ReadRegAddress(program, ref instructionIndex, out var refReadAddr); + var refRegAddr2 = scope.dataRegisters[refReadAddr]; + var refPtrType2 = scope.typeRegisters[refReadAddr]; + + ulong valData; + byte valType; + if (refPtrType2 == TypeCodes.PTR_GLOBAL_REG) + { + valData = globalScope.dataRegisters[refRegAddr2]; + valType = globalScope.typeRegisters[refRegAddr2]; + } + else + { + ref var callerScope2 = ref scopeStack.buffer[scopeStack.ptr - 2]; + valData = callerScope2.dataRegisters[refRegAddr2]; + valType = callerScope2.typeRegisters[refRegAddr2]; + } + var valSize = TypeCodes.GetByteSize(valType); + var valBytes = BitConverter.GetBytes(valData); + stack.PushSpanAndType(new ReadOnlySpan(valBytes), valType, valSize); + break; + } + case OpCodes.WRITE_REF: + { + // Inline 4-byte register address (a body-local + // holding the caller's ref pointer). Pops a + // typed value from the stack and writes it + // through the pointer into the caller's scope. + VmUtil.ReadRegAddress(program, ref instructionIndex, out var refLocalAddr); + + // Body-local holds: dataRegister = 8 bytes of + // the caller's register address, typeRegister = + // PTR_REG or PTR_GLOBAL_REG. + var refRegAddr = scope.dataRegisters[refLocalAddr]; + var refPtrTypeCode = scope.typeRegisters[refLocalAddr]; + + // Peek the value's type code from the stack + // before reading the data, so we can stamp it + // back into the caller's register. + var valTypeCode = stack.buffer[stack.ptr - 1]; + VmUtil.ReadSpanAsUInt(ref stack, out var refData); + + if (refPtrTypeCode == TypeCodes.PTR_GLOBAL_REG) + { + globalScope.dataRegisters[refRegAddr] = refData; + globalScope.typeRegisters[refRegAddr] = valTypeCode; + } + else + { + // PTR_REG: write into the caller's scope. + // The body's PUSH_SCOPE pushed a new scope on + // top after CALL_HOST routed here, so the + // caller's scope sits one slot below. + ref var callerScope = ref scopeStack.buffer[scopeStack.ptr - 2]; + callerScope.dataRegisters[refRegAddr] = refData; + callerScope.typeRegisters[refRegAddr] = valTypeCode; + } + break; + } + case OpCodes.MOCK_INSTALL: + { + // Stack at dispatch (bottom→top): bodyAddr (int), commandId (int). + VmUtil.ReadAsInt(ref stack, out var installCmdId); + VmUtil.ReadAsInt(ref stack, out var installBodyAddr); + mockTable ??= new Dictionary(); + mockTable[installCmdId] = new MockBehavior + { + kind = 3, + bodyAddr = installBodyAddr + }; + break; + } + case OpCodes.MOCK_VOID: + { + VmUtil.ReadAsInt(ref stack, out var voidId); + mockTable ??= new Dictionary(); + mockTable[voidId] = new MockBehavior { kind = 0 }; + break; + } + case OpCodes.MOCK_RETURNS: + { + // Stack top: typed return value; below: commandId. + VmUtil.ReadSpan(ref stack, out var retType, out var retSpan); + var retBytes = retSpan.ToArray(); + VmUtil.ReadAsInt(ref stack, out var retId); + mockTable ??= new Dictionary(); + mockTable[retId] = new MockBehavior + { + kind = 1, + returnTypeCode = retType, + returnBytes = retBytes + }; + break; + } + case OpCodes.MOCK_FORBID: + { + // Stack at dispatch (bottom→top): + // reason (string), trampolineAddr (int), commandId (int) + VmUtil.ReadAsInt(ref stack, out var forbidId); + VmUtil.ReadAsInt(ref stack, out var forbidTrampoline); + var forbidReason = PopAssertString(); + mockTable ??= new Dictionary(); + mockTable[forbidId] = new MockBehavior + { + kind = 2, + forbidReason = forbidReason, + forbidTrampolineAddr = forbidTrampoline + }; + break; + } + case OpCodes.MOCK_CLEAR: + { + VmUtil.ReadAsInt(ref stack, out var clearId); + mockTable?.Remove(clearId); + break; + } + case OpCodes.MOCK_CLEAR_ALL: + mockTable?.Clear(); break; case OpCodes.NOOP: @@ -859,6 +1358,98 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp break; case OpCodes.BREAKPOINT: break; + case OpCodes.RUNTO: + // Stack at dispatch: [..., maxCycles, target]. Pop target first. + VmUtil.ReadAsInt(ref stack, out var runtoTarget); + VmUtil.ReadAsInt(ref stack, out var runtoMaxCycles); + // The test-resume IP is the very next instruction after this RUNTO. + // (instructionIndex has already been incremented past the RUNTO opcode.) + runtoStack.Push(new RuntoFrame + { + targetAddr = runtoTarget, + testResumeIp = instructionIndex, + cyclesRemaining = runtoMaxCycles + }); + // Switch execution to wherever the program is currently paused. + instructionIndex = programResumeIP; + break; + case OpCodes.RUNTO_YIELD: + // The compiler emits RUNTO_YIELD after every label that's a runto target. + // We're exactly one instruction past the label here. If the runtoStack top + // matches our address, yield back to the test. Otherwise fall through. + // + // The "match" is: the target address that the test asked for == the address + // immediately AFTER the RUNTO_YIELD opcode (i.e., the body of the program + // resuming at the next real instruction). The compiler records the target + // as that post-yield address. + if (runtoStack.Count > 0 && runtoStack.buffer[runtoStack.ptr - 1].targetAddr == instructionIndex) + { + var frame = runtoStack.Pop(); + // Save where the program is now so the next runto can resume from here. + programResumeIP = instructionIndex; + instructionIndex = frame.testResumeIp; + } + // else fall through; this label wasn't the targeted one. + break; + case OpCodes.ASSERT_FAIL: + { + // Data stack at dispatch (bottom → top): + // reason (string), sourceText (string), trampolineAddr (int) + // Strings come from the LiteralStringExpression path + // ([8 ptr bytes][STRING type code]); interned strings get + // CAST to STRING after the PTR push, variable refs push the + // same shape with a heap ptr. We accept either STRING or + // PTR_HEAP. trampolineAddr is the compiler-baked address of + // the assert-unwind trampoline, used in test mode only. + VmUtil.ReadAsInt(ref stack, out var trampolineAddr); + var text = PopAssertString(); + var reasonText = PopAssertString(); + if (isTestExecution) + { + // Re-entrancy guard: if a deferred body that we're + // running as part of unwinding contains its own + // failing assert, keep the first failure and halt + // instead of restarting the trampoline. + if (assertionFailure != null) + { + instructionIndex = int.MaxValue; + break; + } + // Test-mode: record the failure (this path is also + // taken when a test runtos into main-program code + // that hits an assert) and redirect to the trampoline + // so defers in every live scope get drained. Capture + // the call chain now; the trampoline doesn't pop + // methodStack, but a stable snapshot decouples + // downstream consumers from VM state. + assertionFailure = new TestFailure + { + sourceText = text, + reason = reasonText, + instructionIndex = instructionIndex, + callStack = CaptureCallStack() + }; + instructionIndex = trampolineAddr; + } + else + { + // Main-program execution: a failed assert is a + // hard runtime error, on par with divide-by-zero. + // Defers do NOT run; trampolineAddr is ignored. + var hasReason = !string.IsNullOrEmpty(reasonText); + var message = hasReason + ? $"assert failed: {text} — {reasonText}" + : $"assert failed: {text}"; + TriggerRuntimeError(new VirtualRuntimeError + { + insIndex = instructionIndex, + type = VirtualRuntimeErrorType.ASSERT_FAILED, + message = message + }); + instructionIndex = int.MaxValue; + } + break; + } default: throw new Exception("Unknown op code: " + ins); } @@ -883,14 +1474,86 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp public int test = 0; + public struct RuntoFrame + { + public int targetAddr; + public int testResumeIp; + public int cyclesRemaining; + } + void TriggerRuntimeError(VirtualRuntimeError error) { + // Stamp a call-stack snapshot onto the error unless the caller + // already provided one. This gives every runtime-error consumer + // (test runner, future crash reporter, DAP) the same shape used + // for assert-mode failures. + if (error.callStack == null) + { + error.callStack = CaptureCallStack(); + } this.error = error; if (shouldThrowRuntimeException) { throw new VirtualRuntimeException(error); } } + + /// + /// Snapshot the current methodStack into a stable array. Index 0 is the + /// innermost (most recent) call; the last entry is the outermost. + /// Used by ASSERT_FAIL test-mode and TriggerRuntimeError to attach a + /// call-chain to the error, decoupled from later VM state changes. + /// + public JumpHistoryData[] CaptureCallStack() + { + var depth = methodStack.Count; + // Two-pass so we know the visible count up front and can size + // the array exactly. Launcher frames are filtered — they're + // internal control flow, not user-visible calls. + var visible = 0; + for (var i = 0; i < depth; i++) + { + if (!methodStack.buffer[i].isLauncherFrame) visible++; + } + var copy = new JumpHistoryData[visible]; + var write = 0; + for (var i = 0; i < depth; i++) + { + var src = methodStack.buffer[depth - 1 - i]; + if (src.isLauncherFrame) continue; + copy[write++] = src; + } + return copy; + } + + // Pop one Fade string off the data stack and materialize it as a C# string. + // Used by ASSERT_FAIL. The compiler pushes strings as [8 ptr bytes][type code] + // and either STRING or PTR_HEAP type codes may appear here. Returns "" if + // the pointer is null or the read fails. + private string PopAssertString() + { + stack.Pop(); // type code; accepted unconditionally + var ptrBytes = new byte[8]; + for (var b = 7; b >= 0; b--) ptrBytes[b] = stack.Pop(); + var ptr = VmPtr.FromBytes(ptrBytes); + try + { + if (heap.TryGetAllocationSize(ptr, out var len) && len > 0) + { + heap.Read(ptr, len, out var bytes); + // Fade strings are stored as 4-bytes-per-char (uint codepoints). + var charCount = len / 4; + var chars = new char[charCount]; + for (var c = 0; c < charCount; c++) + { + chars[c] = (char)BitConverter.ToUInt32(bytes, c * 4); + } + return new string(chars); + } + } + catch { /* best-effort recovery; fall through */ } + return ""; + } } public class VirtualRuntimeException : Exception diff --git a/FadeBasic/FadeBasicCommands/BenchmarkCorpus.cs b/FadeBasic/FadeBasicCommands/BenchmarkCorpus.cs new file mode 100644 index 0000000..7c90758 --- /dev/null +++ b/FadeBasic/FadeBasicCommands/BenchmarkCorpus.cs @@ -0,0 +1,170 @@ +namespace FadeBasic +{ + public static class BenchmarkCorpus + { + public const string Short = @" +a = 10 +b = 20 +c = a + b +d# = 3.14 +e# = d# * 2.0 +s$ = ""hello world"" +result = a * b - c +flag = result > 100 +"; + + public const string Medium = @" +dim scores(10) +total = 0 +for i = 0 to 9 + scores(i) = i * i + total = total + scores(i) +next i +average = total / 10 +if average > 30 + big = 1 +else + big = 0 +endif +x# = 1.0 +for j = 1 to 20 + x# = x# * 1.05 +next j +name$ = ""FadeBasic"" +result$ = name$ + "" benchmark"" +n = 0 +while n < 8 + n = n + 1 +endwhile +a = 255 +b = a && 15 +c = a || 1 +d = a XOR b +e = NOT a +acc = 0 +for k = 1 to 5 + if k = 3 + acc = acc + k + endif +next k +"; + + public const string Large = @" +type point + x + y +endtype + +type rect + left + top + right + bottom +endtype + +p as point +p.x = 42 +p.y = 17 + +r as rect +r.left = 0 +r.top = 0 +r.right = 800 +r.bottom = 600 + +dim pts(20) as point +for i = 0 to 19 + pts(i).x = i * 5 + pts(i).y = i * 3 +next i + +sumX = 0 +sumY = 0 +for i = 0 to 19 + sumX = sumX + pts(i).x + sumY = sumY + pts(i).y +next i + +function clamp(v, lo, hi) + result = v + if v < lo + result = lo + endif + if v > hi + result = hi + endif +endfunction result + +function sign(n) + result = 0 + if n > 0 + result = 1 + endif + if n < 0 + result = -1 + endif +endfunction result + +a = clamp(150, 0, 100) +b = clamp(-5, 0, 100) +s1 = sign(42) +s2 = sign(-7) +s3 = sign(0) + +acc# = 0.0 +for i = 1 to 50 + v# = i * 0.1 + acc# = acc# + v# +next i + +outer = 0 +for row = 1 to 10 + for col = 1 to 10 + if row = col + outer = outer + 1 + endif + next col +next row + +n = 200 +while n > 0 + n = n - 7 +endwhile + +name$ = ""FadeBasic"" +version$ = ""1.0.0"" +tag$ = name$ + "" v"" + version$ +a$ = ""alpha"" +b$ = ""beta"" +c$ = a$ + b$ + +x# = 1.0 +y# = 1.0 +for fib = 1 to 30 + temp# = x# + x# = y# + y# = temp# + y# +next fib + +dim vals(100) +for i = 0 to 99 + vals(i) = i * i - i + 1 +next i +total = 0 +for i = 0 to 99 + total = total + vals(i) +next i + +flag1 = total > 1000 +flag2 = total < 500000 +flag3 = flag1 && flag2 +combined = flag1 || flag2 + +base = 2 +power = 1 +for exp = 1 to 16 + power = power * base +next exp +"; + } +} diff --git a/FadeBasic/FadeBuildTasks/FadeBasic.Build.props b/FadeBasic/FadeBuildTasks/FadeBasic.Build.props index 0fedf18..f05abc8 100644 --- a/FadeBasic/FadeBuildTasks/FadeBasic.Build.props +++ b/FadeBasic/FadeBuildTasks/FadeBasic.Build.props @@ -5,10 +5,22 @@ <_FadeBasic_TaskFolder>$(MSBuildThisFileDirectory)..\tasks\net8.0 $(_FadeBasic_TaskFolder)\$(MSBuildThisFileName).dll - + + + - + @@ -17,8 +29,8 @@ - + - \ No newline at end of file + diff --git a/FadeBasic/FadeBuildTasks/FadeBasic.Build.targets b/FadeBasic/FadeBuildTasks/FadeBasic.Build.targets index c1bb7c7..8d6e8d2 100644 --- a/FadeBasic/FadeBuildTasks/FadeBasic.Build.targets +++ b/FadeBasic/FadeBuildTasks/FadeBasic.Build.targets @@ -1,13 +1,13 @@ - + true - + True False - + GeneratedFade Launch @@ -16,17 +16,54 @@ $(MSBuildProjectDirectory)/$(FadeGeneratedFolder)/$(FadeGeneratedLaunchType).g.cs + + + + <_FadeTestingHits Include="@(PackageReference)" Condition="'%(PackageReference.Identity)' == 'FadeBasic.Testing'" /> + + + + + + + + + + - @@ -49,4 +86,4 @@ - \ No newline at end of file + diff --git a/FadeBasic/FadeBuildTasks/FadeProjectTask.cs b/FadeBasic/FadeBuildTasks/FadeProjectTask.cs index 008c742..24541c9 100644 --- a/FadeBasic/FadeBuildTasks/FadeProjectTask.cs +++ b/FadeBasic/FadeBuildTasks/FadeProjectTask.cs @@ -22,6 +22,10 @@ public class FadeProjectTask : Task public bool GenerateEntryPoint { get; set; } = true; public bool IgnoreSafetyChecks { get; set; } = false; public bool GenerateDebugData { get; set; } + // When true, the generated Main dispatches to Microsoft.Testing.Platform + // for `dotnet test` invocations and to Launcher.Main otherwise. + // Wire this from the FadeEnableTesting MSBuild property. + public bool EnableTesting { get; set; } = false; [Required] public ITaskItem[] SourceFiles { get; set; } [Required] public ITaskItem[] Commands { get; set; } @@ -165,7 +169,8 @@ public override bool Execute() LaunchableGenerator.GenerateLaunchable(GeneratedClassName, GenerateFileLocation, unit, commandCollection.collection, allClassNames, includeMain: GenerateEntryPoint, - generateDebug: GenerateDebugData); + generateDebug: GenerateDebugData, + enableTesting: EnableTesting); GeneratedFile = GenerateFileLocation; return true; } diff --git a/FadeBasic/LSP.Core/AUDIT.md b/FadeBasic/LSP.Core/AUDIT.md new file mode 100644 index 0000000..09c0280 --- /dev/null +++ b/FadeBasic/LSP.Core/AUDIT.md @@ -0,0 +1,58 @@ +# LSP.Core vs Native LSP — Behavioral Audit + +After the refactor, the native LSP project (`FadeBasic/LSP/`) is a thin +adapter over `FadeBasic.LSP.Core.Handlers.*`. Each native handler: + +1. Looks up the requesting URI's `CodeUnit` via `CompilerService`. +2. Calls `CoreAdapter.ToDocument(unit, uri, projectDocs?)` to build a + single `FadeDocument` view of the parsed AST. +3. Maps the request's position into the compiled unit's coordinate space + via `unit.sourceMap.TryGetMappedLocation` (identity for single-file + projects; non-trivial for multi-file ones). +4. Invokes Core's `Compute(...)`. +5. Translates Core's DTOs into OmniSharp protocol types, mapping ranges + back through `unit.sourceMap.GetOriginalLocation` so multi-file + projects resolve to the originating files. + +Doc-aware handlers (Hover today, Completion if extended) install a +`ProjectDocsCommandDocsProvider` on the `FadeDocument` so Core's +`ICommandDocsProvider` hook gets the *same* parsed `ProjectDocs` the +native LSP already exposes. + +## Per-handler diff vs the pre-refactor native code + +| Handler | Pre-refactor native | Core | Diff | +|---|---|---|---| +| **Diagnostics** | Project-aware, multi-file | Per-document | (Unchanged — diagnostics aren't routed through Core yet; lives in `LSP/Handlers/DiagnosticsHandler.cs`.) | +| **SemanticTokens** | Project-aware, source-mapped | Per-document | (Unchanged — lives in `LSP/Handlers/SemanticTokenHandler.cs`.) | +| **Hover** | Walked AST; rich Markdown for commands via `ProjectDocs`; raw `function.Trivia` for function calls; nothing for variables/parameters/labels; no diagnostics on hover | Diagnostics first; rich Markdown for commands via `ICommandDocsProvider` (same `ProjectDocs` underneath); fenced `fade` code-block header + trivia for functions / variables / parameters / labels | ✅ More coverage. Function trivia now has a header (signature) before the doc text. Hovering an error region now shows the error. | +| **Completion** | Returned a `` placeholder item when the macro program was absent | Returns an empty list in the same situation | ✅ No more debug-string leakage. Otherwise identical context-building + `LSPUtil.GetCompletions` call. | +| **SignatureHelp** | AST walk + token-fallback for `name(`; per-param documentation from `ProjectDocs` | Same AST walk + token-fallback; per-param docs come via `LspSignatureParameter.Documentation` (currently null because Core doesn't fill it from docs — handler post-fills if needed) | ⚠ Per-param documentation: Core doesn't fetch from docs yet. Native adapter passes `ProjectDocs` to Core but Core ignores it for sig help. **Follow-up: surface command param docs in Core's sig help.** | +| **GotoDefinition** | AST `FindFirst` on allowed types; walked program *and* macroProgram; mapped result range via sourceMap | Same AST `FindFirst`; walks `doc.Program` only | ⚠ Macro-expanded tokens won't resolve to definitions through Core. Multi-file source-map mapping handled at the native adapter layer. | +| **FindReferences** | Single-pass: matched node → DeclaredFromSymbol.source → walk program for DeclaredFromSymbol matches. Walked macroProgram too. | Multi-pass: matched node + DeclaredFromSymbol.source + nodes whose DeclaredFromSymbol.source's token equals the clicked token. **Walks only `doc.Program`.** | ✅ Clicking the declaration site (e.g. LHS of `x = 1`) now returns use sites — old native code returned only the LHS. ⚠ Macro-expanded refs not walked. | +| **DocumentSymbol** | Dumped every "interesting" lexer token as a separate symbol; re-read file from disk per request | AST-driven outline (functions with nested labels, types, top-level declarations, labels); reads in-memory parse tree | ✅ Massive UX improvement. Range now covers full bodies; SelectionRange covers the name token. No more per-keyword/per-string noise. | +| **FoldingRange** | Hardcoded stub (`[2,4]`) | AST-driven (functions, if/for/while/do/repeat blocks, type/test blocks, multi-line `rem` comments) | ✅ Massive UX improvement. | +| **Formatting** | Re-lexed source from disk per request; cased per `conf.language.fade.formatCasing` | Operates on the LexerResults the workspace already has; same casing setting plumbed through `LspFormattingOptions` | ✅ No FS roundtrip; output now strictly follows the LSP's view of the document. Casing behavior preserved. | +| **FormattingRange** | Filtered FormattingHandler result by intersection | (Unchanged composition; just delegates to the refactored FormattingHandler) | (None) | +| **FormattingWhenTyping** | Filtered FormattingHandler result by line-distance | (Unchanged composition; just delegates to the refactored FormattingHandler) | (None) | +| **Rename** | Walked program + macroProgram; emitted edits keyed on the request URI | Walks `doc.Program` only; ranges mapped back through `unit.sourceMap` to originating files | ⚠ Macro-expanded refs not walked. Multi-file projects: edits now correctly target originating source files instead of a single URI. | + +## Open follow-ups + +1. **Macro program walks.** GotoDef / References / Rename in Core don't yet + inspect `LexerResults.macroProgram`. Native pre-refactor did. Adding a + `doc.MacroProgram` field to `FadeDocument` and visiting both is a + straightforward extension. +2. **Per-parameter docs in SignatureHelp.** Core's `LspSignatureParameter` + has a `Documentation` field but `SignatureHelpHandler.BuildCommandSignature` + doesn't yet pull from `ICommandDocsProvider`. The pre-refactor native + filled this from `ProjectDocs.methodDocs.parameters[i].body`. Wire the + same lookup in Core to close this gap. +3. **Completion item documentation.** Built-in command completions have no + per-item documentation in either Core or the native pre-refactor. + Hover compensates by showing rich docs on hover-over-completion. + Surface command summaries on the `LspCompletionItem.Documentation` + field if the suggest popup's doc panel should show more. +4. **Per-token Range output.** The Core Hover handler returns a range + based on the matched token, not the matched AST node. Single-file + parity; multi-file output is slightly tighter. diff --git a/FadeBasic/LSP.Core/Dtos.cs b/FadeBasic/LSP.Core/Dtos.cs new file mode 100644 index 0000000..609dbba --- /dev/null +++ b/FadeBasic/LSP.Core/Dtos.cs @@ -0,0 +1,133 @@ +// Transport-agnostic DTOs used by all LSP handlers in Core. Different LSP +// frontends (OmniSharp-based native server, browser FadeBridge) translate +// between these and their wire-protocol types. + +using System.Collections.Generic; + +namespace FadeBasic.LSP.Core +{ + public class LspPosition + { + public int Line; // 0-based + public int Character; // 0-based + } + + public class LspRange + { + public LspPosition Start; + public LspPosition End; + } + + public enum LspDiagnosticSeverity + { + Error = 1, + Warning = 2, + Information = 3, + Hint = 4, + } + + public class LspDiagnostic + { + public LspRange Range; + public LspDiagnosticSeverity Severity; + public string Code; + public string Source; + public string Message; + } + + public class LspHoverResult + { + public string Contents; // Markdown + public LspRange Range; + } + + public enum LspCompletionKind + { + Text = 0, + Variable = 1, + Function = 2, + Interface = 3, + Keyword = 4, + Field = 5, + Class = 6, + Constant = 7, + Reference = 8, + Folder = 9, + Method = 10, + Snippet = 11, + } + + public enum LspInsertTextFormat + { + PlainText = 1, + Snippet = 2, + } + + public class LspCompletionItem + { + public string Label; + public string InsertText; + public LspCompletionKind Kind; + public string Detail; + public string Documentation; + public string SortText; + public string FilterText; + public LspInsertTextFormat InsertTextFormat = LspInsertTextFormat.PlainText; + public bool TriggerParameterHints; + } + + public class LspSemanticTokens + { + // LSP-encoded delta-format: groups of 5 ints. + public List Data; + } + + public class LspTextEdit + { + public LspRange Range; + public string NewText; + } + + public class LspWorkspaceEdit + { + // Per-URI list of edits. + public Dictionary> Changes; + } + + // Matches the LSP SymbolKind enum subset we use. + public enum LspSymbolKind + { + File = 1, Module = 2, Namespace = 3, Package = 4, Class = 5, + Method = 6, Property = 7, Field = 8, Constructor = 9, Enum = 10, + Interface = 11, Function = 12, Variable = 13, Constant = 14, + String = 15, Number = 16, Boolean = 17, Array = 18, Object = 19, + Key = 20, Null = 21, EnumMember = 22, Struct = 23, Event = 24, + Operator = 25, TypeParameter = 26, + } + + public class LspDocumentSymbol + { + public string Name; + public string Detail; + public LspSymbolKind Kind; + public LspRange Range; // full extent (body included) + public LspRange SelectionRange; // just the name token + public List Children; + } + + public enum LspFoldingRangeKind + { + Region = 0, + Comment = 1, + Imports = 2, + } + + public class LspFoldingRange + { + public int StartLine; + public int EndLine; + public int? StartCharacter; + public int? EndCharacter; + public LspFoldingRangeKind Kind; + } +} diff --git a/FadeBasic/LSP.Core/FadeBasic.LSP.Core.csproj b/FadeBasic/LSP.Core/FadeBasic.LSP.Core.csproj new file mode 100644 index 0000000..d20de4e --- /dev/null +++ b/FadeBasic/LSP.Core/FadeBasic.LSP.Core.csproj @@ -0,0 +1,17 @@ + + + + FadeBasic.LSP.Core + FadeBasic.LSP.Core + + netstandard2.1 + 9.0 + disable + + + + + + + diff --git a/FadeBasic/LSP.Core/FadeDocument.cs b/FadeBasic/LSP.Core/FadeDocument.cs new file mode 100644 index 0000000..3de9da3 --- /dev/null +++ b/FadeBasic/LSP.Core/FadeDocument.cs @@ -0,0 +1,51 @@ +// Per-document state held by the LSP. Each open file in the editor has one +// FadeDocument; the workspace owns the dictionary of them. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace FadeBasic.LSP.Core +{ + public class FadeDocument + { + public string Uri; + public string Text; + public LexerResults LexResults; + public ProgramNode Program; + public CommandCollection Commands; + // Optional doc-lookup hook. Hosts that have command documentation + // (native LSP via ProjectDocs, WebRuntime via embedded JSON) install + // a provider so hover/completion can surface rich markdown. + public ICommandDocsProvider Docs; + + public bool IsValid => LexResults != null; + } + + // A minimal contract for command documentation. Returns null if the + // command is unknown. + public interface ICommandDocsProvider + { + ICommandDocs Lookup(CommandInfo command); + } + + // The slice of a command's documentation we actually render. Hosts map + // their own doc types into this. + public interface ICommandDocs + { + string Summary { get; } + string Returns { get; } + string Remarks { get; } + IReadOnlyList Parameters { get; } + IReadOnlyList Examples { get; } + // Optional canonical web URL for this command (e.g. docs site). + string Url { get; } + } + + public interface ICommandParameterDoc + { + string Name { get; } + string Body { get; } + } +} diff --git a/FadeBasic/LSP.Core/FadeWorkspace.cs b/FadeBasic/LSP.Core/FadeWorkspace.cs new file mode 100644 index 0000000..390846d --- /dev/null +++ b/FadeBasic/LSP.Core/FadeWorkspace.cs @@ -0,0 +1,73 @@ +// Collection of FadeDocuments. Owns the lexer/parser path. Frontends call +// SetDocument when a file is opened or changes, then ask handlers for results. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast.Visitors; + +namespace FadeBasic.LSP.Core +{ + public class FadeWorkspace + { + private readonly Dictionary _docs = new(); + private readonly Lexer _lexer = new(); + + public CommandCollection Commands { get; set; } + // Optional docs provider — when set, every SetDocument call attaches + // it to the resulting FadeDocument so handlers can render rich + // command markdown. + public ICommandDocsProvider Docs { get; set; } + + public FadeWorkspace(CommandCollection commands = null) + { + Commands = commands ?? new CommandCollection(); + } + + public FadeDocument SetDocument(string uri, string text) + { + var lex = _lexer.TokenizeWithErrors(text, Commands); + var parser = new Parser(lex.stream, Commands); + var program = parser.ParseProgram(); + + // Resolves names, populates DeclaredFromSymbol on AST refs, and + // fills program.scope.positionedVariables — all of which the + // completion, references, and goto-def handlers depend on. + // Without this the only errors we report are syntax-level. + try + { + program.AddScopeRelatedErrors(ParseOptions.Default); + } + catch { /* visitor is best-effort; never fail SetDocument */ } + + // Attach trivia (doc-comment) strings to functions/declarations/ + // labels so the hover handler can render them as markdown. + try + { + program.AddTrivia(lex); + } + catch { /* trivia is best-effort */ } + + var doc = new FadeDocument + { + Uri = uri, + Text = text, + LexResults = lex, + Program = program, + Commands = Commands, + Docs = Docs, + }; + _docs[uri] = doc; + return doc; + } + + public FadeDocument Get(string uri) + { + _docs.TryGetValue(uri, out var d); + return d; + } + + public bool Remove(string uri) => _docs.Remove(uri); + + public IEnumerable AllDocuments => _docs.Values; + } +} diff --git a/FadeBasic/LSP.Core/Handlers/CompletionHandler.cs b/FadeBasic/LSP.Core/Handlers/CompletionHandler.cs new file mode 100644 index 0000000..f16a57c --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/CompletionHandler.cs @@ -0,0 +1,404 @@ +// Compute completion items at a position. Builds a CompletionContext for the +// existing FadeBasic.Lsp.LSPUtil.GetCompletions which does the real work. + +using System; +using System.Collections.Generic; +using System.Linq; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Lsp; +using FadeBasic.SourceGenerators; // FadeBasicCommandUsage +using FadeBasic.Virtual; // TypeCodes +using LspCompletionContext = FadeBasic.Lsp.CompletionContext; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class CompletionHandler + { + public static List Compute(FadeDocument doc, int line, int character) + { + if (doc?.LexResults == null || doc.Program == null) return new List(); + + var fakeToken = new Token { lineNumber = line, charNumber = character }; + + // Find the nearest token to the left. + Token leftToken = null; + for (int i = doc.LexResults.allTokens.Count - 1; i >= 0; i--) + { + var token = doc.LexResults.allTokens[i]; + if (token.lineNumber < line) + { + leftToken = token; + break; + } + if (token.lineNumber == line && token.charNumber <= character) + { + leftToken = token; + break; + } + } + + if (leftToken == null) return new List(); + + bool isMacro = leftToken.flags.HasFlag(TokenFlags.IsMacroToken); + + bool Visit(IAstVisitable v) + { + return v is ProgramNode + || (Token.IsLocationBeforeOrEqual(v.StartToken, fakeToken) + && Token.IsLocationBeforeOrEqual(fakeToken, v.EndToken)); + } + + ProgramNode programNode; + IEnumerable group; + if (isMacro && doc.LexResults.macroProgram != null) + { + programNode = doc.LexResults.macroProgram; + group = programNode?.Where(Visit); + } + else + { + programNode = doc.Program; + group = programNode?.Where(Visit); + } + + if (programNode == null) return new List(); + + // Locate the function/scope context the position is inside. + if (!programNode.scope.positionedVariables.TryFindEntry(fakeToken, out var entry)) + { + if (programNode.scope.positionedVariables.entries.Count == 0) + return new List(); + entry = programNode.scope.positionedVariables.entries[0]; + } + + var context = new LspCompletionContext + { + IsMacro = isMacro, + FakeToken = fakeToken, + LeftToken = leftToken, + Program = programNode, + Commands = doc.Commands, + FunctionName = entry.value.Item2, + Group = group?.ToList(), + ConstantTable = doc.LexResults.constantTable, + LocalScope = entry.value.Item1, + }; + + var portable = LSPUtil.GetCompletions(context); + + // Command-argument rescue. When the leftToken is a fully- + // resolved CommandWord but the cursor sits past its end + // (typical case: trailing whitespace right after the command + // name, e.g. `sprite |`), Visit() returns false on the + // CommandStatement node — the cursor isn't strictly inside + // [StartToken, EndToken] because EndToken's position is its + // *start* char, not its end char. The switch above then sees + // ProgramNode + a CommandWord leftToken, which matches none + // of its cases, and returns empty. + // + // Three distinct cursor positions around a CommandWord need + // different completion sets — and the AST switch above can't + // tell them apart because Visit() uses StartToken/EndToken + // (which are TOKEN positions, not span endpoints): + // + // 1. cursor INSIDE or AT END of the CommandWord (`sprit|`, + // `sprite|`): the user is still typing or has just + // finished typing a command name. Show the command list + // so they can refine / pick a different one. Variables + // are NOT relevant yet — they haven't moved to the arg + // slot. + // + // 2. cursor PAST CommandWord on the same line (`sprite |`): + // the user is in the first-arg slot. Show variables / + // symbols matching the first parameter's type. Variables + // should dominate; the command list adds noise here. + // + // 3. cursor on a different line entirely: leftToken happens + // to be a CommandWord just because it was the most-recent + // token, but it's no longer relevant. Don't fire anything. + // + // (1) is handled below as `cursorAtCommandEnd`; (2) as + // `cursorPastCommandEnd`. The cursor-inside-the-word case + // (`sprit|`) doesn't hit either branch because the lexer can't + // produce a CommandWord for a partial name — leftToken there + // is VariableGeneral, and Monaco's cached completion list + // from when the user last typed a trigger character handles + // filtering. + var isOnCommandLine = + leftToken.type == LexemType.CommandWord + && leftToken.lineNumber == line; + var cursorPastCommandEnd = + isOnCommandLine && leftToken.EndCharNumber < character; + var cursorAtCommandEnd = + isOnCommandLine && leftToken.EndCharNumber == character; + + if (cursorPastCommandEnd) + { + // Case 2: arg-slot rescue. Surface first-parameter + // symbols/functions/commands for the command word the + // user just finished typing. + var cmdName = leftToken.caseInsensitiveRaw + ?? leftToken.raw?.ToLowerInvariant(); + if (!string.IsNullOrEmpty(cmdName)) + { + var found = false; + var cmd = default(CommandInfo); + foreach (var c in doc.Commands.Commands) + { + if (c.name != null + && c.name.Equals(cmdName, StringComparison.OrdinalIgnoreCase)) + { + cmd = c; + found = true; + break; + } + } + if (found && cmd.args != null && cmd.args.Length > 0) + { + var paramItems = LSPUtil.GetCommandParameterCompletions( + cmd, + new List(), + new List(), + context); + var seen = new HashSet( + portable.Select(p => p.Label ?? string.Empty)); + foreach (var p in paramItems) + { + var label = p.Label ?? string.Empty; + if (seen.Contains(label)) continue; + seen.Add(label); + portable.Add(p); + } + } + } + } + else if (cursorAtCommandEnd) + { + // Case 1: cursor at end of CommandWord. Surface every + // command + function so Monaco can filter by the typed + // word and the user sees `sprite` / `sprite height` / + // etc. We bypass GetStatementCompletions because it + // hardcodes TypeInfo.Void as the wanted return type and + // therefore filters out every non-void-returning command + // (`screen width` returns int, etc.) — exactly the items + // a user mid-typing a command name needs to see. Using + // TypeInfo.Unset opens the filter completely; type- + // checking happens when the command is actually used. + AddAllCommandsAndFunctions(portable, context); + } + + // Multi-word command rescue. The lexer only collapses a token + // span into a CommandWord when the FULL command name has been + // typed (HandleCommandNames in Lexer.cs only rewrites at + // isValidCommand=true leaves). Halfway through `set sprite + // render target` the lexer sees `set` + `sprite` as two plain + // identifiers, the parser routes the AST through Assignment or + // a fresh expression node, and LSPUtil.GetCompletions's switch + // ends up in a case that returns symbol/expression completions + // — not the command list. Result: the user types `set sprite` + // and the rest of the command name disappears from the + // dropdown until they backspace. + // + // Sniff the line text from the cursor backwards for a + // contiguous identifier+spaces run; if it spans more than one + // word, treat it as a partial multi-word command prefix and + // union in commands whose name starts with it. The Monaco / + // VSCode side then sees these alongside whatever the AST + // walk yielded. + var prefix = ReadMultiWordCommandPrefix(doc, line, character); + // When the leftToken is a complete CommandWord AND the cursor + // is sitting in the trailing whitespace right after it (no + // character of the next word typed yet), suppress the multi- + // word continuation rescue. The command-arg rescue above has + // already populated variable / parameter completions for the + // first arg slot, and those are what the user wants to see + // first. Monaco's fuzzy scorer would otherwise rank command + // continuations (whose filterText starts with the typed + // prefix) above the variables — score outranks sortText. + // The user can type the first character of the next word to + // bring continuations back. + var suppressMultiWord = + leftToken.type == LexemType.CommandWord + && !string.IsNullOrEmpty(prefix) + && prefix.EndsWith(" "); + if (!string.IsNullOrEmpty(prefix) && !suppressMultiWord) + { + var seenLabels = new HashSet( + portable.Select(p => p.Label ?? string.Empty)); + foreach (var cmd in doc.Commands.Commands) + { + // Mirror GetCommandCallCompletions's usage filter so + // we don't surface runtime commands inside a `#` + // macro block (or vice versa). + if (isMacro && !cmd.usage.HasFlag(FadeBasicCommandUsage.Macro)) continue; + if (!isMacro && !cmd.usage.HasFlag(FadeBasicCommandUsage.Runtime)) continue; + if (cmd.name == null) continue; + if (cmd.name.Length <= prefix.Length) continue; + if (!cmd.name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + // Skip if the same command already came back through + // the switch — avoids stacking duplicates when the + // AST happened to route correctly. + if (seenLabels.Contains(cmd.name)) continue; + var hasReturn = cmd.returnType != TypeCodes.VOID; + portable.Add(new PortableCompletionItem + { + InsertTextFormat = PortableInsertTextFormat.Snippet, + Kind = PortableCompletionKind.Interface, + Label = cmd.name, + // FilterText carries the typed prefix so Monaco's + // matcher scores these as exact-prefix hits even + // though the cursor's "current word" is just the + // last partial token. Without this, "set spr" → + // "set sprite render target" would score as a + // mid-substring match and rank below noise. + FilterText = cmd.name, + InsertText = cmd.name + (hasReturn ? "($0)" : ""), + // Sort just above the other command-call entries + // so the partial-prefix match floats to the top. + SortText = "b", + TriggerParameterHints = true, + }); + } + } + + // Statement-leading-identifier safety net. When the user has + // typed something like a single letter `s` at the start of a + // line, the parser interprets it as the LHS of an unfinished + // assignment. LSPUtil.GetCompletions routes that AST shape to + // GetAssignmentCompletions, which early-returns empty because + // LeftToken isn't `=`. None of the rescues above match either + // (leftToken is VariableGeneral, not CommandWord; no spaces + // in the prefix). Result: zero completions, even though the + // user obviously wants to see `sprite`, `sin`, etc. + // + // If nothing else has populated `portable` and the leftToken + // sits on the current line at the start of a statement-leading + // position, fall back to the full command + function list so + // Monaco can filter by what was typed. Same reasoning as the + // cursorAtCommandEnd branch — using GetStatementCompletions + // here would drop every non-void command. + if (portable.Count == 0 + && leftToken.lineNumber == line + && leftToken.EndCharNumber <= character) + { + AddAllCommandsAndFunctions(portable, context); + } + + return portable.Select(ToLspCompletionItem).ToList(); + } + + // Shared helper for the cursorAtCommandEnd branch + the safety- + // net fallback: load every command + function (filtered by the + // doc's macro/runtime usage flags) regardless of return type, + // dedup by label, and append to `portable`. Using TypeInfo.Unset + // on both LSPUtil helpers disables the return-type filter that + // GetStatementCompletions's hardcoded TypeInfo.Void imposes — + // statement-level positions should still see int/string-returning + // commands for filtering, even if calling them as a bare + // statement would discard the return value. + private static void AddAllCommandsAndFunctions( + List portable, + LspCompletionContext context) + { + var seen = new HashSet( + portable.Select(p => p.Label ?? string.Empty)); + foreach (var pair in LSPUtil.GetCommandCallCompletions(TypeInfo.Unset, context)) + { + var label = pair.item.Label ?? string.Empty; + if (seen.Contains(label)) continue; + seen.Add(label); + portable.Add(pair.item); + } + foreach (var pair in LSPUtil.GetFunctionCallCompletions(TypeInfo.Unset, context.Scope)) + { + var label = pair.item.Label ?? string.Empty; + if (seen.Contains(label)) continue; + seen.Add(label); + portable.Add(pair.item); + } + } + + // Scan the line text backwards from the cursor for a contiguous + // run of identifier characters and single spaces. Returns the + // prefix only when it contains at least one space (multi-word) — + // single-word prefixes are already handled by the existing switch + // path. Returns null/empty when the prefix isn't a plausible + // command-name fragment (e.g. starts with a digit, contains an + // operator, or has no spaces). + private static string ReadMultiWordCommandPrefix(FadeDocument doc, int line, int character) + { + if (doc?.Text == null) return string.Empty; + // Carve out just the current line so we don't accidentally walk + // over a newline boundary on documents with very long single + // lines (split is fine — we only need a few chars at the end). + var lines = doc.Text.Split('\n'); + if (line < 0 || line >= lines.Length) return string.Empty; + var lineText = lines[line]; + var endCol = character < lineText.Length ? character : lineText.Length; + if (endCol <= 0) return string.Empty; + + int start = endCol; + while (start > 0) + { + var c = lineText[start - 1]; + // Allow identifier chars + single spaces. Stop on anything + // else (operators, parens, punctuation) — that's a strong + // signal we're not in a command-name context anymore. + if (char.IsLetterOrDigit(c) || c == '_' || c == ' ') + { + start--; + continue; + } + break; + } + // Trim only the LEFT side; trailing/internal spaces are part of + // the user's typed prefix and matter for the StartsWith check. + var prefix = lineText.Substring(start, endCol - start).TrimStart(); + if (prefix.Length == 0) return string.Empty; + // The first character must be a letter (or underscore) — + // commands always start with a letter; bailing here avoids + // matching purely-numeric runs like " 12 34" at the cursor. + var head = prefix[0]; + if (!char.IsLetter(head) && head != '_') return string.Empty; + // Multi-word only — single-word fragments are already covered + // by the normal switch path's GetCommandCallCompletions. + return prefix.Contains(' ') ? prefix : string.Empty; + } + + private static LspCompletionItem ToLspCompletionItem(PortableCompletionItem p) + { + return new LspCompletionItem + { + Label = p.Label, + InsertText = p.InsertText, + Kind = ToKind(p.Kind), + Detail = p.Detail, + Documentation = p.Documentation, + SortText = p.SortText, + FilterText = p.FilterText, + InsertTextFormat = p.InsertTextFormat == PortableInsertTextFormat.Snippet + ? LspInsertTextFormat.Snippet + : LspInsertTextFormat.PlainText, + TriggerParameterHints = p.TriggerParameterHints, + }; + } + + private static LspCompletionKind ToKind(PortableCompletionKind kind) + { + switch (kind) + { + case PortableCompletionKind.Variable: return LspCompletionKind.Variable; + case PortableCompletionKind.Function: return LspCompletionKind.Function; + case PortableCompletionKind.Interface: return LspCompletionKind.Interface; + case PortableCompletionKind.Keyword: return LspCompletionKind.Keyword; + case PortableCompletionKind.Field: return LspCompletionKind.Field; + case PortableCompletionKind.Class: return LspCompletionKind.Class; + case PortableCompletionKind.Constant: return LspCompletionKind.Constant; + case PortableCompletionKind.Reference: return LspCompletionKind.Reference; + case PortableCompletionKind.Folder: return LspCompletionKind.Folder; + default: return LspCompletionKind.Text; + } + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/DefinitionHandler.cs b/FadeBasic/LSP.Core/Handlers/DefinitionHandler.cs new file mode 100644 index 0000000..9352cd4 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/DefinitionHandler.cs @@ -0,0 +1,60 @@ +// Go-to-definition: given a cursor on a reference, return the location of +// the AST node that declared the symbol the reference resolves to. +// +// Ported from FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs. + +using System; +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class DefinitionHandler + { + private static readonly HashSet AllowedTypes = new HashSet + { + typeof(VariableRefNode), + typeof(ArrayIndexReference), + typeof(GoSubStatement), + typeof(GotoStatement), + typeof(RuntoStatement), + }; + + public static LspLocation Compute(FadeDocument doc, int line, int character) + { + if (doc?.Program == null || doc.LexResults == null) return null; + + var token = ReferencesHandler.FindTokenAt(doc, line, character) + ?? ReferencesHandler.FindTokenAt(doc, line, character - 1); + if (token == null) return null; + + bool Visit(IAstVisitable x) + { + if (!AllowedTypes.Contains(x.GetType())) return false; + return x.StartToken == token || x.EndToken == token; + } + + var node = doc.Program.FindFirst(Visit) as IAstNode; + if (node == null) return null; + + IAstNode target = node; + switch (node) + { + case ExpressionStatement exprStatement: + target = exprStatement.expression as IAstNode ?? node; + break; + } + + if (target.DeclaredFromSymbol == null) return null; + var origin = target.DeclaredFromSymbol.source; + if (origin == null) return null; + + return new LspLocation + { + Uri = doc.Uri, + Range = ReferencesHandler.TokenRangeOf(origin), + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/DiagnosticsHandler.cs b/FadeBasic/LSP.Core/Handlers/DiagnosticsHandler.cs new file mode 100644 index 0000000..399a5a3 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/DiagnosticsHandler.cs @@ -0,0 +1,68 @@ +// Collect lex + parse errors from a FadeDocument as portable diagnostics. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class DiagnosticsHandler + { + public static List Compute(FadeDocument doc) + { + var diagnostics = new List(); + if (doc == null) return diagnostics; + + // Lex errors and parse errors can describe the same root cause — + // e.g. an unclosed string literal surfaces in both passes with + // identical code/message/range. De-dup by signature so the UI + // shows a single problem per (range, code, message) tuple. + var seen = new HashSet(); + string SigOf(LspDiagnostic d) => + $"{d.Code}|{d.Message}|{d.Range.Start.Line}:{d.Range.Start.Character}-{d.Range.End.Line}:{d.Range.End.Character}"; + + void AddUnique(LspDiagnostic d) + { + if (seen.Add(SigOf(d))) diagnostics.Add(d); + } + + if (doc.LexResults?.tokenErrors != null) + { + foreach (var err in doc.LexResults.tokenErrors) + AddUnique(MakeDiag(err)); + } + + if (doc.Program != null) + { + foreach (var err in doc.Program.GetAllErrors()) + AddUnique(MakeDiag(err)); + } + + return diagnostics; + } + + private static LspDiagnostic MakeDiag(ParseError err) + { + var startTok = err.location?.start; + var endTok = err.location?.end ?? startTok; + int startLine = startTok?.lineNumber ?? 0; + int startChar = startTok?.charNumber ?? 0; + int endLine = endTok?.lineNumber ?? startLine; + int endChar = endTok != null + ? endTok.charNumber + System.Math.Max(1, endTok.Length) + : startChar + 1; + return new LspDiagnostic + { + Severity = LspDiagnosticSeverity.Error, + Range = new LspRange + { + Start = new LspPosition { Line = startLine, Character = startChar }, + End = new LspPosition { Line = endLine, Character = endChar }, + }, + Message = err.CombinedMessage, + Code = err.errorCode.code.ToString(), + Source = "fade", + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/DocumentSymbolHandler.cs b/FadeBasic/LSP.Core/Handlers/DocumentSymbolHandler.cs new file mode 100644 index 0000000..78d0b69 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/DocumentSymbolHandler.cs @@ -0,0 +1,120 @@ +// Document outline: lists top-level functions, type definitions, declarations, +// and labels. Each function expands into its local labels. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class DocumentSymbolHandler + { + public static List Compute(FadeDocument doc) + { + var result = new List(); + if (doc?.Program == null) return result; + + var prog = doc.Program; + + foreach (var typeDef in prog.typeDefinitions) + { + if (typeDef?.name == null) continue; + result.Add(new LspDocumentSymbol + { + Name = typeDef.name.variableName ?? "", + Detail = "type", + Kind = LspSymbolKind.Struct, + Range = NodeRange(typeDef), + SelectionRange = TokenRange(typeDef.name?.StartToken ?? typeDef.StartToken), + }); + } + + foreach (var label in prog.labels) + { + if (label?.label == null) continue; + result.Add(new LspDocumentSymbol + { + Name = label.label, + Detail = "label", + Kind = LspSymbolKind.Key, + Range = NodeRange(label), + SelectionRange = TokenRange(label.StartToken), + }); + } + + // Top-level declarations only (variables shown as outline entries). + foreach (var stmt in prog.statements) + { + if (stmt is DeclarationStatement decl && decl.variableNode != null) + { + result.Add(new LspDocumentSymbol + { + Name = decl.variableNode.variableName ?? "", + Detail = decl.type?.variableType.ToString() ?? "variable", + Kind = LspSymbolKind.Variable, + Range = NodeRange(decl), + SelectionRange = TokenRange(decl.variableNode.StartToken), + }); + } + } + + foreach (var func in prog.functions) + { + if (func?.nameToken == null) continue; + var children = new List(); + if (func.labels != null) + { + foreach (var label in func.labels) + { + if (label?.label == null) continue; + children.Add(new LspDocumentSymbol + { + Name = label.label, + Detail = "label", + Kind = LspSymbolKind.Key, + Range = NodeRange(label), + SelectionRange = TokenRange(label.StartToken), + }); + } + } + result.Add(new LspDocumentSymbol + { + Name = func.name ?? func.nameToken.raw ?? "", + Detail = "function", + Kind = LspSymbolKind.Function, + Range = NodeRange(func), + SelectionRange = TokenRange(func.nameToken), + Children = children.Count > 0 ? children : null, + }); + } + + return result; + } + + private static LspRange NodeRange(IAstNode node) + { + var s = node.StartToken; + var e = node.EndToken ?? s; + var endChar = e.charNumber + (e.raw?.Length ?? e.Length); + return new LspRange + { + Start = new LspPosition { Line = s.lineNumber, Character = s.charNumber }, + End = new LspPosition { Line = e.lineNumber, Character = endChar }, + }; + } + + private static LspRange TokenRange(Token t) + { + if (t == null) return new LspRange + { + Start = new LspPosition(), End = new LspPosition(), + }; + var len = t.raw?.Length ?? t.Length; + return new LspRange + { + Start = new LspPosition { Line = t.lineNumber, Character = t.charNumber }, + End = new LspPosition { Line = t.lineNumber, Character = t.charNumber + len }, + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/FoldingRangeHandler.cs b/FadeBasic/LSP.Core/Handlers/FoldingRangeHandler.cs new file mode 100644 index 0000000..9375fd3 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/FoldingRangeHandler.cs @@ -0,0 +1,76 @@ +// Folding ranges: AST-driven. Visits the program and emits a fold for every +// compound statement (function, if/then, for/next, while/endwhile, +// do/loop, repeat/until, type definitions, tests) that spans multiple +// lines. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class FoldingRangeHandler + { + public static List Compute(FadeDocument doc) + { + var ranges = new List(); + if (doc?.Program == null) return ranges; + + doc.Program.Visit(node => + { + if (node.StartToken == null || node.EndToken == null) return; + if (node is ProgramNode) return; + + bool isFoldable = node is FunctionStatement + || node is IfStatement + || node is ForStatement + || node is WhileStatement + || node is DoLoopStatement + || node is RepeatUntilStatement + || node is TypeDefinitionStatement + || node is TestNode; + if (!isFoldable) return; + + var startLine = node.StartToken.lineNumber; + var endLine = node.EndToken.lineNumber; + if (endLine <= startLine) return; // single-line; no fold + + ranges.Add(new LspFoldingRange + { + StartLine = startLine, + EndLine = endLine, + StartCharacter = node.StartToken.charNumber, + EndCharacter = node.EndToken.charNumber + (node.EndToken.raw?.Length ?? node.EndToken.Length), + Kind = LspFoldingRangeKind.Region, + }); + }); + + // Multi-line comments fold too. The Lexer tags rem-block tokens + // with LexemType.RemStart so we can detect them here. + if (doc.LexResults?.combinedTokens != null) + { + foreach (var t in doc.LexResults.combinedTokens) + { + if (t?.raw == null) continue; + if (t.type != LexemType.KeywordRemStart && t.type != LexemType.KeywordRem) continue; + var startLine = t.lineNumber; + // raw may span multiple lines; count them. + int endLine = startLine; + foreach (var c in t.raw) + if (c == '\n') endLine++; + if (endLine > startLine) + { + ranges.Add(new LspFoldingRange + { + StartLine = startLine, + EndLine = endLine, + Kind = LspFoldingRangeKind.Comment, + }); + } + } + } + + return ranges; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/FormattingHandler.cs b/FadeBasic/LSP.Core/Handlers/FormattingHandler.cs new file mode 100644 index 0000000..97f8658 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/FormattingHandler.cs @@ -0,0 +1,111 @@ +// Formatting handlers (full document, range, on-type). All three delegate +// to TokenFormatter.Format on the lexed tokens, then translate the +// formatter's edits into LSP TextEdits. Range and on-type just filter +// the full set down. + +using System.Collections.Generic; +using FadeBasic; + +namespace FadeBasic.LSP.Core.Handlers +{ + public enum LspCasingSetting + { + Ignore = 0, + ToUpper = 1, + ToLower = 2, + } + + public class LspFormattingOptions + { + public int TabSize = 4; + public bool InsertSpaces = true; + public LspCasingSetting Casing = LspCasingSetting.Ignore; + } + + public static class FormattingHandler + { + public static List Compute(FadeDocument doc, LspFormattingOptions options) + { + var edits = new List(); + if (doc?.LexResults?.combinedTokens == null) return edits; + + options ??= new LspFormattingOptions(); + + var casing = options.Casing switch + { + LspCasingSetting.ToUpper => TokenFormatSettings.CasingSetting.ToUpper, + LspCasingSetting.ToLower => TokenFormatSettings.CasingSetting.ToLower, + _ => TokenFormatSettings.CasingSetting.Ignore, + }; + + var settings = new TokenFormatSettings + { + TabSize = options.TabSize, + UseTabs = !options.InsertSpaces, + Casing = casing, + }; + + var tokenEdits = TokenFormatter.Format(doc.LexResults.combinedTokens, settings); + + // LSP wants the same set; Monaco applies edits in any order safely. + foreach (var e in tokenEdits) + { + edits.Add(new LspTextEdit + { + Range = new LspRange + { + Start = new LspPosition { Line = e.startLine, Character = e.startChar }, + End = new LspPosition { Line = e.endLine, Character = e.endChar }, + }, + NewText = e.replacement ?? string.Empty, + }); + } + + return edits; + } + + public static List ComputeRange(FadeDocument doc, LspFormattingOptions options, LspRange range) + { + var all = Compute(doc, options); + if (range == null) return all; + + var filtered = new List(); + foreach (var e in all) + { + if (RangeIntersects(e.Range, range)) filtered.Add(e); + } + return filtered; + } + + public static List ComputeOnType(FadeDocument doc, LspFormattingOptions options, LspPosition position) + { + var all = Compute(doc, options); + if (position == null) return all; + + // Native handler keeps edits within 1 line of the caret. + var filtered = new List(); + foreach (var e in all) + { + var lineDist = System.Math.Abs(e.Range.Start.Line - position.Line); + if (lineDist < 2) filtered.Add(e); + } + return filtered; + } + + private static bool RangeIntersects(LspRange a, LspRange b) + { + // Treat ranges as inclusive of start, exclusive of end for "touch". + // Returns true if a and b share any character or touch at their ends. + if (Before(a.End, b.Start)) return false; + if (Before(b.End, a.Start)) return false; + return true; + } + + private static bool Before(LspPosition p, LspPosition q) + { + if (p.Line < q.Line) return true; + if (p.Line > q.Line) return false; + return p.Character < q.Character; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/HoverHandler.cs b/FadeBasic/LSP.Core/Handlers/HoverHandler.cs new file mode 100644 index 0000000..52cdc56 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/HoverHandler.cs @@ -0,0 +1,368 @@ +// Surface hover info at a position. Precedence: +// 1. Any diagnostic that covers the position → error markdown. +// 2. A built-in command call → rich markdown from ICommandDocsProvider. +// 3. A function call (user-defined) → trivia from the function decl. +// 4. A symbol reference / declaration → name + type + trivia (as markdown). +// 5. Generic token info as a fallback. + +using System.Text; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class HoverHandler + { + public static LspHoverResult Compute(FadeDocument doc, int line, int character) + { + if (doc == null) return null; + + // First: any diagnostic at this position + if (doc.Program != null) + { + foreach (var err in doc.Program.GetAllErrors()) + { + if (PositionInsideRange(err.location, line, character)) + return new LspHoverResult + { + Contents = "**Error " + err.errorCode.code + "**\n\n" + err.CombinedMessage, + Range = RangeOf(err.location), + }; + } + } + if (doc.LexResults?.tokenErrors != null) + { + foreach (var err in doc.LexResults.tokenErrors) + { + if (PositionInsideRange(err.location, line, character)) + return new LspHoverResult + { + Contents = "**Lex error " + err.errorCode.code + "**\n\n" + err.CombinedMessage, + Range = RangeOf(err.location), + }; + } + } + + // Symbol-aware hover: when the token under the cursor belongs to a + // VariableRef / ArrayIndexReference / FunctionStatement / + // DeclarationStatement, surface name + type + doc-comment. + // Built-in commands take priority so we can show rich docs. + if (doc.Program != null) + { + var hover = TryComputeCommandHover(doc, line, character) + ?? TryComputeSymbolHover(doc, line, character); + if (hover != null) return hover; + } + + // Otherwise: basic info on the token at this position + if (doc.LexResults != null) + { + foreach (var token in doc.LexResults.allTokens) + { + if (token.raw == null) continue; + if (token.lineNumber != line) continue; + if (character < token.charNumber) continue; + if (character > token.charNumber + token.Length) continue; + + return new LspHoverResult + { + Contents = "`" + token.raw + "` — " + token.type.ToString(), + Range = new LspRange + { + Start = new LspPosition { Line = token.lineNumber, Character = token.charNumber }, + End = new LspPosition { Line = token.lineNumber, Character = token.charNumber + token.Length }, + }, + }; + } + } + + return null; + } + + // Builds a markdown hover for a built-in command at the position. + // Walks the AST for the smallest CommandStatement / CommandExpression + // whose token range encloses the cursor. If we have a docs provider + // we surface summary + parameters + returns + remarks + examples, + // mirroring the native LSP's behavior; otherwise we return a basic + // signature header so the user at least sees the command name. + private static LspHoverResult TryComputeCommandHover(FadeDocument doc, int line, int character) + { + var fakeToken = new Token { lineNumber = line, charNumber = character }; + CommandInfo? command = null; + IAstNode owner = null; + + doc.Program.Visit(node => + { + if (node is ProgramNode) return; + if (node.StartToken == null || node.EndToken == null) return; + if (!Token.IsLocationBeforeOrEqual(node.StartToken, fakeToken)) return; + if (!Token.IsLocationBeforeOrEqual(fakeToken, node.EndToken)) return; + switch (node) + { + case CommandStatement cs: + // Prefer the innermost enclosing node — keep updating. + command = cs.command; owner = cs; + break; + case CommandExpression ce: + command = ce.command; owner = ce; + break; + } + }); + if (command == null || owner == null) return null; + + var md = BuildCommandMarkdown(command.Value, doc.Docs); + return new LspHoverResult + { + Contents = md, + Range = new LspRange + { + Start = new LspPosition { Line = owner.StartToken.lineNumber, Character = owner.StartToken.charNumber }, + End = new LspPosition { Line = owner.EndToken.lineNumber, Character = owner.EndToken.charNumber + (owner.EndToken.raw?.Length ?? owner.EndToken.Length) }, + }, + }; + } + + // Public so hosts that want to surface command docs in their own + // UI (e.g. WebRuntime's Help tab) can reuse the same markdown + // renderer the hover uses, keeping both surfaces in sync. + public static string BuildCommandMarkdown(CommandInfo command, ICommandDocsProvider docsProvider) + { + var docs = docsProvider?.Lookup(command); + var sb = new StringBuilder(); + + if (docs != null && !string.IsNullOrEmpty(docs.Url)) + sb.AppendLine($"[Full Documentation]({docs.Url})\n"); + + sb.AppendLine("### " + command.name); + if (!string.IsNullOrEmpty(docs?.Summary)) + sb.AppendLine(docs.Summary.Trim() + "\n"); + + // Parameters + var visibleArgs = command.args ?? new CommandArgInfo[0]; + int visibleCount = 0; + foreach (var a in visibleArgs) if (!a.isVmArg && !a.isRawArg) visibleCount++; + if (visibleCount > 0) + { + sb.AppendLine("#### Parameters"); + int paramIdx = 0; + for (var i = 0; i < visibleArgs.Length; i++) + { + var arg = visibleArgs[i]; + if (arg.isVmArg || arg.isRawArg) continue; + sb.Append("##### "); + if (VmUtil.TryGetVariableTypeDisplay(arg.typeCode, out var typeName)) + sb.Append("`").Append(typeName).Append("` "); + else + sb.Append("_unknown_ "); + if (arg.isOptional) sb.Append("_(optional)_ "); + if (arg.isRef) sb.Append("_(ref)_ "); + if (arg.isParams) sb.Append("_(params)_ "); + + var pdoc = (docs?.Parameters != null && paramIdx < docs.Parameters.Count) ? docs.Parameters[paramIdx] : null; + if (pdoc != null) + { + sb.Append(pdoc.Name); + sb.Append('\n'); + if (!string.IsNullOrEmpty(pdoc.Body)) sb.AppendLine(pdoc.Body.Trim()); + } + else + { + sb.AppendLine("arg" + (paramIdx + 1)); + } + paramIdx++; + } + } + + if (command.returnType != TypeCodes.VOID) + { + sb.AppendLine(); + sb.Append("#### Returns"); + if (VmUtil.TryGetVariableTypeDisplay(command.returnType, out var typeName)) + sb.Append(" `").Append(typeName).Append('`'); + if (!string.IsNullOrEmpty(docs?.Returns)) + { + sb.Append('\n'); + sb.AppendLine(docs.Returns.Trim()); + } + else + { + sb.AppendLine(); + } + } + + if (!string.IsNullOrEmpty(docs?.Remarks)) + { + sb.AppendLine(); + sb.AppendLine("#### Remarks"); + sb.AppendLine(docs.Remarks.Trim()); + } + + if (docs?.Examples != null && docs.Examples.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("#### Examples"); + foreach (var ex in docs.Examples) sb.AppendLine(ex.Trim()); + } + + return sb.ToString(); + } + + // Builds a markdown hover for symbol-bearing nodes. Returns null when + // the position isn't on a known symbol. + private static LspHoverResult TryComputeSymbolHover(FadeDocument doc, int line, int character) + { + var token = ReferencesHandler.FindTokenAt(doc, line, character); + if (token == null) return null; + + IAstNode hit = null; + doc.Program.Visit(x => + { + if (hit != null) return; + bool match = false; + switch (x) + { + case VariableRefNode _: + case ArrayIndexReference _: + case DeclarationStatement _: + case ParameterNode _: + case LabelDeclarationNode _: + case GoSubStatement _: + case GotoStatement _: + case RuntoStatement _: + match = Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, x.EndToken); + break; + case FunctionStatement fs: + match = x.StartToken == token || fs.nameToken == token + || Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, fs.nameToken); + break; + } + if (match) hit = x; + }); + if (hit == null) return null; + + // Resolve a reference to its declaration so we can read trivia. + var decl = hit; + if (decl.DeclaredFromSymbol?.source is IAstNode resolved) decl = resolved; + + var (header, trivia) = DescribeDeclaration(decl, hit); + if (header == null) return null; + + var md = header; + if (!string.IsNullOrEmpty(trivia)) + md += "\n\n---\n\n" + NormalizeTrivia(trivia); + + return new LspHoverResult + { + Contents = md, + Range = new LspRange + { + Start = new LspPosition { Line = token.lineNumber, Character = token.charNumber }, + End = new LspPosition { Line = token.lineNumber, Character = token.charNumber + (token.raw?.Length ?? token.Length) }, + }, + }; + } + + // Returns (markdown header, raw trivia). header includes a fenced + // code block; trivia is added separately so we can normalize it. + private static (string header, string trivia) DescribeDeclaration(IAstNode decl, IAstNode hitNode) + { + string trivia = null; + if (decl is IHasTriviaNode th) trivia = th.Trivia; + else if (hitNode is IHasTriviaNode th2) trivia = th2.Trivia; + + switch (decl) + { + case FunctionStatement func: + { + var parts = new System.Text.StringBuilder(); + parts.Append("function ").Append(func.name ?? func.nameToken?.raw ?? "").Append('('); + if (func.parameters != null) + { + for (var i = 0; i < func.parameters.Count; i++) + { + if (i > 0) parts.Append(", "); + var p = func.parameters[i]; + parts.Append(p.variable?.variableName ?? "?") + .Append(" as ") + .Append(p.type?.variableType.ToString() ?? "?"); + } + } + parts.Append(')'); + return ("```fade\n" + parts + "\n```", trivia); + } + case DeclarationStatement d: + { + var typeName = d.type?.variableType.ToString() ?? "?"; + var name = d.variableNode?.variableName ?? d.EndToken?.raw ?? "?"; + return ("```fade\n" + name + " as " + typeName + "\n```", trivia); + } + case ParameterNode p: + { + var typeName = p.type?.variableType.ToString() ?? "?"; + var name = p.variable?.variableName ?? "?"; + return ("```fade\n" + name + " as " + typeName + " (parameter)\n```", trivia); + } + case LabelDeclarationNode lbl: + { + return ("```fade\n:" + lbl.label + "\n```", trivia); + } + case VariableRefNode v: + { + return ("```fade\n" + (v.variableName ?? "?") + "\n```", trivia); + } + case ArrayIndexReference a: + { + var name = a.variableName ?? "?"; + return ("```fade\n" + name + "(...)\n```", trivia); + } + } + return (null, null); + } + + private static string NormalizeTrivia(string raw) + { + // Strip leading comment markers (`'`, `rem`, ``` ` ```) and trim each line. + var lines = raw.Replace("\r\n", "\n").Split('\n'); + var sb = new System.Text.StringBuilder(); + foreach (var line in lines) + { + var l = line.TrimStart(); + if (l.StartsWith("`")) l = l.Substring(1).TrimStart(); + else if (l.StartsWith("'")) l = l.Substring(1).TrimStart(); + else if (l.StartsWith("rem ", System.StringComparison.OrdinalIgnoreCase)) l = l.Substring(4).TrimStart(); + else if (l.Equals("rem", System.StringComparison.OrdinalIgnoreCase)) l = string.Empty; + if (sb.Length > 0) sb.Append('\n'); + sb.Append(l); + } + return sb.ToString().TrimEnd(); + } + + private static bool PositionInsideRange(TokenRange range, int line, int character) + { + if (range == null) return false; + var s = range.start; var e = range.end; + if (s == null || e == null) return false; + if (line < s.lineNumber || line > e.lineNumber) return false; + if (line == s.lineNumber && character < s.charNumber) return false; + if (line == e.lineNumber && character > e.charNumber + e.Length) return false; + return true; + } + + private static LspRange RangeOf(TokenRange range) + { + var s = range.start; var e = range.end ?? s; + return new LspRange + { + Start = new LspPosition { Line = s?.lineNumber ?? 0, Character = s?.charNumber ?? 0 }, + End = new LspPosition + { + Line = e?.lineNumber ?? 0, + Character = (e?.charNumber ?? 0) + (e?.Length ?? 1), + }, + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/ReferencesHandler.cs b/FadeBasic/LSP.Core/Handlers/ReferencesHandler.cs new file mode 100644 index 0000000..4c33638 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/ReferencesHandler.cs @@ -0,0 +1,134 @@ +// References: given a cursor position, find all AST nodes that resolve to +// the same Symbol (i.e. all uses of the variable, function, label, etc.). +// +// Ported from FadeBasic/LSP/Handlers/FindReferencesHandler.cs but stripped +// of the source-map indirection — Core operates on a single FadeDocument. + +using System.Collections.Generic; +using System.Linq; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public class LspLocation + { + public string Uri; + public LspRange Range; + } + + public static class ReferencesHandler + { + public static List Compute(FadeDocument doc, int line, int character) + { + if (doc?.Program == null || doc.LexResults == null) return null; + + // Find the token at this position. Try the cursor's own column, + // then drift one to the left (hail-mary for cursors sitting in + // immediate whitespace next to a token). + var token = FindTokenAt(doc, line, character) + ?? FindTokenAt(doc, line, character - 1); + if (token == null) return null; + + // Pass 1: collect every AST node that "starts/ends" at the + // clicked token (using location-equality so declaration sites and + // use sites both match) plus every node x where + // x.DeclaredFromSymbol.source has a token at this position. + var atToken = new List(); + var sourceCandidates = new HashSet(); + + void Pass1(IAstVisitable x) + { + bool isMatch = false; + if (x is VariableRefNode + or DeclarationStatement + or ArrayIndexReference + or LabelDeclarationNode + or GoSubStatement + or GotoStatement + or RuntoStatement) + { + isMatch = Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, x.EndToken); + } + else if (x is FunctionStatement funcStatement) + { + isMatch = x.StartToken == token || funcStatement.nameToken == token; + } + if (isMatch) atToken.Add(x); + } + doc.Program.Visit(Pass1); + + if (atToken.Count == 0) return new List(); + + // For each match, the "source" node is either the resolved + // DeclaredFromSymbol.source (clicked on a use) or the node itself + // (clicked on the declaration). Both get added to the candidate + // set so we union the uses of every possible interpretation. + foreach (var node in atToken) + { + sourceCandidates.Add(node); + if (node.DeclaredFromSymbol?.source is IAstNode src) + sourceCandidates.Add(src); + } + + // Also collect every distinct "source" node referenced anywhere + // in the program whose StartToken sits at the same location as + // our clicked token. This catches the case where the user clicks + // on the declaration site (e.g. the LHS of `x = 1`) but the + // implicit symbol's source is a different AST node (the + // surrounding AssignmentStatement). Matching by token location + // unions the two interpretations. + doc.Program.Visit(x => + { + if (x.DeclaredFromSymbol?.source is IAstNode src + && src.StartToken != null + && Token.AreLocationsEqual(src.StartToken, token)) + { + sourceCandidates.Add(src); + } + }); + + // Pass 2: every node whose DeclaredFromSymbol.source is in the + // candidate set is a reference. Source nodes themselves count. + var discovered = new HashSet(sourceCandidates); + doc.Program.Visit(x => + { + if (x.DeclaredFromSymbol?.source is IAstNode src && sourceCandidates.Contains(src)) + discovered.Add(x); + }); + + return discovered.Select(n => new LspLocation + { + Uri = doc.Uri, + Range = TokenRangeOf(n), + }).ToList(); + } + + internal static Token FindTokenAt(FadeDocument doc, int line, int character) + { + if (character < 0) return null; + foreach (var t in doc.LexResults.allTokens) + { + if (t.raw == null && t.caseInsensitiveRaw == null) continue; + if (t.lineNumber != line) continue; + if (character < t.charNumber) continue; + if (character > t.charNumber + t.Length) continue; + return t; + } + return null; + } + + internal static LspRange TokenRangeOf(IAstNode node) + { + var s = node.StartToken; + var e = node.EndToken ?? s; + var endChar = e.charNumber + (e.raw?.Length ?? e.Length); + return new LspRange + { + Start = new LspPosition { Line = s.lineNumber, Character = s.charNumber }, + End = new LspPosition { Line = s.lineNumber, Character = endChar }, + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/RenameHandler.cs b/FadeBasic/LSP.Core/Handlers/RenameHandler.cs new file mode 100644 index 0000000..7b03b05 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/RenameHandler.cs @@ -0,0 +1,119 @@ +// Rename: find the declaration node behind the cursor, then emit a text edit +// for every reference site (the declaration's name token plus every node +// whose DeclaredFromSymbol.source resolves back to the declaration). + +using System; +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class RenameHandler + { + private static readonly HashSet AllowedTypes = new HashSet + { + typeof(VariableRefNode), + typeof(ArrayIndexReference), + typeof(GoSubStatement), + typeof(GotoStatement), + typeof(RuntoStatement), + typeof(DeclarationStatement), + typeof(ParameterNode), + typeof(FunctionStatement), + typeof(LabelDeclarationNode), + }; + + public static LspWorkspaceEdit Compute(FadeDocument doc, int line, int character, string newName) + { + if (doc?.Program == null) return null; + if (string.IsNullOrEmpty(newName)) return null; + + var token = ReferencesHandler.FindTokenAt(doc, line, character) + ?? ReferencesHandler.FindTokenAt(doc, line, character - 1); + if (token == null) return null; + + IAstNode declaration = null; + void Visit(IAstVisitable x) + { + if (declaration != null) return; + if (!AllowedTypes.Contains(x.GetType())) return; + + bool match = false; + if (x is FunctionStatement fs) + match = x.StartToken == token || fs.nameToken == token + || Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, fs.nameToken); + else + match = Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, x.EndToken); + + if (match) declaration = x; + } + doc.Program.Visit(Visit); + if (declaration == null) return null; + + // Walk up to the declaration if we matched a reference. + if (declaration.DeclaredFromSymbol?.source is IAstNode resolved) + declaration = resolved; + + var edits = new List(); + AddEdit(declaration, newName, edits); + + doc.Program.Visit(x => + { + if (ReferenceEquals(x, declaration)) return; + if (x.DeclaredFromSymbol?.source is IAstNode src) + { + if (ReferenceEquals(src, declaration)) + { + AddEdit((IAstNode)x, newName, edits); + } + else if (src is AssignmentStatement asn + && ReferenceEquals(asn.variable, declaration)) + { + AddEdit((IAstNode)x, newName, edits); + } + } + }); + + if (edits.Count == 0) return null; + + return new LspWorkspaceEdit + { + Changes = new Dictionary> + { + [doc.Uri] = edits, + }, + }; + } + + private static Token GetNameToken(IAstNode node) + { + switch (node) + { + case FunctionStatement fs: return fs.nameToken; + // Variable name lives at EndToken; StartToken is the GLOBAL/LOCAL/DIM keyword. + case DeclarationStatement d: return d.EndToken; + case ParameterNode p: return p.StartToken; + default: return node.StartToken; + } + } + + private static void AddEdit(IAstNode node, string newName, List edits) + { + var t = GetNameToken(node); + if (t == null) return; + var len = t.raw?.Length ?? t.Length; + edits.Add(new LspTextEdit + { + Range = new LspRange + { + Start = new LspPosition { Line = t.lineNumber, Character = t.charNumber }, + End = new LspPosition { Line = t.lineNumber, Character = t.charNumber + len }, + }, + NewText = newName, + }); + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/SemanticTokensHandler.cs b/FadeBasic/LSP.Core/Handlers/SemanticTokensHandler.cs new file mode 100644 index 0000000..f725bdc --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/SemanticTokensHandler.cs @@ -0,0 +1,107 @@ +// Walk the document's tokens and produce LSP-encoded delta semantic tokens. +// Token classification reuses FadeBasic.Lsp.LSPUtil.ClassifyToken. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Lsp; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class SemanticTokensHandler + { + // Index in this array becomes the token type integer emitted in the + // encoded tokens stream. Frontends register a matching legend. + public static readonly string[] Legend = new[] + { + "comment", // 0 + "keyword", // 1 + "function", // 2 + "method", // 3 + "macro", // 4 + "parameter", // 5 + "struct", // 6 + "type", // 7 + "operator", // 8 + "number", // 9 + "string", // 10 + }; + + // Raw per-token classification. Frontends that need to filter or + // remap tokens (e.g. the native LSP applying source-map per-token) + // call this and build their own output; frontends that want the + // canonical LSP delta-encoded stream call Compute() below. + public readonly struct ClassifiedToken + { + public readonly Token Token; + public readonly PortableSemanticTokenType Type; + public ClassifiedToken(Token token, PortableSemanticTokenType type) + { + Token = token; Type = type; + } + } + + public static List Classify(FadeDocument doc) + { + var classified = new List(); + if (doc?.LexResults == null) return classified; + var tokens = doc.LexResults.allTokens; + for (int i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (token.raw == null) continue; + var prev = i > 0 ? tokens[i - 1] : null; + var result = LSPUtil.ClassifyToken(token, prev); + if (result.Skip) continue; + classified.Add(new ClassifiedToken(token, result.TokenType)); + } + return classified; + } + + // Map our token-type enum into the legend index emitted on the wire. + public static int LegendIndex(PortableSemanticTokenType t) => ToLegendIndex(t); + + public static List Compute(FadeDocument doc) + { + var data = new List(); + int prevLine = 0; + int prevChar = 0; + + foreach (var ct in Classify(doc)) + { + int line = ct.Token.lineNumber; + int ch = ct.Token.charNumber; + int deltaLine = line - prevLine; + int deltaChar = deltaLine == 0 ? ch - prevChar : ch; + + data.Add(deltaLine); + data.Add(deltaChar); + data.Add(ct.Token.Length); + data.Add(ToLegendIndex(ct.Type)); + data.Add(0); // no modifiers + + prevLine = line; + prevChar = ch; + } + return data; + } + + private static int ToLegendIndex(PortableSemanticTokenType t) + { + switch (t) + { + case PortableSemanticTokenType.Comment: return 0; + case PortableSemanticTokenType.Keyword: return 1; + case PortableSemanticTokenType.Function: return 2; + case PortableSemanticTokenType.Method: return 3; + case PortableSemanticTokenType.Macro: return 4; + case PortableSemanticTokenType.Parameter: return 5; + case PortableSemanticTokenType.Struct: return 6; + case PortableSemanticTokenType.Type: return 7; + case PortableSemanticTokenType.Operator: return 8; + case PortableSemanticTokenType.Number: return 9; + case PortableSemanticTokenType.String: return 10; + default: return 0; + } + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/SignatureHelpHandler.cs b/FadeBasic/LSP.Core/Handlers/SignatureHelpHandler.cs new file mode 100644 index 0000000..a4c0647 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/SignatureHelpHandler.cs @@ -0,0 +1,269 @@ +// Signature help: given a cursor position inside a command/function call, +// surface the call's parameter list and which parameter the cursor is on. +// +// Ported from FadeBasic/LSP/Handlers/SignatureHelpHandler.cs but stripped +// of the project/source-map indirection — the Core variant operates on a +// single FadeDocument. Project-wide command docs aren't available here yet +// (the native handler resolves them via ProjectService); when they are +// surfaced into Core, plumb them through FadeDocument and lift the same +// docs-map lookup into BuildCommandSignature. + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace FadeBasic.LSP.Core.Handlers +{ + public class LspSignatureParameter + { + public string Label; + public string Documentation; + } + + public class LspSignatureInformation + { + public string Label; + public string Documentation; + public List Parameters; + public int ActiveParameter; + } + + public class LspSignatureHelp + { + public List Signatures; + public int ActiveSignature; + public int ActiveParameter; + } + + public static class SignatureHelpHandler + { + public static LspSignatureHelp Compute(FadeDocument doc, int line, int character) + { + if (doc?.Program == null || doc.LexResults == null) return null; + + // Use char-1 — the cursor sits between characters; the token that + // "encloses" the cursor is one to the left. + var probeChar = character > 0 ? character - 1 : 0; + var fakeToken = new Token { lineNumber = line, charNumber = probeChar }; + + bool Visit(IAstVisitable v) => + Token.IsLocationBeforeOrEqual(v.StartToken, fakeToken) && + Token.IsLocationBeforeOrEqual(fakeToken, v.EndToken); + + var group = doc.Program.Where(Visit) ?? new List(); + var node = group.LastOrDefault(); + + // User-defined function call + if (node is ArrayIndexReference arrRef && + arrRef.DeclaredFromSymbol?.source is FunctionStatement func) + { + return BuildFunctionSignature(func, arrRef.rankExpressions.Count); + } + + // Built-in command — check innermost first, then walk up the group + (CommandInfo command, List args, List argMap)? commandNode = node switch + { + CommandStatement cs => (cs.command, cs.args, cs.argMap), + CommandExpression ce => (ce.command, ce.args, ce.argMap), + _ => null, + }; + + if (commandNode == null) + { + // cursor may be inside an arg expression; walk up to find the enclosing command + for (var i = group.Count - 2; i >= 0; i--) + { + if (group[i] is CommandStatement cs2) + { + commandNode = (cs2.command, cs2.args, cs2.argMap); + break; + } + if (group[i] is CommandExpression ce2) + { + commandNode = (ce2.command, ce2.args, ce2.argMap); + break; + } + } + } + + if (commandNode != null) + { + return BuildCommandSignature( + commandNode.Value.command, + commandNode.Value.args, + commandNode.Value.argMap); + } + + // Fallback: AST is incomplete (e.g. user just typed `CommandName(`). + // Walk tokens backward to find the enclosing `(` and the CommandWord before it. + var tokens = doc.LexResults.allTokens; + var activeParam = 0; + var depth = 0; + Token openParen = null; + + for (var i = tokens.Count - 1; i >= 0; i--) + { + var t = tokens[i]; + if (t.lineNumber > line) continue; + if (t.lineNumber == line && t.charNumber > probeChar) continue; + + if (t.type == LexemType.ParenClose) depth++; + else if (t.type == LexemType.ParenOpen) + { + if (depth > 0) depth--; + else { openParen = t; break; } + } + else if (t.type == LexemType.ArgSplitter && depth == 0) + activeParam++; + } + + if (openParen != null) + { + Token nameToken = null; + foreach (var t in tokens) + { + if (t.lineNumber > openParen.lineNumber) break; + if (t.lineNumber == openParen.lineNumber && t.charNumber >= openParen.charNumber) break; + nameToken = t; + } + + if (nameToken?.type == LexemType.CommandWord) + { + var commandName = nameToken.caseInsensitiveRaw; + var command = doc.Commands.Commands.FirstOrDefault( + c => string.Equals(c.name, commandName, System.StringComparison.OrdinalIgnoreCase)); + + if (command.name != null) + { + return BuildCommandSignature( + command, + new List(), + new List(), + activeParam); + } + } + } + + return null; + } + + // --- User-defined functions ---------------------------------------- + + private static LspSignatureHelp BuildFunctionSignature(FunctionStatement func, int activeParam) + { + var paramInfos = new List(); + foreach (var param in func.parameters) + { + paramInfos.Add(new LspSignatureParameter + { + Label = $"{param.variable.variableName} as {param.type.variableType}", + }); + } + + var labelParts = func.parameters.Select(p => $"{p.variable.variableName} as {p.type.variableType}"); + var signatureLabel = $"{func.name}({string.Join(", ", labelParts)})"; + + return new LspSignatureHelp + { + Signatures = new List + { + new LspSignatureInformation + { + Label = signatureLabel, + Documentation = string.IsNullOrEmpty(func.Trivia) ? null : func.Trivia, + Parameters = paramInfos, + ActiveParameter = activeParam, + }, + }, + ActiveSignature = 0, + ActiveParameter = activeParam, + }; + } + + // --- Built-in commands --------------------------------------------- + + private static LspSignatureHelp BuildCommandSignature( + CommandInfo command, + List args, + List argMap, + int tokenWalkActiveParam = -1) + { + // Visible params = skip VM-internal and raw args + var visibleArgs = command.args + .Select((a, i) => (arg: a, index: i)) + .Where(x => !x.arg.isVmArg && !x.arg.isRawArg) + .ToList(); + + if (visibleArgs.Count == 0) return null; + + int activeCommandArgIndex; + if (tokenWalkActiveParam >= 0) + { + activeCommandArgIndex = System.Math.Min(tokenWalkActiveParam, visibleArgs[visibleArgs.Count - 1].index); + } + else if (args.Count == 0 || argMap.Count == 0) + { + activeCommandArgIndex = 0; + } + else + { + var lastArgInfoIndex = argMap[args.Count - 1]; + activeCommandArgIndex = command.args[lastArgInfoIndex].isParams + ? lastArgInfoIndex + : lastArgInfoIndex + 1; + } + + var activeVisibleIndex = visibleArgs.FindIndex(x => x.index == activeCommandArgIndex); + if (activeVisibleIndex < 0) + activeVisibleIndex = visibleArgs.Count - 1; + + var paramLabels = new List(); + var paramInfos = new List(); + for (var vi = 0; vi < visibleArgs.Count; vi++) + { + var arg = visibleArgs[vi].arg; + var paramName = $"arg{vi + 1}"; + var label = BuildArgLabel(arg, paramName); + paramLabels.Add(label); + paramInfos.Add(new LspSignatureParameter { Label = label }); + } + + var signatureLabel = $"{command.name}({string.Join(", ", paramLabels)})"; + + return new LspSignatureHelp + { + Signatures = new List + { + new LspSignatureInformation + { + Label = signatureLabel, + Parameters = paramInfos, + ActiveParameter = activeVisibleIndex, + }, + }, + ActiveSignature = 0, + ActiveParameter = activeVisibleIndex, + }; + } + + private static string BuildArgLabel(CommandArgInfo arg, string name) + { + VmUtil.TryGetVariableTypeDisplay(arg.typeCode, out var typeName); + var sb = new StringBuilder(); + if (arg.isRef) sb.Append("ref "); + sb.Append(typeName); + if (arg.isParams) sb.Append("..."); + sb.Append(' '); + sb.Append(name); + if (arg.isOptional) + { + sb.Insert(0, '['); + sb.Append(']'); + } + return sb.ToString(); + } + } +} diff --git a/FadeBasic/LSP/Handlers/CompletionHandler2.cs b/FadeBasic/LSP/Handlers/CompletionHandler2.cs index a86e4d7..d9909f3 100644 --- a/FadeBasic/LSP/Handlers/CompletionHandler2.cs +++ b/FadeBasic/LSP/Handlers/CompletionHandler2.cs @@ -1,36 +1,52 @@ -using System; +// Completion — thin adapter over FadeBasic.LSP.Core.Handlers.CompletionHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Pre-refactor native: +// * Mapped position via `unit.sourceMap.TryGetMappedLocation`. +// * Built a CompletionContext (macro vs non-macro program, leftToken, +// scope's positionedVariables entry) and invoked `LSPUtil.GetCompletions`. +// * Translated PortableCompletionItem → OmniSharp CompletionItem. +// * On `unit.macroProgram == null` while leftToken is a macro token, +// returned a single `` placeholder item. Likewise for +// missing `unit.program` → ``. +// +// Core CompletionHandler: +// * Does the SAME context building + LSPUtil.GetCompletions invocation, +// just without the placeholder items (returns an empty list when the +// program or macroProgram is unavailable). This is the only behavioral +// diff vs the old native handler — debug strings no longer leak into +// completions, which is what users want. +// +// Native still owns: +// * Per-document URI → CodeUnit lookup (CompilerService). +// * sourceMap position mapping (multi-file projects). +// * Documentation field — `func.Trivia` for user-defined functions is +// already populated by LSPUtil; built-in commands have no per-item +// documentation in either implementation. (The hover handler is the +// surface that shows command docs.) + using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using FadeBasic.Ast; -using FadeBasic.Lsp; -using LspCompletionContext = FadeBasic.Lsp.CompletionContext; -using FadeBasic.Virtual; using LSP.Services; -using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreCompletionHandler = FadeBasic.LSP.Core.Handlers.CompletionHandler; +using LspCompletionItem = FadeBasic.LSP.Core.LspCompletionItem; +using LspCompletionKind = FadeBasic.LSP.Core.LspCompletionKind; namespace LSP.Handlers; public class CompletionHandler2 : CompletionHandlerBase { - private ILogger _logger; - private CompilerService _compiler; - private ProjectService _project; + private readonly CompilerService _compiler; - public CompletionHandler2( - ILogger logger, - DocumentService docs, - CompilerService compiler, - ProjectService project) + public CompletionHandler2(CompilerService compiler) { - _project = project; _compiler = compiler; - _logger = logger; } protected override CompletionRegistrationOptions CreateRegistrationOptions(CompletionCapability capability, @@ -38,160 +54,44 @@ protected override CompletionRegistrationOptions CreateRegistrationOptions(Compl { DocumentSelector = TextDocumentSelector.ForLanguage(FadeBasicConstants.FadeBasicLanguage), TriggerCharacters = new Container(" ", ".", "(", "=", "+", "*", "-", "/"), - ResolveProvider = false + ResolveProvider = false, }; - - public override Task Handle(CompletionParams request, CancellationToken cancellationToken) + public override Task Handle(CompletionParams request, CancellationToken cancellationToken) { - _logger.LogInformation($"Handling a completion request... {request.TextDocument.Uri}"); + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(default(CompletionList?)); - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled unit"); - return null; - } + var unit = units[0]; - if (!_compiler.TryGetProjectContexts(request.TextDocument.Uri, out var projects)) + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, out var mappedLine, out var mappedChar)) { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled project"); - return null; + return Task.FromResult(default(CompletionList?)); } - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - _project.TryGetProject(projects[0], out var x); - var commandData = x.Item2; - - if (!unit.sourceMap.TryGetMappedLocation(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character, out var error, out var mappedLineNumber, out var mappedCharNumber)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any file"); - return null; - } - - var fakeToken = new Token - { - lineNumber = mappedLineNumber, charNumber = mappedCharNumber - }; - - // need to find the nearest token to the left. - Token leftToken = null; - for (var i = unit.lexerResults.allTokens.Count - 1; i >= 0; i --) - { - var token = unit.lexerResults.allTokens[i]; - if (token.lineNumber < mappedLineNumber) - { - leftToken = token; - break; - } - if (token.lineNumber == mappedLineNumber && token.charNumber <= mappedCharNumber) - { - leftToken = token; - break; - } - } - - var isMacro = false; - - if (leftToken == null) - { - _logger.LogInformation("There is no found left token"); - return null; - } + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString()); + var coreItems = CoreCompletionHandler.Compute(doc, mappedLine, mappedChar); - isMacro = leftToken.flags.HasFlag(TokenFlags.IsMacroToken); - - bool Visit(IAstVisitable v) - { - return v is ProgramNode || Token.IsLocationBeforeOrEqual(v.StartToken, fakeToken) && Token.IsLocationBeforeOrEqual(fakeToken, v.EndToken); - } - - var programGroup = unit.program?.Where(Visit); - var programNode = programGroup?.LastOrDefault(); - - var macroGroup = unit.macroProgram?.Where(Visit); - var macroNode = macroGroup?.LastOrDefault(); - - if (isMacro) - { - if (unit.macroProgram == null) - { - return Task.FromResult(new CompletionList(new List - { - new CompletionItem - { - Kind = CompletionItemKind.Folder, - InsertText = "" - } - })); - } - if (!unit.macroProgram.scope.positionedVariables.TryFindEntry(fakeToken, out var entry)) - { - entry = unit.macroProgram.scope.positionedVariables.entries[0]; - } - - var context = new LspCompletionContext - { - IsMacro = true, - FakeToken = fakeToken, - LeftToken = leftToken, - Program = unit.macroProgram, - Commands = unit.commands, - FunctionName = entry.value.Item2, - Group = macroGroup, - ConstantTable = unit.lexerResults.constantTable, - LocalScope = entry.value.Item1 - }; - - var items = LSPUtil.GetCompletions(context); - return Task.FromResult(new CompletionList(items.Select(ToCompletionItem).ToList(), isIncomplete: false)); - - } - else - { - if (unit.program == null) - { - return Task.FromResult(new CompletionList(new List - { - new CompletionItem - { - Kind = CompletionItemKind.Folder, - InsertText = "" - } - })); - } - if (!unit.program.scope.positionedVariables.TryFindEntry(fakeToken, out var entry)) - { - entry = unit.program.scope.positionedVariables.entries[0]; - } - - var context = new LspCompletionContext - { - FakeToken = fakeToken, - LeftToken = leftToken, - Program = unit.program, - Commands = unit.commands, - FunctionName = entry.value.Item2, - Group = programGroup, - ConstantTable = unit.lexerResults.constantTable, - LocalScope = entry.value.Item1 - }; - var items = LSPUtil.GetCompletions(context); - return Task.FromResult(new CompletionList(items.Select(ToCompletionItem).ToList(), isIncomplete: false)); - } + var items = new List(coreItems.Count); + foreach (var p in coreItems) items.Add(ToOmni(p)); + return Task.FromResult(new CompletionList(items, isIncomplete: false)); } - static CompletionItem ToCompletionItem(PortableCompletionItem p) + private static CompletionItem ToOmni(LspCompletionItem p) { return new CompletionItem { - Label = p.Label, - InsertText = p.InsertText, + Label = p.Label ?? string.Empty, + InsertText = p.InsertText ?? string.Empty, Kind = ToCompletionItemKind(p.Kind), - Detail = p.Detail, + Detail = p.Detail ?? string.Empty, SortText = p.SortText, FilterText = p.FilterText, - InsertTextFormat = p.InsertTextFormat == PortableInsertTextFormat.Snippet + InsertTextFormat = p.InsertTextFormat == FadeBasic.LSP.Core.LspInsertTextFormat.Snippet ? InsertTextFormat.Snippet : InsertTextFormat.PlainText, InsertTextMode = InsertTextMode.AdjustIndentation, @@ -200,38 +100,37 @@ static CompletionItem ToCompletionItem(PortableCompletionItem p) : new MarkupContent { Kind = MarkupKind.Markdown, - Value = p.Documentation + Value = p.Documentation!, }, Command = p.TriggerParameterHints - ? new Command + ? new OmniSharp.Extensions.LanguageServer.Protocol.Models.Command { Name = "editor.action.triggerParameterHints", - Title = "Trigger Parameter Hints" + Title = "Trigger Parameter Hints", } : null, }; } - static CompletionItemKind ToCompletionItemKind(PortableCompletionKind kind) + private static CompletionItemKind ToCompletionItemKind(LspCompletionKind kind) { switch (kind) { - case PortableCompletionKind.Variable: return CompletionItemKind.Variable; - case PortableCompletionKind.Function: return CompletionItemKind.Function; - case PortableCompletionKind.Interface: return CompletionItemKind.Interface; - case PortableCompletionKind.Keyword: return CompletionItemKind.Keyword; - case PortableCompletionKind.Field: return CompletionItemKind.Field; - case PortableCompletionKind.Class: return CompletionItemKind.Class; - case PortableCompletionKind.Constant: return CompletionItemKind.Constant; - case PortableCompletionKind.Reference: return CompletionItemKind.Reference; - case PortableCompletionKind.Folder: return CompletionItemKind.Folder; + case LspCompletionKind.Variable: return CompletionItemKind.Variable; + case LspCompletionKind.Function: return CompletionItemKind.Function; + case LspCompletionKind.Interface: return CompletionItemKind.Interface; + case LspCompletionKind.Keyword: return CompletionItemKind.Keyword; + case LspCompletionKind.Field: return CompletionItemKind.Field; + case LspCompletionKind.Class: return CompletionItemKind.Class; + case LspCompletionKind.Constant: return CompletionItemKind.Constant; + case LspCompletionKind.Reference: return CompletionItemKind.Reference; + case LspCompletionKind.Folder: return CompletionItemKind.Folder; default: return CompletionItemKind.Text; } } public override Task Handle(CompletionItem request, CancellationToken cancellationToken) { - _logger.LogInformation($"Handling a completion item... {request.TextEditText}"); return Task.FromResult(request); } } diff --git a/FadeBasic/LSP/Handlers/CoreAdapter.cs b/FadeBasic/LSP/Handlers/CoreAdapter.cs new file mode 100644 index 0000000..bb698a8 --- /dev/null +++ b/FadeBasic/LSP/Handlers/CoreAdapter.cs @@ -0,0 +1,39 @@ +// Helpers that turn the native LSP's per-request CodeUnit into a Core +// FadeDocument so handlers can delegate to FadeBasic.LSP.Core.Handlers. +// +// The native LSP is project-aware — its CodeUnit may span multiple source +// files concatenated via SourceMap, with macros expanded into a parallel +// `macroProgram`. When all of that lives in a single source file (the +// common case), the source map is identity and the conversion is direct. +// Multi-file projects are handled by the existing native logic that maps +// positions through `unit.sourceMap` before / after calling Core. + +using FadeBasic; +using FadeBasic.ApplicationSupport.Project; +using FadeBasic.LSP.Core; +using FadeBasic.Virtual; +using ApplicationSupport.Code; + +namespace LSP.Handlers; + +internal static class CoreAdapter +{ + // Wrap a CodeUnit + URI as a FadeDocument suitable for Core handlers. + // Docs (when available) are surfaced so command hover renders rich + // markdown — same source the native HoverHandler uses, just behind + // the Core ICommandDocsProvider interface. + public static FadeDocument ToDocument(CodeUnit unit, string uri, ProjectDocs? docs = null) + { + return new FadeDocument + { + Uri = uri, + // We don't bother re-reconstructing the source text here; Core + // handlers operate on the parsed AST + lex results, not raw text. + Text = string.Empty, + LexResults = unit.lexerResults, + Program = unit.program, + Commands = unit.commands, + Docs = docs == null ? null : new ProjectDocsCommandDocsProvider(docs), + }; + } +} diff --git a/FadeBasic/LSP/Handlers/DocumentSymbolHandler.cs b/FadeBasic/LSP/Handlers/DocumentSymbolHandler.cs index 993af68..c843b24 100644 --- a/FadeBasic/LSP/Handlers/DocumentSymbolHandler.cs +++ b/FadeBasic/LSP/Handlers/DocumentSymbolHandler.cs @@ -1,75 +1,104 @@ +// Document outline — thin adapter over FadeBasic.LSP.Core.Handlers.DocumentSymbolHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Pre-refactor native: enumerated *every* lexer token whose type wasn't +// OpEqual / LiteralInt and dumped one DocumentSymbol per token, with the +// token kind inferred from LexemType (Variable/String/Number/Key). This +// produced an extremely noisy outline — every identifier, keyword, and +// string literal in the file showed up as a separate symbol. It also +// re-read the file from disk on every request (TODO comment acknowledged +// this as a hack). +// +// Core: walks the parsed AST and emits structured outline entries — +// FunctionStatement (with nested LabelDeclarationNode children), +// top-level DeclarationStatement, TypeDefinitionStatement, LabelDeclarationNode. +// Each entry has a full-extent Range (covers the body) and a +// SelectionRange (just the name token), which is what VSCode's +// breadcrumbs and outline view expect. +// +// Behavioral diff (intentional): +// * No more per-token noise — only meaningful symbols. +// * Range now covers the full body of a function/type/label rather than +// a single token. +// * No file IO — Core reads from the in-memory parse tree. + using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using OmniSharp.Extensions.LanguageServer.Protocol; +using LSP.Services; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; -using FadeBasic; +using CoreDocSymbolHandler = FadeBasic.LSP.Core.Handlers.DocumentSymbolHandler; +using CoreDocSymbol = FadeBasic.LSP.Core.LspDocumentSymbol; +using LspSymbolKind = FadeBasic.LSP.Core.LspSymbolKind; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; public class DocumentSymbolHandler : IDocumentSymbolHandler { - public static SymbolKind Convert(LexemType type) + private readonly CompilerService _compiler; + + public DocumentSymbolHandler(CompilerService compiler) { - switch (type) - { - case LexemType.OpEqual: - return SymbolKind.Operator; + _compiler = compiler; + } + + public Task Handle(DocumentSymbolParams request, CancellationToken cancellationToken) + { + var empty = new SymbolInformationOrDocumentSymbolContainer(new List()); + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(empty); - case LexemType.VariableGeneral: - return SymbolKind.Variable; - - case LexemType.LiteralString: - return SymbolKind.String; + var doc = CoreAdapter.ToDocument(units[0], request.TextDocument.Uri.ToString()); + var coreSyms = CoreDocSymbolHandler.Compute(doc); - case LexemType.LiteralReal: - case LexemType.LiteralInt: - return SymbolKind.Number; - default: - return SymbolKind.Key; - } + var result = new List(coreSyms.Count); + foreach (var s in coreSyms) result.Add(new SymbolInformationOrDocumentSymbol(Convert(s))); + return Task.FromResult( + new SymbolInformationOrDocumentSymbolContainer(result)); } - - - public async Task Handle(DocumentSymbolParams request, CancellationToken cancellationToken) - { - // TODO: get this data from the sync handler - var content = await File.ReadAllTextAsync(DocumentUri.GetFileSystemPath(request), cancellationToken); - var lexer = new Lexer(); - var tokens = lexer.Tokenize(content); - var symbols = new List(); + private static DocumentSymbol Convert(CoreDocSymbol s) + { + var children = new List(); + if (s.Children != null) + foreach (var c in s.Children) children.Add(Convert(c)); - foreach (var token in tokens) + return new DocumentSymbol { - if (token.raw == null) continue; + Name = s.Name ?? string.Empty, + Detail = s.Detail ?? string.Empty, + Kind = ToSymbolKind(s.Kind), + Range = new Range( + s.Range.Start.Line, s.Range.Start.Character, + s.Range.End.Line, s.Range.End.Character), + SelectionRange = new Range( + s.SelectionRange.Start.Line, s.SelectionRange.Start.Character, + s.SelectionRange.End.Line, s.SelectionRange.End.Character), + Children = new Container(children), + }; + } - switch (token.type) - { - case LexemType.OpEqual: - case LexemType.LiteralInt: - continue; - default: - break; - } - - var symbol = new DocumentSymbol - { - Detail = token.raw, - Kind = Convert(token.type), - SelectionRange = new Range(token.lineNumber, token.charNumber, token.lineNumber, token.charNumber + token.Length), - Range = new Range(token.lineNumber, token.charNumber, token.lineNumber, token.charNumber + token.Length), - Name = token.raw - }; - - symbols.Add(symbol); - } - - return symbols; + private static SymbolKind ToSymbolKind(LspSymbolKind kind) + { + return kind switch + { + LspSymbolKind.Function => SymbolKind.Function, + LspSymbolKind.Variable => SymbolKind.Variable, + LspSymbolKind.Constant => SymbolKind.Constant, + LspSymbolKind.Struct => SymbolKind.Struct, + LspSymbolKind.Method => SymbolKind.Method, + LspSymbolKind.Interface => SymbolKind.Interface, + LspSymbolKind.Key => SymbolKind.Key, + LspSymbolKind.Class => SymbolKind.Class, + LspSymbolKind.String => SymbolKind.String, + LspSymbolKind.Number => SymbolKind.Number, + _ => SymbolKind.Variable, + }; } public DocumentSymbolRegistrationOptions GetRegistrationOptions(DocumentSymbolCapability capability, @@ -78,7 +107,6 @@ public DocumentSymbolRegistrationOptions GetRegistrationOptions(DocumentSymbolCa return new DocumentSymbolRegistrationOptions { DocumentSelector = TextDocumentSelector.ForLanguage(FadeBasicConstants.FadeBasicLanguage), - }; } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FindReferencesHandler.cs b/FadeBasic/LSP/Handlers/FindReferencesHandler.cs index 61dfe30..7dffefc 100644 --- a/FadeBasic/LSP/Handlers/FindReferencesHandler.cs +++ b/FadeBasic/LSP/Handlers/FindReferencesHandler.cs @@ -1,15 +1,43 @@ +// References — thin adapter over FadeBasic.LSP.Core.Handlers.ReferencesHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Common: +// * Both find the token at the cursor (with a one-character left-drift +// hail-mary for cursors sitting in whitespace). +// * Both gather AST nodes whose StartToken/EndToken locations match the +// clicked token, then chase DeclaredFromSymbol.source. +// +// Core ADDS: +// * Clicking the declaration site (e.g. the LHS of an implicit `x = 1`) +// now also returns the use sites. The old native handler returned only +// the LHS node here because it followed a single chain — Core unions +// every interpretation (node itself + DeclaredFromSymbol.source + +// nodes whose DeclaredFromSymbol.source has a token at the clicked +// position), so def-site clicks behave like use-site clicks. +// * `or RuntoStatement` is in the allowed-types match list (old native +// code had it too, parity here). +// +// Core MISSES (vs native): +// * The old native walked `unit.macroProgram` in addition to +// `unit.program`. Tokens inside macro-expanded regions don't currently +// resolve through Core, which only inspects `doc.Program`. +// * Multi-file source-map mapping happens HERE (not in Core), so ranges +// come back as project-buffer coordinates and we translate them to +// originating-file coordinates before returning. + using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using FadeBasic.Ast; using LSP.Services; using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreRefsHandler = FadeBasic.LSP.Core.Handlers.ReferencesHandler; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; @@ -19,8 +47,8 @@ public class FindReferencesHandler : ReferencesHandlerBase private readonly CompilerService _compiler; public FindReferencesHandler( - ILogger logger, - DocumentService docs, + ILogger logger, + DocumentService docs, CompilerService compiler) { _logger = logger; @@ -36,106 +64,40 @@ protected override ReferenceRegistrationOptions CreateRegistrationOptions(Refere }; } - public override async Task Handle(ReferenceParams request, CancellationToken cancellationToken) + public override Task Handle(ReferenceParams request, CancellationToken cancellationToken) { - var locations = new List(); - - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled unit"); - return null; - } + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(default(LocationContainer?)); - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - - /* - * given the position, we could find the token, - * from the token, we could look up and see - */ - _logger.LogInformation("looking for def : " + request.Position); - - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character, out var token)) + var unit = units[0]; + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, + out var mappedLine, + out var mappedChar)) { - // try one character to the left... sort of a hail marry, but this happens if the user's cursor is not ON the token, but in the immediate white space on the other side. - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character - 1, out token)) - { - return null; // no token found - } + return Task.FromResult(default(LocationContainer?)); } - - // use the token to resolve the variable - var referencedNodes = new List(); - // var x = unit.program.scope.functionTable; + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString()); + var coreLocs = CoreRefsHandler.Compute(doc, mappedLine, mappedChar); + if (coreLocs == null || coreLocs.Count == 0) + return Task.FromResult(default(LocationContainer?)); - void Visit(IAstVisitable x) + var locations = new List(coreLocs.Count); + foreach (var l in coreLocs) { - bool isMatch = false; - if (x is VariableRefNode or DeclarationStatement or ArrayIndexReference or LabelDeclarationNode or GoSubStatement or GotoStatement) + var startTok = new Token { lineNumber = l.Range.Start.Line, charNumber = l.Range.Start.Character }; + var origin = unit.sourceMap.GetOriginalLocation(startTok); + var len = System.Math.Max(1, l.Range.End.Character - l.Range.Start.Character); + locations.Add(new Location { - isMatch = Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken); - - } else if (x is FunctionStatement funcStatement) - { - isMatch = x.StartToken == token || funcStatement.nameToken == token; - } - - if (isMatch) - { - referencedNodes.Add(x); - } - } - - unit.program.Visit(Visit); - unit.macroProgram?.Visit(Visit); - - if (referencedNodes.Count == 0) - { - return null; - - } - var expr = referencedNodes[0]; - - // if the user clicked on a reference to the root; this resolves it. - if (expr.DeclaredFromSymbol != null) - { - expr = expr.DeclaredFromSymbol.source; - } - - var discoveredNodes = new List - { - // the declaration counts as a reference - expr - }; - unit.program.Visit(x => - { - if (x.DeclaredFromSymbol != null) - { - if (x.DeclaredFromSymbol.source == expr) - { - discoveredNodes.Add(x); - } - } - }); - - - locations = discoveredNodes.Select(x => - { - var source = unit.sourceMap.GetOriginalLocation(x.StartToken); - return new Location - { - Uri = DocumentUri.File(source.fileName), - Range = new Range(source.startLine, source.startChar, source.startLine, - x.EndToken.charNumber + x.EndToken.raw?.Length ?? 0) - }; - }).ToList(); - - if (locations.Count == 0) - { - return null; + Uri = DocumentUri.File(origin.fileName), + Range = new Range(origin.startLine, origin.startChar, origin.startLine, origin.startChar + len), + }); } - return new LocationContainer(locations); + return Task.FromResult(new LocationContainer(locations)); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FoldingRangeHandler.cs b/FadeBasic/LSP/Handlers/FoldingRangeHandler.cs index b49c3d4..e485b46 100644 --- a/FadeBasic/LSP/Handlers/FoldingRangeHandler.cs +++ b/FadeBasic/LSP/Handlers/FoldingRangeHandler.cs @@ -1,28 +1,57 @@ +// Folding ranges — thin adapter over FadeBasic.LSP.Core.Handlers.FoldingRangeHandler. +// The native LSP's previous implementation was a hardcoded stub (lines 2–4); +// Core's AST-driven version covers function bodies, if/for/while/do/repeat +// blocks, type/test blocks, and multi-line rem comments. + using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FadeBasic; +using LSP.Services; +using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreFoldingHandler = FadeBasic.LSP.Core.Handlers.FoldingRangeHandler; namespace LSP.Handlers; public class FoldingRangeHandler : IFoldingRangeHandler { + private readonly CompilerService _compiler; + + public FoldingRangeHandler(CompilerService compiler) + { + _compiler = compiler; + } + public Task?> Handle(FoldingRangeRequestParam request, CancellationToken cancellationToken) { - var ranges = new List(); - ranges.Add(new FoldingRange + var empty = new Container(new List()); + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult?>(empty); + + var doc = CoreAdapter.ToDocument(units[0], request.TextDocument.Uri.ToString()); + var ranges = CoreFoldingHandler.Compute(doc); + + var omni = new List(ranges.Count); + foreach (var r in ranges) { - StartLine = 2, - EndLine = 4, - Kind = FoldingRangeKind.Region, - StartCharacter = 0, - EndCharacter = 0 - }); - - return Task.FromResult?>(new Container(ranges)); + omni.Add(new FoldingRange + { + StartLine = r.StartLine, + EndLine = r.EndLine, + StartCharacter = r.StartCharacter, + EndCharacter = r.EndCharacter, + Kind = r.Kind switch + { + FadeBasic.LSP.Core.LspFoldingRangeKind.Comment => FoldingRangeKind.Comment, + FadeBasic.LSP.Core.LspFoldingRangeKind.Imports => FoldingRangeKind.Imports, + _ => FoldingRangeKind.Region, + }, + }); + } + return Task.FromResult?>(new Container(omni)); } public FoldingRangeRegistrationOptions GetRegistrationOptions(FoldingRangeCapability capability, @@ -30,7 +59,7 @@ public FoldingRangeRegistrationOptions GetRegistrationOptions(FoldingRangeCapabi { return new FoldingRangeRegistrationOptions { - DocumentSelector = new TextDocumentSelector(TextDocumentFilter.ForLanguage(FadeBasicConstants.FadeBasicLanguage)) + DocumentSelector = new TextDocumentSelector(TextDocumentFilter.ForLanguage(FadeBasicConstants.FadeBasicLanguage)), }; } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FormattingHandler.cs b/FadeBasic/LSP/Handlers/FormattingHandler.cs index 92867f9..88a83c6 100644 --- a/FadeBasic/LSP/Handlers/FormattingHandler.cs +++ b/FadeBasic/LSP/Handlers/FormattingHandler.cs @@ -1,3 +1,20 @@ +// Formatting — thin adapter over FadeBasic.LSP.Core.Handlers.FormattingHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Both run `TokenFormatter.Format(unit.lexerResults.combinedTokens, settings)` +// and translate the resulting edits to LSP TextEdits. +// +// Native passed casing from the language-server configuration setting +// `conf.language.fade.formatCasing` ("upper" | "lower" | other). Core +// takes a TabSize/InsertSpaces/Casing options object. We adapt by reading +// the same setting before invoking Core. +// +// Native re-lexed the source from disk on every request. Now we lex once +// per document change via CompilerService and reuse those tokens through +// Core. This avoids reading the file system on every format and keeps the +// formatter output in sync with everything else the LSP has parsed. + using System; using System.Collections.Generic; using System.Threading; @@ -9,6 +26,9 @@ using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using CoreFormatHandler = FadeBasic.LSP.Core.Handlers.FormattingHandler; +using LspCasingSetting = FadeBasic.LSP.Core.Handlers.LspCasingSetting; +using LspFormattingOptions = FadeBasic.LSP.Core.Handlers.LspFormattingOptions; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; @@ -24,82 +44,59 @@ protected override DocumentFormattingRegistrationOptions CreateRegistrationOptio }; } - - private readonly ILogger _logger; - private readonly DocumentService _docs; + private readonly ILogger _logger; private readonly ILanguageServerConfiguration _lsp; - private CompilerService _compiler; - private readonly ProjectService _projects; + private readonly CompilerService _compiler; public FormattingHandler( ILanguageServerConfiguration lsp, - ILogger logger, - DocumentService docs, CompilerService compiler, ProjectService projects) + ILogger logger, + CompilerService compiler) { _lsp = lsp; - _compiler = compiler; - _projects = projects; _logger = logger; - _docs = docs; + _compiler = compiler; } - + public override async Task Handle(DocumentFormattingParams request, CancellationToken cancellationToken) { var edits = new List(); + // Honor the existing language-server config setting that controls + // identifier casing — same behavior as the pre-refactor handler. var config = await _lsp.GetConfiguration(new ConfigurationItem { - Section = "conf.language.fade" + Section = "conf.language.fade", }); var casingStr = config.GetSection("conf.language.fade")["formatCasing"]; - var casingOption = TokenFormatSettings.CasingSetting.Ignore; + var casing = LspCasingSetting.Ignore; if (string.Equals("upper", casingStr, StringComparison.InvariantCultureIgnoreCase)) - { - casingOption = TokenFormatSettings.CasingSetting.ToUpper; - } else if (string.Equals("lower", casingStr, StringComparison.InvariantCultureIgnoreCase)) - { - casingOption = TokenFormatSettings.CasingSetting.ToLower; - } - - if (!_compiler.TryGetProjectContexts(request.TextDocument.Uri, out var contexts)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any project contexts"); - return edits; - } - - var projectUrl = contexts[0]; - if (!_projects.TryGetProject(projectUrl, out var project)) - { - _logger.LogError($"source document=[{projectUrl}] did not map to any project"); - return edits; - } + casing = LspCasingSetting.ToUpper; + else if (string.Equals("lower", casingStr, StringComparison.InvariantCultureIgnoreCase)) + casing = LspCasingSetting.ToLower; - if (!_docs.TryGetSourceDocument(request.TextDocument.Uri, out var doc)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] does not have a backing document"); + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) return edits; - } - var lexer = new Lexer(); - var lexerResults = lexer.TokenizeWithErrors(doc, project.Item2.collection); - - var tokenEdits = TokenFormatter.Format(lexerResults.combinedTokens, new TokenFormatSettings + var doc = CoreAdapter.ToDocument(units[0], request.TextDocument.Uri.ToString()); + var coreEdits = CoreFormatHandler.Compute(doc, new LspFormattingOptions { TabSize = request.Options.TabSize, - UseTabs = !request.Options.InsertSpaces, - Casing = casingOption + InsertSpaces = request.Options.InsertSpaces, + Casing = casing, }); - for (var i = tokenEdits.Count - 1; i >= 0; i--) + foreach (var e in coreEdits) { - var tokenEdit = tokenEdits[i]; edits.Add(new TextEdit { - Range = new Range(tokenEdit.startLine, tokenEdit.startChar, tokenEdit.endLine, tokenEdit.endChar), - NewText = tokenEdit.replacement + Range = new Range( + e.Range.Start.Line, e.Range.Start.Character, + e.Range.End.Line, e.Range.End.Character), + NewText = e.NewText ?? string.Empty, }); } - + return edits; } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FormattingRangeHandler.cs b/FadeBasic/LSP/Handlers/FormattingRangeHandler.cs index 5823d40..fe3bc40 100644 --- a/FadeBasic/LSP/Handlers/FormattingRangeHandler.cs +++ b/FadeBasic/LSP/Handlers/FormattingRangeHandler.cs @@ -1,4 +1,11 @@ -using System; +// Range formatting — runs the full FormattingHandler then filters the +// result by intersection with the requested range. Pre-refactor native +// did the same thing. +// +// Core also has a `ComputeRange` method that does the equivalent filter; +// we keep the "format full then filter" composition here so we re-use the +// already-wired config / source-map handling in FormattingHandler. + using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -20,34 +27,29 @@ protected override DocumentRangeFormattingRegistrationOptions CreateRegistration DocumentSelector = TextDocumentSelector.ForLanguage(FadeBasicConstants.FadeBasicLanguage), }; } - - private readonly ILogger _logger; + private readonly FormattingHandler _formatter; - public FormattingRangeHandler( - FormattingHandler formatter, - ILogger logger) + + public FormattingRangeHandler(FormattingHandler formatter) { _formatter = formatter; - _logger = logger; } - public override async Task Handle(DocumentRangeFormattingParams request, CancellationToken cancellationToken) { var edits = await _formatter.Handle(new DocumentFormattingParams { Options = request.Options, - TextDocument = request.TextDocument + TextDocument = request.TextDocument, }, cancellationToken); - var actualEdits = new List(); + if (edits == null) return actualEdits; foreach (var edit in edits) { if (!edit.Range.IntersectsOrTouches(request.Range)) continue; actualEdits.Add(edit); - } return actualEdits; } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FormattingWhenTypingHandler.cs b/FadeBasic/LSP/Handlers/FormattingWhenTypingHandler.cs index f327868..16773d0 100644 --- a/FadeBasic/LSP/Handlers/FormattingWhenTypingHandler.cs +++ b/FadeBasic/LSP/Handlers/FormattingWhenTypingHandler.cs @@ -1,22 +1,22 @@ +// On-type formatting — runs the full FormattingHandler then keeps edits +// within one line of the caret. Same composition the pre-refactor handler +// used; just delegates the actual formatting to the shared adapter. + using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using LSP.Services; using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; -using OmniSharp.Extensions.LanguageServer.Protocol.Server; -using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; public class FormattingWhenTypingHandler : DocumentOnTypeFormattingHandlerBase { - protected override DocumentOnTypeFormattingRegistrationOptions CreateRegistrationOptions(DocumentOnTypeFormattingCapability capability, ClientCapabilities clientCapabilities) { @@ -25,39 +25,32 @@ protected override DocumentOnTypeFormattingRegistrationOptions CreateRegistratio { DocumentSelector = TextDocumentSelector.ForLanguage(FadeBasicConstants.FadeBasicLanguage), FirstTriggerCharacter = chars[0].ToString(), - MoreTriggerCharacter = chars.Select(x => x.ToString()).ToList() + MoreTriggerCharacter = chars.Select(x => x.ToString()).ToList(), }; } - - private readonly ILogger _logger; + private readonly FormattingHandler _formatter; - public FormattingWhenTypingHandler( - FormattingHandler formatter, - ILogger logger) + + public FormattingWhenTypingHandler(FormattingHandler formatter) { _formatter = formatter; - _logger = logger; } - + public override async Task Handle(DocumentOnTypeFormattingParams request, CancellationToken cancellationToken) { var edits = await _formatter.Handle(new DocumentFormattingParams { Options = request.Options, - TextDocument = request.TextDocument + TextDocument = request.TextDocument, }, cancellationToken); - var actualEdits = new List(); + if (edits == null) return actualEdits; foreach (var edit in edits) { var lineDist = Math.Abs(edit.Range.Start.Line - request.Position.Line); - if (lineDist < 2) - { - actualEdits.Add(edit); - } + if (lineDist < 2) actualEdits.Add(edit); } return actualEdits; - } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs b/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs index 1af1f06..987dddb 100644 --- a/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs +++ b/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs @@ -1,16 +1,30 @@ +// Goto definition — thin adapter over FadeBasic.LSP.Core.Handlers.DefinitionHandler. +// +// Audit vs the pre-refactor native handler: +// * Both find the AST node at the cursor (VariableRef / ArrayIndexReference / +// GoSub / Goto / Runto), then follow DeclaredFromSymbol.source to the +// declaration. +// * The native handler additionally walked the macroProgram. Core walks +// `doc.Program` only; macro lookups currently fall back to the +// non-existence path. The native FindFirst behavior used here returned the +// declaration of an in-source token — for macro-expanded tokens this +// never produced a location anyway (the old code's `unit.macroProgram` +// pass searched the SAME source coordinates, so behavior is preserved +// for non-macro positions). +// * Both translate ranges back through `unit.sourceMap` so multi-file +// projects resolve to the originating file. + using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using ApplicationSupport.Code; using FadeBasic; -using FadeBasic.Ast; using LSP.Services; using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreDefHandler = FadeBasic.LSP.Core.Handlers.DefinitionHandler; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; @@ -18,19 +32,14 @@ namespace LSP.Handlers; public class GotoDefinitionHandler : DefinitionHandlerBase { private readonly ILogger _logger; - private readonly DocumentService _docs; private readonly CompilerService _compiler; - public GotoDefinitionHandler( - ILogger logger, - DocumentService docs, - CompilerService compiler) + public GotoDefinitionHandler(ILogger logger, DocumentService docs, CompilerService compiler) { _logger = logger; - _docs = docs; _compiler = compiler; } - + protected override DefinitionRegistrationOptions CreateRegistrationOptions(DefinitionCapability capability, ClientCapabilities clientCapabilities) { @@ -40,97 +49,43 @@ protected override DefinitionRegistrationOptions CreateRegistrationOptions(Defin }; } - public override async Task Handle(DefinitionParams request, CancellationToken cancellationToken) + public override Task Handle(DefinitionParams request, CancellationToken cancellationToken) { - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled unit"); - return null; - } - - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - - /* - * given the position, we could find the token, - * from the token, we could look up and see - */ - _logger.LogInformation("looking for def : " + request.Position); - - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character, out var token)) + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(default(LocationOrLocationLinks?)); + + var unit = units[0]; + + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, + out var mappedLine, + out var mappedChar)) { - return null; // no token found + return Task.FromResult(default(LocationOrLocationLinks?)); } - // at this point, we know the token, but we need the part of the AST it represents. + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString()); + var loc = CoreDefHandler.Compute(doc, mappedLine, mappedChar); + if (loc == null) return Task.FromResult(default(LocationOrLocationLinks?)); - var allowedTypes = new HashSet + // Map the result range back through the source map so multi-file + // projects resolve to the originating file. + var startToken = new FadeBasic.Token { - typeof(VariableRefNode), - typeof(ArrayIndexReference), - typeof(GoSubStatement), - typeof(GotoStatement), + lineNumber = loc.Range.Start.Line, + charNumber = loc.Range.Start.Character, }; + var origin = unit.sourceMap.GetOriginalLocation(startToken); + int rangeLen = Math.Max(1, loc.Range.End.Character - loc.Range.Start.Character); - bool Visit(IAstVisitable x) - { - if (!allowedTypes.Contains(x.GetType())) return false; - return x.StartToken == token || x.EndToken == token; - } - var node = unit.program.FindFirst(Visit) - ?? unit.macroProgram?.FindFirst(Visit); - _logger.LogInformation($"looking for {node}"); - - LocationOrLocationLink location = null; - switch (node) - { - case ExpressionStatement exprStatement: - location = GetLink(exprStatement.expression, unit); - break; - - case GoSubStatement _: - case GotoStatement _: - case ArrayIndexReference _: - case VariableRefNode _: - location = GetLink(node, unit); - break; - } - - // once we know the AST node, we can look for its "declaration" AST node - - if (location == null) - { - return null; - } - - return new LocationOrLocationLinks(location); - // return null; - // var links = new LocationOrLocationLink[] - // { - // new LocationOrLocationLink(new Location - // { - // Uri = request.TextDocument.Uri, - // Range = new Range(20, 0, 20, 5) - // }) - // }; - return null; - // return new LocationOrLocationLinks(links); - } - - LocationOrLocationLink GetLink(IAstNode node, CodeUnit unit) - { - if (node.DeclaredFromSymbol == null) return null; - - var origin = node.DeclaredFromSymbol.source.StartToken; - var definition = unit.sourceMap.GetOriginalLocation(origin); - - return + return Task.FromResult(new LocationOrLocationLinks( new LocationOrLocationLink(new Location { - Uri = DocumentUri.File(definition.fileName), - Range = new Range(definition.startLine, definition.startChar, definition.startLine, - definition.startChar + origin.Length) - }); - + Uri = DocumentUri.File(origin.fileName), + Range = new Range(origin.startLine, origin.startChar, origin.startLine, origin.startChar + rangeLen), + }))); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/HoverHandler.cs b/FadeBasic/LSP/Handlers/HoverHandler.cs index 19e09b7..2441987 100644 --- a/FadeBasic/LSP/Handlers/HoverHandler.cs +++ b/FadeBasic/LSP/Handlers/HoverHandler.cs @@ -1,43 +1,62 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +// Hover — thin adapter over FadeBasic.LSP.Core.Handlers.HoverHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Pre-refactor native: +// * Walked the AST for the smallest matching node at the cursor. +// * For a CommandStatement/CommandExpression, generated rich Markdown from +// ProjectDocs (groups → commands → methodDocs). +// * For a function call (FunctionCall flag on ExpressionStatement or +// ArrayIndexReference), looked up `scope.functionTable` and returned +// `function.Trivia` verbatim. +// * For nodes whose DeclaredFromSymbol.source implements IHasTriviaNode, +// returned the source's Trivia verbatim. +// * Did NOT surface diagnostics on hover. +// * Did NOT surface variable / parameter / label info beyond raw trivia. +// +// Core HoverHandler now provides: +// * Error/lex-error markdown when a diagnostic encloses the cursor. +// * Rich command markdown via ICommandDocsProvider (same ProjectDocs +// pipeline behind a small interface). The native LSP installs a +// `ProjectDocsCommandDocsProvider` here so the exact-same markdown +// pipeline is used. +// * Function-call hover via DeclaredFromSymbol.source on the AST. +// * Symbol info for VariableRef / Declaration / Parameter / Label, +// formatted as a fenced `fade` code block + trivia. +// +// Behavioral diffs vs old native: +// * Hovering over a token that maps to a diagnostic now surfaces the +// diagnostic instead of nothing (better). +// * Hovering over a variable/parameter/label now shows a signature-shaped +// header, not just trivia (more informative). +// * The output range is derived from the matched token, not from +// `unit.sourceMap.GetOriginalRange` on the AST node. For single-file +// projects this is identical; for multi-file ones the new range may +// be tighter (token-level instead of node-level). +// * The old function-call path returned trivia raw (no header); Core now +// prefixes a `function name(args)` code-block header before trivia. + using System.Threading; using System.Threading.Tasks; using FadeBasic; -using FadeBasic.ApplicationSupport.Project; -using FadeBasic.Ast; -using FadeBasic.Json; -using FadeBasic.Virtual; using LSP.Services; -using Microsoft.Extensions.Logging; -using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreHoverHandler = FadeBasic.LSP.Core.Handlers.HoverHandler; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; public class HoverHandler : HoverHandlerBase { - private readonly ILogger _logger; - private readonly DocumentService _docs; private readonly CompilerService _compiler; - private readonly ProjectService _project; - public HoverHandler( - ILogger logger, - DocumentService docs, - CompilerService compiler, - ProjectService project) + public HoverHandler(CompilerService compiler) { - _logger = logger; - _docs = docs; _compiler = compiler; - _project = project; } - + protected override HoverRegistrationOptions CreateRegistrationOptions(HoverCapability capability, ClientCapabilities clientCapabilities) { return new HoverRegistrationOptions @@ -46,239 +65,45 @@ protected override HoverRegistrationOptions CreateRegistrationOptions(HoverCapab }; } - - StringBuilder GenerateMarkdown(CommandInfo command, DocumentUri uri) + public override Task Handle(HoverParams request, CancellationToken cancellationToken) { - var sb = new StringBuilder(); - if (!_compiler.TryGetDocsForSrc(uri, out var docs, out var docHost)) - { - sb.Append("no docs loaded"); - return sb; - } + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(default(Hover?)); - CommandDocs foundCommand = null; - CommandGroupDocs foundGroup = null; - foreach (var docGroup in docs.groups) - { - if (foundCommand != null) - { - break; - } - - foreach (var docCommand in docGroup.commands) - { - if (command.name == docCommand.commandName) - { - foundGroup = docGroup; - foundCommand = docCommand; - break; - } - } - } - - // var foundCommand = docs.groups.SelectMany(x => x.commands) - // .FirstOrDefault(x => x.commandName == command.name); + var unit = units[0]; - if (foundCommand == null) + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, out var mappedLine, out var mappedChar)) { - sb.Append("no docs available"); // no token found - return sb; + return Task.FromResult(default(Hover?)); } - sb.AppendLine($"[Full Documentation]({docHost.GetUrlForCommand(foundGroup.title, foundCommand.commandName)})"); + // Install the project's docs so Core's command hover path renders + // the same Markdown the pre-refactor native handler did. + _compiler.TryGetDocsForSrc(request.TextDocument.Uri, out var projectDocs, out _); - sb.AppendLine($"### {foundCommand.commandName}"); - if (!string.IsNullOrEmpty(foundCommand.methodDocs.summary)) - { - sb.AppendLine(foundCommand.methodDocs.summary.Trim()); - } - sb.Append(Environment.NewLine); - - if (command.args.Length > 0) - { - sb.AppendLine($"#### Parameters"); - if (foundCommand.methodDocs.parameters.Count > command.args.Length) - { - sb.Append("(invalid number of parameter docs)"); - return sb; - } - for (var i = 0; i < command.args.Length; i++) - { - var arg = command.args[i]; - var parameter = i < foundCommand.methodDocs.parameters.Count ? foundCommand.methodDocs.parameters[i] : default; - sb.Append("##### "); - if (VmUtil.TryGetVariableTypeDisplay(arg.typeCode, out var type)) - { - sb.Append($"`{type}` "); - } - else - { - sb.Append("_unknown_ "); - } - - if (arg.isOptional) - { - sb.Append("_(optional)_ "); - } - if (arg.isRef) - { - sb.Append("_(ref)_ "); - } + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString(), projectDocs); + var hover = CoreHoverHandler.Compute(doc, mappedLine, mappedChar); + if (hover == null) return Task.FromResult(default(Hover?)); - if (parameter != default) - { - sb.Append(parameter.name); - sb.Append(Environment.NewLine); - sb.AppendLine(parameter.body.Trim()); - } - else - { - sb.AppendLine("_(doc missing)_"); - } - } - } - - if (command.returnType != TypeCodes.VOID) - { - sb.Append(Environment.NewLine); - sb.Append("#### Returns"); - if (VmUtil.TryGetVariableTypeDisplay(command.returnType, out var type)) - { - sb.Append($" `{type}`"); - } - - if (!string.IsNullOrEmpty(foundCommand.methodDocs.returns)) - { - sb.Append(Environment.NewLine); - sb.AppendLine(foundCommand.methodDocs.returns.Trim()); - } - } - - if (!string.IsNullOrEmpty(foundCommand.methodDocs.remarks)) - { - sb.Append(Environment.NewLine); - sb.AppendLine("#### Remarks"); - sb.AppendLine(foundCommand.methodDocs.remarks.Trim()); - } + // Map the range back to the originating source file so multi-file + // projects highlight the right region. + var startTok = new Token { lineNumber = hover.Range.Start.Line, charNumber = hover.Range.Start.Character }; + var origin = unit.sourceMap.GetOriginalLocation(startTok); + var len = System.Math.Max(1, hover.Range.End.Character - hover.Range.Start.Character); + var range = new Range(origin.startLine, origin.startChar, origin.startLine, origin.startChar + len); - if (foundCommand.methodDocs.examples.Count > 0) - { - sb.Append(Environment.NewLine); - sb.AppendLine("#### Examples"); - foreach (var example in foundCommand.methodDocs.examples) - { - sb.AppendLine(example.Trim()); - } - } - - return sb; - } - - public override Task Handle(HoverParams request, CancellationToken cancellationToken) - { - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled unit"); - return null; - } - - if (units.Count == 0) return Task.FromResult(default(Hover?)); - - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - - /* - * given the position, we could find the token, - * from the token, we could look up and see - */ - // _logger.LogInformation("looking for def : " + request.Position); - - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character, out var token)) - { - return Task.FromResult(default(Hover?)); // no token found - } - - var local = unit.sourceMap.GetOriginalLocation(token); - var range = new Range(local.startLine, local.startChar, local.startLine, token.raw.Length); - - var referencedNodes = new List(); - // var x = unit.program.scope.functionTable; - - void VisitFunc(IAstVisitable x) - { - var isMatch = Token.AreLocationsEqual(x.StartToken, token) || Token.AreLocationsEqual(x.EndToken, token); - var isInvalidNode = x is ProgramNode; - if (isMatch && !isInvalidNode) - { - referencedNodes.Add(x); - } - } - unit.program?.Visit(VisitFunc); - unit.macroProgram?.Visit(VisitFunc); - - // var markdown = $"test [this]({request.TextDocument.Uri.ToString()}#L2%2C4)"; - var markdown = ""; - if (referencedNodes.Count == 0) - { - markdown = "no known node"; - } - - else - { - var smalledReferencedNode = referencedNodes.MinBy(a => - a.EndToken.charNumber + (a.EndToken.raw?.Length ?? 0) - a.StartToken.charNumber); - - var expr = smalledReferencedNode;//referencedNodes[0]; - var exprRange = unit.sourceMap.GetOriginalRange(new TokenRange - { - start = expr.StartToken, - end = expr.EndToken - }); - range = new Range(exprRange.startLine, exprRange.startChar, exprRange.endLine, exprRange.endChar); - - switch (expr) - { - case AstNode node when node.DeclaredFromSymbol?.source is IHasTriviaNode triviaSource: - markdown = triviaSource.Trivia; - break; - case ExpressionStatement exprStatement when exprStatement.StartToken.flags.HasFlag(TokenFlags.FunctionCall) && exprStatement.expression is ArrayIndexReference exprIndexRef: - if (!unit.program.scope.functionTable.TryGetValue(exprIndexRef.variableName, out var function)) - { - markdown = "_function does not exist_"; - break; - } - - markdown = function.Trivia; - break; - case ArrayIndexReference indexRef when indexRef.StartToken.flags.HasFlag(TokenFlags.FunctionCall): - if (!unit.program.scope.functionTable.TryGetValue(indexRef.variableName, out function)) - { - markdown = "_function does not exist_"; - break; - } - - markdown = function.Trivia; - break; - case CommandExpression expression: - markdown = GenerateMarkdown(expression.command, request.TextDocument.Uri).ToString(); - // a command that returns something is an expression! - break; - case CommandStatement statement: - markdown = GenerateMarkdown(statement.command, request.TextDocument.Uri).ToString(); - break; - } - } - - var hover = new Hover() + return Task.FromResult(new Hover { Range = range, Contents = new MarkedStringsOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, - Value = markdown - }) - }; - return Task.FromResult(hover); - + Value = hover.Contents ?? string.Empty, + }), + }); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/RenameHandler.cs b/FadeBasic/LSP/Handlers/RenameHandler.cs index d40edd9..7fe60ab 100644 --- a/FadeBasic/LSP/Handlers/RenameHandler.cs +++ b/FadeBasic/LSP/Handlers/RenameHandler.cs @@ -1,29 +1,47 @@ -using System; +// Rename — thin adapter over FadeBasic.LSP.Core.Handlers.RenameHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Common: +// * Both find the AST node behind the cursor, walk to the declaration via +// DeclaredFromSymbol.source, then emit one TextEdit per reference site +// (the declaration's name token + every node whose DeclaredFromSymbol +// points back to it). +// * Both use the same `GetNameToken` rules for which token actually gets +// the replacement string (e.g., DeclarationStatement.EndToken to skip +// the GLOBAL/LOCAL/DIM keyword). +// +// Diff: +// * The old native handler walked both `unit.program` and +// `unit.macroProgram`. Core walks only `doc.Program`. Tokens inside +// macro-expanded regions don't yet rename through Core. (Aligns with +// References / GotoDef — TODO if macro renames become a requirement.) +// * The old handler returned ranges keyed by the request's DocumentUri. +// Core returns ranges in unit (project-buffer) coordinates; we map +// them back to originating files via `unit.sourceMap` here, so the +// resulting WorkspaceEdit's URIs match the originating source files. + using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using ApplicationSupport.Code; using FadeBasic; -using FadeBasic.Ast; using LSP.Services; -using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreRenameHandler = FadeBasic.LSP.Core.Handlers.RenameHandler; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; public class RenameHandler : RenameHandlerBase { - private readonly ILogger _logger; private readonly CompilerService _compiler; - public RenameHandler(ILogger logger, CompilerService compiler) + public RenameHandler(CompilerService compiler) { - - _logger = logger; _compiler = compiler; } @@ -36,185 +54,49 @@ protected override RenameRegistrationOptions CreateRegistrationOptions(RenameCap public override Task Handle(RenameParams request, CancellationToken cancellationToken) { - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) return Task.FromResult(default(WorkspaceEdit?)); var unit = units[0]; - - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), - request.Position.Line, request.Position.Character, out var token)) - { - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), - request.Position.Line, request.Position.Character - 1, out token)) - return Task.FromResult(default(WorkspaceEdit?)); - } - - // var declarationNode = ResolveDeclaration(unit, token); - - var allowedTypes = new HashSet - { - typeof(VariableRefNode), - typeof(ArrayIndexReference), - typeof(GoSubStatement), - typeof(GotoStatement), - typeof(DeclarationStatement), - typeof(ParameterNode), - }; - - bool Visit(IAstVisitable x) + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, out var mappedLine, out var mappedChar)) { - if (!allowedTypes.Contains(x.GetType())) return false; - return x.StartToken == token || x.EndToken == token; - } - IAstNode? declarationNode = unit.program.FindFirst(Visit) - ?? unit.macroProgram?.FindFirst(Visit); - - declarationNode = declarationNode?.DeclaredFromSymbol?.source ?? declarationNode; - if (declarationNode == null) return Task.FromResult(default(WorkspaceEdit?)); + } - var edits = new List(); - CollectEdits(unit, declarationNode, request.NewName, edits); - - _logger.LogInformation($"Rename: found {edits.Count} edits for '{request.NewName}' in {request.TextDocument.Uri}"); - - if (edits.Count == 0) + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString()); + var ws = CoreRenameHandler.Compute(doc, mappedLine, mappedChar, request.NewName); + if (ws == null || ws.Changes == null || ws.Changes.Count == 0) return Task.FromResult(default(WorkspaceEdit?)); - return Task.FromResult(new WorkspaceEdit + // Translate each edit back into the originating file's coordinate + // space via `unit.sourceMap`. Edits from a single concatenated + // project buffer may resolve to different source files. + var changes = new Dictionary>(); + foreach (var kv in ws.Changes) { - Changes = new Dictionary> + foreach (var edit in kv.Value) { - [request.TextDocument.Uri] = edits + var startTok = new Token { lineNumber = edit.Range.Start.Line, charNumber = edit.Range.Start.Character }; + var origin = unit.sourceMap.GetOriginalLocation(startTok); + var len = System.Math.Max(1, edit.Range.End.Character - edit.Range.Start.Character); + var key = DocumentUri.File(origin.fileName); + if (!changes.TryGetValue(key, out var list)) + changes[key] = list = new List(); + list.Add(new TextEdit + { + NewText = edit.NewText ?? string.Empty, + Range = new Range(origin.startLine, origin.startChar, origin.startLine, origin.startChar + len), + }); } - }); - } - - // public override Task Handle(PrepareRenameParams request, - // CancellationToken cancellationToken) - // { - // if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - // return Task.FromResult(default(RangeOrPlaceholderRange?)); - // - // var unit = units[0]; - // - // if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), - // request.Position.Line, request.Position.Character, out var token)) - // { - // if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), - // request.Position.Line, request.Position.Character - 1, out token)) - // return Task.FromResult(default(RangeOrPlaceholderRange?)); - // } - // - // var declarationNode = ResolveDeclaration(unit, token); - // if (declarationNode == null) - // return Task.FromResult(default(RangeOrPlaceholderRange?)); - // - // var nameToken = GetNameToken(declarationNode); - // var loc = unit.sourceMap.GetOriginalLocation(nameToken); - // var range = new Range(loc.startLine, loc.startChar, loc.startLine, - // loc.startChar + (nameToken.raw?.Length ?? 0)); - // - // return Task.FromResult( - // new RangeOrPlaceholderRange(new PlaceholderRange - // { - // Range = range, - // Placeholder = nameToken.raw ?? string.Empty - // })); - // } - - // ------------------------------------------------------------------------- - - /// - /// Given the token under the cursor, walks up to the declaration node - /// (the node that owns the symbol — not a reference to it). - /// - IAstNode? ResolveDeclaration(CodeUnit unit, Token token) - { - IAstNode? found = null; - - void Visit(IAstVisitable x) - { - bool isMatch = x switch - { - VariableRefNode => Token.AreLocationsEqual(token, x.StartToken), - DeclarationStatement => Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken), - ArrayIndexReference => Token.AreLocationsEqual(token, x.StartToken), - LabelDeclarationNode => Token.AreLocationsEqual(token, x.StartToken), - GoSubStatement => Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken), - GotoStatement => Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken), - FunctionStatement fs => Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, fs.nameToken), - _ => false - }; - - if (isMatch) found = x; } - unit.program.Visit(Visit); - unit.macroProgram?.Visit(Visit); - - if (found == null) return null; - - // Walk up to declaration if this is a reference - if (found.DeclaredFromSymbol != null) - found = found.DeclaredFromSymbol.source; - - return found; - } - - /// - /// Returns the token that represents just the name portion of a declaration node. - /// - static Token GetNameToken(IAstNode node) => node switch - { - FunctionStatement fs => fs.nameToken, - // Variable name is at EndToken; StartToken is the scope keyword (GLOBAL/LOCAL/DIM) - DeclarationStatement decl => decl.EndToken, - // ParameterNode.StartToken == parameter.variable.startToken (the name token) - ParameterNode param => param.StartToken, - _ => node.StartToken - }; - - void CollectEdits(CodeUnit unit, IAstNode declarationNode, string newName, List edits) - { - // Include the declaration itself - AddEdit(unit, declarationNode, newName, edits); - - // Include all references that point back to this declaration - unit.program.Visit(x => - { - if (x == declarationNode) return; - if (x.DeclaredFromSymbol?.source == declarationNode) - AddEdit(unit, x, newName, edits); - if (x.DeclaredFromSymbol?.source is AssignmentStatement assignment && - assignment.variable == declarationNode) - { - AddEdit(unit, x, newName, edits); - } - }); - - unit.macroProgram?.Visit(x => - { - if (x == declarationNode) return; - if (x.DeclaredFromSymbol?.source == declarationNode) - AddEdit(unit, x, newName, edits); - if (x.DeclaredFromSymbol?.source is AssignmentStatement macroAssignment && - macroAssignment.variable == declarationNode) - { - AddEdit(unit, x, newName, edits); - } - }); - } - - void AddEdit(CodeUnit unit, IAstNode node, string newName, List edits) - { - var nameToken = GetNameToken(node); - var loc = unit.sourceMap.GetOriginalLocation(nameToken); - edits.Add(new TextEdit + return Task.FromResult(new WorkspaceEdit { - NewText = newName, - Range = new Range(loc.startLine, loc.startChar, loc.startLine, - loc.startChar + (nameToken.raw?.Length ?? 0)) + Changes = changes.ToDictionary(k => k.Key, v => (IEnumerable)v.Value), }); } } diff --git a/FadeBasic/LSP/Handlers/SemanticTokenHandler.cs b/FadeBasic/LSP/Handlers/SemanticTokenHandler.cs index 12eb4de..ddb45b8 100644 --- a/FadeBasic/LSP/Handlers/SemanticTokenHandler.cs +++ b/FadeBasic/LSP/Handlers/SemanticTokenHandler.cs @@ -1,39 +1,45 @@ +// Semantic tokens — thin adapter over FadeBasic.LSP.Core.Handlers.SemanticTokensHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Pre-refactor native and Core both run `LSPUtil.ClassifyToken` against +// every token in the lex stream. The only project-aware step is filtering +// tokens to the requesting URI and remapping each token's position +// through `unit.sourceMap.GetOriginalLocation` (multi-file projects feed +// many source files into one concatenated lex buffer). +// +// Refactor: Core now exposes `Classify(doc)` returning the raw +// (token, type) list. The native adapter calls Classify, then for each +// token does the per-token source-map filter+remap before pushing onto +// the OmniSharp builder. Core's `Compute(doc)` still returns the +// canonical LSP delta-encoded ints (used unchanged by WebRuntime which +// is single-file). + using System; -using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using FadeBasic.ApplicationSupport.Project; -using FadeBasic.Json; using FadeBasic.Lsp; -using FadeBasic.Sdk; using LSP.Services; using Microsoft.Extensions.Logging; -using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; -using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; +using CoreSemTokensHandler = FadeBasic.LSP.Core.Handlers.SemanticTokensHandler; namespace LSP.Handlers; public class SemanticTokenHandler : SemanticTokensHandlerBase { private readonly ILogger _logger; - private readonly DocumentService _docs; - private CompilerService _compiler; - private readonly ProjectService _projects; + private readonly CompilerService _compiler; - public SemanticTokenHandler( - ILogger logger, - DocumentService docs, CompilerService compiler, ProjectService projects) + public SemanticTokenHandler(ILogger logger, CompilerService compiler) { - _compiler = compiler; - _projects = projects; _logger = logger; - _docs = docs; + _compiler = compiler; } + protected override SemanticTokensRegistrationOptions CreateRegistrationOptions(SemanticTokensCapability capability, ClientCapabilities clientCapabilities) { @@ -45,89 +51,59 @@ protected override SemanticTokensRegistrationOptions CreateRegistrationOptions(S TokenModifiers = capability.TokenModifiers, TokenTypes = capability.TokenTypes, }, - Full = new SemanticTokensCapabilityRequestFull - { - Delta = true - }, - Range = true + Full = new SemanticTokensCapabilityRequestFull { Delta = true }, + Range = true, }; } - protected override async Task Tokenize(SemanticTokensBuilder builder, ITextDocumentIdentifierParams identifier, + protected override Task Tokenize(SemanticTokensBuilder builder, ITextDocumentIdentifierParams identifier, CancellationToken cancellationToken) { - SourceMap sourceMap = null; try { - if (!_compiler.TryGetProjectsFromSource(identifier.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{identifier.TextDocument.Uri}] did not map to any compiled unit"); - return; - } + if (!_compiler.TryGetProjectsFromSource(identifier.TextDocument.Uri, out var units) || units.Count == 0) + return Task.CompletedTask; + var unit = units[0]; - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - sourceMap = unit.sourceMap; + var doc = CoreAdapter.ToDocument(unit, identifier.TextDocument.Uri.ToString()); + var classified = CoreSemTokensHandler.Classify(doc); var emptyMods = Array.Empty(); + var thisFilePath = identifier.TextDocument.Uri.GetFileSystemPath(); - for (var i = 0; i < unit.lexerResults.allTokens.Count; i++) + foreach (var ct in classified) { - var token = unit.lexerResults.allTokens[i]; - if (token.raw == null) continue; - - var location = unit.sourceMap.GetOriginalLocation(token.lineNumber, token.charNumber); - if (location.fileName != identifier.TextDocument.Uri.GetFileSystemPath()) - continue; - - var prevToken = i > 0 ? unit.lexerResults.allTokens[i - 1] : null; - var result = LSPUtil.ClassifyToken(token, prevToken); - if (result.Skip) continue; - - builder.Push(location.startLine, location.startChar, token.Length, ToSemanticTokenType(result.TokenType), emptyMods); + var location = unit.sourceMap.GetOriginalLocation(ct.Token.lineNumber, ct.Token.charNumber); + if (location.fileName != thisFilePath) continue; + builder.Push(location.startLine, location.startChar, ct.Token.Length, + ToSemanticTokenType(ct.Type), emptyMods); } - - } catch (Exception ex) { - _logger.LogError($"TOKEN ERR type=[{ex.GetType().Name}] message=[{ex.Message}] stack=[{ex.StackTrace}]" ); - if (sourceMap == null) - { - _logger.LogError(" No source map exists"); - } - else - { - _logger.LogError(sourceMap.fullSource); - _logger.LogError("File Ranges"); - _logger.LogError(string.Join(",", sourceMap.fileRanges.Select(kvp => $"[{kvp.Item1}] -> {kvp.Item2.Start} to {kvp.Item2.End}"))); - - _logger.LogError("File To Ranges"); - _logger.LogError(string.Join(",", sourceMap._fileToRange.Select(kvp => $"[{kvp.Key}] -> {kvp.Value.Start} to {kvp.Value.End}"))); - - _logger.LogError("Line To TOkens"); - _logger.LogError(string.Join(",", sourceMap._lineToTokens.Select(kvp => $"[{kvp.Key}] -> {string.Join("|", kvp.Value.Select(t => t.Jsonify()))}"))); - } + _logger.LogError($"TOKEN ERR type=[{ex.GetType().Name}] message=[{ex.Message}]"); } finally { builder.Commit(); } + return Task.CompletedTask; } - static SemanticTokenType ToSemanticTokenType(PortableSemanticTokenType type) + private static SemanticTokenType ToSemanticTokenType(PortableSemanticTokenType type) { switch (type) { - case PortableSemanticTokenType.Comment: return SemanticTokenType.Comment; - case PortableSemanticTokenType.Function: return SemanticTokenType.Function; - case PortableSemanticTokenType.Macro: return SemanticTokenType.Macro; + case PortableSemanticTokenType.Comment: return SemanticTokenType.Comment; + case PortableSemanticTokenType.Function: return SemanticTokenType.Function; + case PortableSemanticTokenType.Macro: return SemanticTokenType.Macro; case PortableSemanticTokenType.Parameter: return SemanticTokenType.Parameter; - case PortableSemanticTokenType.Keyword: return SemanticTokenType.Keyword; - case PortableSemanticTokenType.Struct: return SemanticTokenType.Struct; - case PortableSemanticTokenType.Type: return SemanticTokenType.Type; - case PortableSemanticTokenType.Operator: return SemanticTokenType.Operator; - case PortableSemanticTokenType.Number: return SemanticTokenType.Number; - case PortableSemanticTokenType.String: return SemanticTokenType.String; - case PortableSemanticTokenType.Method: return SemanticTokenType.Method; + case PortableSemanticTokenType.Keyword: return SemanticTokenType.Keyword; + case PortableSemanticTokenType.Struct: return SemanticTokenType.Struct; + case PortableSemanticTokenType.Type: return SemanticTokenType.Type; + case PortableSemanticTokenType.Operator: return SemanticTokenType.Operator; + case PortableSemanticTokenType.Number: return SemanticTokenType.Number; + case PortableSemanticTokenType.String: return SemanticTokenType.String; + case PortableSemanticTokenType.Method: return SemanticTokenType.Method; default: return SemanticTokenType.Comment; } } @@ -136,4 +112,4 @@ protected override Task GetSemanticTokensDocument(ITextD { return Task.FromResult(new SemanticTokensDocument(RegistrationOptions.Legend)); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/SignatureHelpHandler.cs b/FadeBasic/LSP/Handlers/SignatureHelpHandler.cs index cb63d77..9a74899 100644 --- a/FadeBasic/LSP/Handlers/SignatureHelpHandler.cs +++ b/FadeBasic/LSP/Handlers/SignatureHelpHandler.cs @@ -1,29 +1,35 @@ +// Signature help — thin adapter over FadeBasic.LSP.Core.Handlers.SignatureHelpHandler. +// +// Audit vs the pre-refactor native handler: +// * Both walk to the innermost CommandStatement/CommandExpression at the +// cursor and, failing that, walk tokens back to the enclosing `(` to +// handle the "user just typed name(" case. +// * Both build the same "name(arg1, arg2, …)" label and parameter list. +// * The old native handler additionally consulted ProjectDocs for per-param +// documentation. Core's interface doesn't yet expose that — we therefore +// post-fill `Documentation` here from ProjectDocs when available, so the +// hover behavior previously seen by users is preserved. + using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using FadeBasic; using FadeBasic.ApplicationSupport.Project; -using FadeBasic.Ast; -using FadeBasic.Virtual; using LSP.Services; -using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreSigHandler = FadeBasic.LSP.Core.Handlers.SignatureHelpHandler; namespace LSP.Handlers; public class SignatureHelpHandler : SignatureHelpHandlerBase { - private readonly ILogger _logger; private readonly CompilerService _compiler; private readonly ProjectService _project; - public SignatureHelpHandler(ILogger logger, CompilerService compiler, ProjectService project) + public SignatureHelpHandler(CompilerService compiler, ProjectService project) { - _logger = logger; _compiler = compiler; _project = project; } @@ -39,15 +45,16 @@ protected override SignatureHelpRegistrationOptions CreateRegistrationOptions( public override Task Handle(SignatureHelpParams request, CancellationToken cancellationToken) { - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) return Task.FromResult(default(SignatureHelp?)); var unit = units[0]; + // Map the URI-space position into the compiled unit's coordinate space. if (!unit.sourceMap.TryGetMappedLocation( request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character - 1, + request.Position.Character, out _, out var mappedLine, out var mappedChar)) @@ -55,261 +62,58 @@ protected override SignatureHelpRegistrationOptions CreateRegistrationOptions( return Task.FromResult(default(SignatureHelp?)); } - var fakeToken = new Token { lineNumber = mappedLine, charNumber = mappedChar }; - - bool Visit(IAstVisitable v) => - Token.IsLocationBeforeOrEqual(v.StartToken, fakeToken) && - Token.IsLocationBeforeOrEqual(fakeToken, v.EndToken); - - var group = unit.program?.Where(Visit) ?? new List(); - var node = group.LastOrDefault(); - - _logger.LogInformation("SignatureHelp node: " + node?.GetType().Name); - - // User-defined function call - if (node is ArrayIndexReference arrRef && - arrRef.DeclaredFromSymbol?.source is FunctionStatement func) - { - return Task.FromResult(BuildFunctionSignature(func, arrRef.rankExpressions.Count)); - } - - // Built-in command — check innermost first, then walk up the group - (CommandInfo command, List args, List argMap)? commandNode = node switch - { - CommandStatement cs => (cs.command, cs.args, cs.argMap), - CommandExpression ce => (ce.command, ce.args, ce.argMap), - _ => null - }; - - if (commandNode == null) - { - // cursor may be inside an arg expression; walk up to find the enclosing command - for (var i = group.Count - 2; i >= 0; i--) - { - if (group[i] is CommandStatement cs2) - { - commandNode = (cs2.command, cs2.args, cs2.argMap); - break; - } - if (group[i] is CommandExpression ce2) - { - commandNode = (ce2.command, ce2.args, ce2.argMap); - break; - } - } - } - - // Get project data once — needed for both AST and token-walk paths - CommandDocs? commandDocs = null; - ProjectCommandInfo? commandData = null; - if (_compiler.TryGetProjectContexts(request.TextDocument.Uri, out var projects) && - _project.TryGetProject(projects[0], out var projectData)) + // Pick up project docs so we can fill per-parameter Documentation. + ProjectDocs? projectDocs = null; + if (_compiler.TryGetProjectContexts(request.TextDocument.Uri, out var ctxs) + && _project.TryGetProject(ctxs[0], out var projectData)) { - commandData = projectData.Item2; + projectDocs = projectData.Item2.docs; } - if (commandNode != null) - { - commandData?.docs.map.TryGetValue(commandNode.Value.command.sig, out commandDocs); - return Task.FromResult(BuildCommandSignature(commandNode.Value.command, commandNode.Value.args, commandNode.Value.argMap, commandDocs)); - } + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString(), projectDocs); + var core = CoreSigHandler.Compute(doc, mappedLine, mappedChar); + if (core == null || core.Signatures == null || core.Signatures.Count == 0) + return Task.FromResult(default(SignatureHelp?)); - // Fallback: AST is incomplete (e.g. user just typed `CommandName(`). - // Walk tokens backward to find the enclosing `(` and the CommandWord before it. - if (commandData != null) + var sigs = new List(core.Signatures.Count); + foreach (var s in core.Signatures) { - var tokens = unit.lexerResults.allTokens; - var activeParam = 0; - var depth = 0; - Token? openParen = null; - - for (var i = tokens.Count - 1; i >= 0; i--) + var paramInfos = new List(); + for (var i = 0; i < (s.Parameters?.Count ?? 0); i++) { - var t = tokens[i]; - if (t.lineNumber > mappedLine) continue; - if (t.lineNumber == mappedLine && t.charNumber > mappedChar) continue; - - if (t.type == LexemType.ParenClose) depth++; - else if (t.type == LexemType.ParenOpen) + var p = s.Parameters![i]; + paramInfos.Add(new ParameterInformation { - if (depth > 0) depth--; - else { openParen = t; break; } - } - else if (t.type == LexemType.ArgSplitter && depth == 0) - activeParam++; + Label = new ParameterInformationLabel(p.Label ?? string.Empty), + Documentation = string.IsNullOrEmpty(p.Documentation) + ? null + : new StringOrMarkupContent(new MarkupContent + { + Kind = MarkupKind.Markdown, + Value = p.Documentation!, + }), + }); } - - if (openParen != null) + sigs.Add(new SignatureInformation { - // Find the token immediately before the `(` - Token? nameToken = null; - foreach (var t in tokens) - { - if (t.lineNumber > openParen.lineNumber) break; - if (t.lineNumber == openParen.lineNumber && t.charNumber >= openParen.charNumber) break; - nameToken = t; - } - - if (nameToken?.type == LexemType.CommandWord) - { - var commandName = nameToken.caseInsensitiveRaw; - var command = unit.commands.Commands.FirstOrDefault( - c => string.Equals(c.name, commandName, System.StringComparison.OrdinalIgnoreCase)); - - if (command.name != null) - { - commandData.docs.map.TryGetValue(command.sig, out commandDocs); - return Task.FromResult(BuildCommandSignature(command, new List(), new List(), commandDocs, activeParam)); - } - } - } - } - - return Task.FromResult(default(SignatureHelp?)); - } - - // ------------------------------------------------------------------------- - // User-defined functions - // ------------------------------------------------------------------------- - - SignatureHelp? BuildFunctionSignature(FunctionStatement func, int activeParam) - { - var paramInfos = new List(); - foreach (var param in func.parameters) - { - paramInfos.Add(new ParameterInformation - { - Label = new ParameterInformationLabel($"{param.variable.variableName} as {param.type.variableType}"), - }); - } - - var labelParts = func.parameters.Select(p => $"{p.variable.variableName} as {p.type.variableType}"); - var signatureLabel = $"{func.name}({string.Join(", ", labelParts)})"; - - var sigInfo = new SignatureInformation - { - Label = signatureLabel, - Documentation = string.IsNullOrEmpty(func.Trivia) - ? null - : new StringOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, Value = func.Trivia }), - Parameters = new Container(paramInfos), - ActiveParameter = activeParam, - }; - - return new SignatureHelp - { - Signatures = new Container(sigInfo), - ActiveSignature = 0, - ActiveParameter = activeParam, - }; - } - - // ------------------------------------------------------------------------- - // Built-in commands - // ------------------------------------------------------------------------- - - SignatureHelp? BuildCommandSignature( - CommandInfo command, - List args, - List argMap, - CommandDocs? docs, - int tokenWalkActiveParam = -1) - { - // Visible params = skip VM-internal and raw args - var visibleArgs = command.args - .Select((a, i) => (arg: a, index: i)) - .Where(x => !x.arg.isVmArg && !x.arg.isRawArg) - .ToList(); - - if (visibleArgs.Count == 0) - return null; - - // Compute which CommandArgInfo index the cursor is at. - // tokenWalkActiveParam is used when the AST is incomplete (user just opened the paren). - int activeCommandArgIndex; - if (tokenWalkActiveParam >= 0) - { - activeCommandArgIndex = System.Math.Min(tokenWalkActiveParam, visibleArgs[^1].index); - } - else if (args.Count == 0 || argMap.Count == 0) - { - activeCommandArgIndex = 0; - } - else - { - var lastArgInfoIndex = argMap[args.Count - 1]; - activeCommandArgIndex = command.args[lastArgInfoIndex].isParams - ? lastArgInfoIndex // stay on the variadic param - : lastArgInfoIndex + 1; - } - - // Map CommandArgInfo index → visible param index - var activeVisibleIndex = visibleArgs.FindIndex(x => x.index == activeCommandArgIndex); - if (activeVisibleIndex < 0) - activeVisibleIndex = visibleArgs.Count - 1; // clamp to last (e.g. past all optional params) - - // Build parameter information - var paramLabels = new List(); - var paramInfos = new List(); - for (var vi = 0; vi < visibleArgs.Count; vi++) - { - var (arg, _) = visibleArgs[vi]; - var paramName = docs?.methodDocs.parameters.Count > vi - ? docs.methodDocs.parameters[vi].name - : $"arg{vi + 1}"; - var paramDoc = docs?.methodDocs.parameters.Count > vi - ? docs.methodDocs.parameters[vi].body?.Trim() - : null; - - var label = BuildArgLabel(arg, paramName); - paramLabels.Add(label); - paramInfos.Add(new ParameterInformation - { - Label = new ParameterInformationLabel(label), - Documentation = string.IsNullOrEmpty(paramDoc) + Label = s.Label ?? string.Empty, + Documentation = string.IsNullOrEmpty(s.Documentation) ? null - : new StringOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, Value = paramDoc }), + : new StringOrMarkupContent(new MarkupContent + { + Kind = MarkupKind.Markdown, + Value = s.Documentation!, + }), + Parameters = new Container(paramInfos), + ActiveParameter = s.ActiveParameter, }); } - // Build the full signature label - var signatureLabel = $"{command.name}({string.Join(", ", paramLabels)})"; - - // Build documentation for the whole signature - StringOrMarkupContent? sigDoc = null; - if (!string.IsNullOrEmpty(docs?.methodDocs.summary)) - sigDoc = new StringOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, Value = docs!.methodDocs.summary }); - - var sigInfo = new SignatureInformation - { - Label = signatureLabel, - Documentation = sigDoc, - Parameters = new Container(paramInfos), - ActiveParameter = activeVisibleIndex, - }; - - return new SignatureHelp - { - Signatures = new Container(sigInfo), - ActiveSignature = 0, - ActiveParameter = activeVisibleIndex, - }; - } - - static string BuildArgLabel(CommandArgInfo arg, string name) - { - VmUtil.TryGetVariableTypeDisplay(arg.typeCode, out var typeName); - var sb = new StringBuilder(); - if (arg.isRef) sb.Append("ref "); - sb.Append(typeName); - if (arg.isParams) sb.Append("..."); - sb.Append(' '); - sb.Append(name); - if (arg.isOptional) + return Task.FromResult(new SignatureHelp { - sb.Insert(0, '['); - sb.Append(']'); - } - return sb.ToString(); + Signatures = new Container(sigs), + ActiveSignature = core.ActiveSignature, + ActiveParameter = core.ActiveParameter, + }); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/LSP.csproj b/FadeBasic/LSP/LSP.csproj index 67ba1ad..8409d07 100644 --- a/FadeBasic/LSP/LSP.csproj +++ b/FadeBasic/LSP/LSP.csproj @@ -19,6 +19,7 @@ + diff --git a/FadeBasic/TEST_DESIGN.md b/FadeBasic/TEST_DESIGN.md new file mode 100644 index 0000000..961bc11 --- /dev/null +++ b/FadeBasic/TEST_DESIGN.md @@ -0,0 +1,413 @@ +# Fade Test Design + +A design sketch for first-class testing in Fade. Companion to [TESTS.md](TESTS.md), which covers the `dotnet test` integration plumbing. This doc covers the **language-level** test model: what tests look like in `.fbasic` source. + +## The core insight + +Conventional test frameworks (xUnit, NUnit, MSTest) assume the host language has a clear separation between *declarations* (functions, types) and *execution* (a `Main`). Tests work by calling functions in isolation. Fade, like classic BASIC, has no such separation — the program *is* the imperative top-level body. There is no `Main` to skip, and most interesting behavior lives inside the main loop, not in factor-out-able functions. + +A test framework that grafts xUnit's model onto Fade ends up either: +- forcing users to refactor everything into testable functions (fights the language), or +- creating a separate "test scope" with awkward bridges to program state (the scope-access problem). + +The fresh paradigm: **a test is a script that drives the program between its natural pause points.** + +The program's labels (`:start`, `:sync`) become cue points. A test pauses execution at a label, optionally pokes state, advances to the next label, and asserts. These are ordinary language labels — the same kind used by `goto`/`gosub` — not a test-only construct, so adding tests to Fade requires no new label machinery and labels carry no runtime cost. Scope merges automatically because the test is *inside* the paused program — it's a debugger session expressed as code. + +## Headline syntax + +```fbasic +` --- program --- + +imageId = 1 +texture imageId, "fish.png" + +x = 0 +y = 0 +sprite 1, x, y, imageId + +:start +sync rate 60 +do + :step + x = x + 1 + set sprite position 1, x, y + if x > screen width() + x = 0 + endif + :sync + sync +loop + +` --- tests --- + +abstract test root + local fakeWidth = 10 + mock screen width + returns fakeWidth + endmock +endtest + +test wraps_at_right_edge from root + runto :start + assert x is 0 + + for n = 1 to fakeWidth + 1 + runto :sync + next + + assert x is 0 ` should have wrapped exactly once +endtest + +test moves_one_per_frame from root + runto :start + local before = x + runto :sync + assert x is before + 1 +endtest +``` + +## Design principles + +### 1. Test is a block that captures a token stream into the test manifest + +`test name ... endtest` is a top-level construct. Not `#test` decorating a function. This: +- Eliminates the "test scope vs function scope" tension. +- Lets the test body refer to program-scope identifiers directly. +- Removes the awkward "register the function" step. + +`test` implicitly opens a tokenize-flavored region whose tokens route to the **test manifest**, not back into program code. No `#tokenize` keyword required — `test` knows. In `dotnet run` builds, test blocks compile to nothing — there is no manifest. In `dotnet test` builds, each block contributes one entry. + +**Tests compose with `#macro`.** A `test` block can sit inside a `#macro` and consume its compile-time values via `[name]` substitution, exactly like a `#tokenize` region: + +```fbasic +#macro + for n = 1 to 5 + test "addCorrectly_" + [n] + assert add([n], [n]) is [n] * 2 + endtest + next +#endmacro +``` + +This is *not* nested macros. `test` is a tokenize-flavored region, not a macro itself, so it's legal inside `#macro`. What's still illegal: opening a new `#macro` block inside a test that's already inside `#macro`. If a test inside an explicit `#macro` needs compile-time setup, just put it in the surrounding macro before the `test`. + +**Top-level `test` blocks have a hoist rule.** When a test sits at top level (not inside an explicit `#macro`), the compiler synthesizes a `#macro` wrapper around it. If the author writes a `#macro ... #endmacro` block inside the test body, those statements are hoisted out of the test and into the synthesized wrapper: + +```fbasic +` source +test a + #macro + x = 23 + #endmacro + assert [x] is 23 +endtest + +` desugared +#macro + x = 23 + test a + assert [x] is 23 + endtest +#endmacro +``` + +This preserves the no-nested-macros rule (the inner `#macro` was notational; it never really nests) while letting authors write per-test compile-time setup locally. + +### 2. Fixtures are tests you continue from + +`test B from A` runs A's body, then continues from where A left off into B's body. No separate fixture concept. A test with only setup is a fixture in spirit; mark it `abstract test` to declare "do not run on its own, only continue from." + +The semantic emphasis is *continuation*, not classical inheritance. Child B doesn't inherit definitions and re-execute fresh — it picks up from A's exact ending state, including any program execution A drove (mocks installed, `runto`s already taken, program counter wherever A paused it). + +Composition rules: +- The parent's `local` declarations and test-functions are visible to the child. +- The parent's mock queues are present at the child's start (since the parent's body runs first and installs them). The child can append further entries, or `clear mock` to start fresh. +- Single-parent only for now. + +Two valid implementation strategies, with the same observable semantics: + +- **Replay.** Each child run starts fresh and re-executes its parent chain top-down before running the child body. Simple, robust, slow as the chain grows. +- **Snapshot.** After a parent finishes, take a snapshot of VM state (and any mockable C# host state). Child runs start from the snapshot. Faster for deep trees with shared expensive setup; harder to implement because the snapshot has to capture everything reachable — VM stack, heap, mock table, host-side bindings — and restore it perfectly. + +Replay is the right default. Snapshot is an optimization for later, gated on a perf measurement that says it matters. The contract should be defined in terms of observable behavior ("child sees parent's ending state") so either implementation is valid. + +### 3. Mocks are FIFO queues of behaviors + +```fbasic +mock screen width + returns fakeWidth +endmock +``` + +A `mock` block configures behavior for a Fade command at the C# boundary. The simple form above means *"every call to `screen width` returns `fakeWidth`, for the rest of the test."* + +**Mocks are queues, not single overrides.** The body of a `mock` block is an ordered list of behavior entries. Each call to the command consumes from the front of the queue (FIFO). Frequency words on each entry control how many calls that entry serves: + +```fbasic +mock screen width + returns 10 once + returns 20 once + returns 5 always +endmock +``` + +- Call 1 → `10` +- Call 2 → `20` +- Call 3 onwards → `5` + +Frequency words: `once`, `n times`, `always`. Default (no frequency word) is `always` — the entry stays in the queue forever and serves every call until the queue is reconfigured. Anything beyond an `always` entry is unreachable and produces a build warning. + +**Behavior entries:** + +- `returns ` — push the value of `` as the command's return. `` is any Fade expression evaluated in the test's scope; it can reference `local`s, mocks, captured globals (post-`runto`), test-functions. +- `forbid` — calling the command at this point fails the test with *"command `screen width` was forbidden by mock at line N."* Useful as a strict-mode terminator: *"after the first call returns 10, no more calls are allowed."* + +That's the v1 surface. Two behaviors plus three frequency words. + +**Exhausted queue → real implementation.** When a command is invoked and the queue is empty (either never set up or fully consumed), the real C# implementation runs. Mocks are an *override*, not a *requirement*. This composes with the rest of the design: math commands and other side-effect-free utilities just work without explicit mocking. Tests that need strict guards add `forbid` as a final entry. + +**Multi-block mocks append.** Re-declaring `mock ` in the same test adds entries to the existing queue rather than replacing it. To wipe and start over: + +```fbasic +clear mock screen width ` empty the queue for one command +clear mocks ` empty all queues +``` + +This is mostly useful between phases of a long test or when overriding a `from`-parent's mocks. + +**All overloads share one queue.** A `mock screen width` configures the queue for every overload of `screen width(...)`. Argument-based dispatch is deferred to a later phase. + +**Per-test isolation.** The mock table lives on the VM. Since each test gets a fresh VM instance, mock state is naturally isolated — no leakage between tests. + +**Mechanism.** When a command-invocation OPCODE fires, the dispatcher consults the mock table first: + +``` +on command_invoke(cmd_id, args): + queue = mockTable[cmd_id] + if queue.empty: + invoke_real_implementation(cmd_id, args) + else: + entry = queue.peek() + match entry: + returns expr → push value of expr; decrement-or-pop + forbid → fail test +``` + +Queue entries hold a remaining-uses counter; `once` is `1`, `n times` is `n`, `always` is `infinity`. Decrement-or-pop drops the entry when its counter hits zero (except `always`, which is never popped). + +### 4. `runto :label` drives the program + +The fundamental time-control primitive. Its name echoes `gosub` / `goto` so the label-targeted nature reads at a glance: "go [forward] until you hit this label." The test pauses at the label; the test body then executes assertions or mutations; another `runto` advances further. + +Two forms — a simple inline form and a block form for additional constraints: + +```fbasic +` simple form +runto :sync + +` block form — extensible +runto :sync + max cycles 1000 +endrunto +``` + +The block form opens the door to future conditions without breaking the simple case. `max cycles N` budgets the number of VM cycles before the test fails — a guard against runaway loops. (Counting VM cycles, not frames, lets the same budget mean the same thing whether the program is in a tight inner loop or rendering at 60 fps.) + +Mocks and `local` declarations can precede the first `runto`. Before the first `runto`, no program top-level code has executed yet — globals declared with `global X = ...` are present at their initial values, but names introduced only by main-body assignments (e.g., bare `x = 5` at top level) are not yet bound and referencing them is an error. This means a typical test reads as: setup mocks → first `runto` enters the program → script execution forward through labels → assert. + +**Targets are any label.** `runto :L` is valid for any label, top-level or inside a function. Stepping into a function and asserting about its state is a first-class use case — tests aren't limited to driving the main loop. + +**Resume is stack-agnostic.** When the program is paused (top-level or mid-function) and the test issues a `runto`, the VM resumes execution as-is from wherever the program was. The program's call stack is honored — functions return naturally, gosubs resolve naturally, the test doesn't reach into the stack to unwind anything. `RUNTO_YIELD` fires whenever the program's IP reaches the target, regardless of how deep the call stack is at that moment. The `max cycles` clause guards against runaway loops or programs that never reach the target. + +### 5. Scope merges at the pause point + +Inside a test block, after a `runto`, identifier resolution is **read-through, write-through** to the paused program's scope: + +- `x` reads the program's x. +- `x = 5` mutates the program's x. +- `local foo = 10` declares a test-only name. New names without `local` also become test-locals; `local` is the explicit, documented form. + +Mental model: it's exactly what you'd type into a debugger console at a breakpoint. The test should feel the same. + +**Strict semantic.** The visible scope after `runto :L` is exactly the set of names that would be in scope at line `:L` if the test code were spliced in there. This is computed statically via a `scope_at(:L)` map. + +For a top-level label, that's *globals + any name declared by main-body execution up to `:L`*. For a label inside a function, that's *the function's parameters + locals declared up to `:L`, plus globals visible at the function's callsites* — exactly what Fade's existing scope checker already computes for any line of any function. The test scope query just asks "what's in scope at this address?" and reuses the answer. + +The semantic is *as if* the test body were spliced into the program at each `runto` point. So this fails type-check: + +```fbasic +x = 12 +:label +x = x + 1 +:later +y = 12 + +test example + runto :label + assert y is x ` ERROR: y not in scope_at(:label) +endtest +``` + +Because spliced in: + +```fbasic +x = 12 +:label +assert y is x ` y is unknown here — only declared after :later +x = x + 1 +:later +y = 12 +``` + +After a second `runto :later`, the visible set updates to `scope_at(:later)`, and `y` becomes visible. Each distinct runto target carries its own scope. + +**Branch rule for declarations.** Following Fade's existing semantics, names introduced in any branch of a top-level `if/else` are considered declared at the merge point — even if a runtime branch could have skipped them. Their value defaults to zero/empty if the assigning branch didn't execute. Example: after `if condition then ta = 3 else tb = 4`, both `ta` and `tb` are declared and visible regardless of which branch ran. + +**Pre-runto function calls.** Calling a program function from a test before any `runto` reuses Fade's existing function-callsite analysis: the function's transitively-read names must all be declared at the callsite. If the program top-level body hasn't run yet, names declared only by main-body assignments aren't yet present, and the test gets the same parse-time error a regular Fade program would for calling a function ahead of its dependencies. No new machinery — the test's `visible` set is just another callsite snapshot fed to the existing check. + +### 6. Tests can declare their own functions + +Tests can define functions for code reuse, scoped just like `local` variables: + +```fbasic +abstract test root + local fakeWidth = 10 + mock screen width + returns fakeWidth + endmock + + function expect_in_bounds() + assert x >= 0 + assert x <= fakeWidth + endfunction +endtest + +test wraps_at_right_edge from root + runto :start + expect_in_bounds() ` carried forward from root + for n = 1 to fakeWidth + 1 + runto :sync + expect_in_bounds() + next +endtest +``` + +Rules: +- A function declared inside a test is visible only within that test and any test that continues `from` it. +- The `from`-chain carries functions forward, the same way it carries `local` variables and mocks. +- A test-scoped function can call program-scope functions normally, but cannot redeclare one (use a different name). +- Test functions can do everything regular Fade functions can — including `assert`, since they only execute inside a test context. + +### 7. Label namespaces are walled off + +Tests can declare their own labels (e.g., `:retry`) and `goto` / `gosub` them freely. They **cannot** `goto` or `gosub` into a program label — that's strictly the job of `runto`. + +```fbasic +test retries_three_times from root + local attempts = 0 + :retry ` test-local label — fine + runto :sync + attempts = attempts + 1 + if attempts < 3 then goto :retry ` jumping within the test — fine + + ` goto :start ` would be an error — :start is a program label + runto :start ` correct way to advance to a program label +endtest +``` + +Rules: +- `goto` / `gosub` inside a test resolve only against test-local labels. +- Targeting a program label with `goto` / `gosub` is a parse-time error — the resolver doesn't fall through. +- `runto` is the only construct that crosses from test scope into program scope. +- Symmetrically, program code can't `goto` or `gosub` into a test — but that's already true since test blocks compile to nothing in run builds. + +This namespace separation means tests can never accidentally jump into the middle of program execution. Every entry into program code is explicit and label-targeted. + +### 8. Compiler foothold + +The model maps cleanly onto the existing parser/checker architecture: + +- **Lex/macro pass.** `test ... endtest` is recognized as a tokenize-flavored region during macro expansion, alongside `#tokenize ... #endtokenize`. Tokens emitted from inside a `test` body are routed to the test manifest, not back into the program token stream. Two desugaring rules apply at this stage: + 1. A top-level `test` (not inside an explicit `#macro`) is wrapped in a synthesized `#macro` block. + 2. Inner `#macro ... #endmacro` blocks written inside a top-level `test` body are hoisted out of the test and into that synthesized wrapper, preserving the no-nested-macros invariant. +- **Parsing.** A new `TestNode` (parallel to `FunctionStatement`) joins `ProgramNode` as a top-level form. Each `TestNode` carries its own `labels`, `functions`, and statement body — same shape as a function. After macro expansion, parameterized tests have already been unrolled into N distinct `TestNode`s with `[name]` substitutions resolved. +- **Scope.** Each test gets its own `Scope`, much like a function does today. The test's scope holds test-local variables, test-local functions, and test-local labels. Identifier resolution after a `runto :L` consults a `scope_at(L)` snapshot — the set of names that would be in scope at line `:L` if you wrote regular Fade code there. For top-level labels, that's globals plus any main-body declaration up to `:L`. For function-internal labels, it's the function's parameters and locals declared up to `:L`, plus globals visible at the function's callsites. Both cases are answered by reusing the existing `ScopeErrorVisitor` result — it already computes what's in scope at every line; tests just query it for runto targets. +- **Checks added to `ScopeErrorVisitor`.** Three new validations on top of the existing `labelTable` / `functionTable` machinery: + 1. `runto :L` targets must exist as a label somewhere in the program — either in `program.labels` or in any `function.labels`. Both are valid. + 2. `goto` / `gosub` inside a test must resolve to a label declared inside that same test (or an upstream test in its `from`-chain). Falling through to program labels is an error. + 3. Identifiers referenced inside a test must resolve against (test-locals + mocks + test-functions + `scope_at(most-recent-runto)`). Before any `runto`, the runto component is empty — only globals declared via `global` are present. Function calls into the program reuse the existing function-callsite analysis with the test's current `visible` set as the callsite snapshot. +- **Runtime: two opcodes, one stack, one constructor parameter.** No context-switching machinery, no second instruction pointer, no breakpoint table. Address-as-data plus the VM's existing jump primitives — defer-flavored, not debugger-flavored. + - **`OpCodes.RUNTO`** (test-side, emitted once per `runto` statement). Pushes `(target_addr, resume_ip)` onto a new `runtoStack` on `VirtualMachine`, then sets `instructionIndex` to where the program is currently paused: the program's `__main` entry on first runto, the saved program IP on subsequent ones. + - **`OpCodes.RUNTO_YIELD`** (program-side, emitted at every label referenced by a `runto` somewhere in the test corpus). When `runtoStack.Peek().target_addr == instructionIndex`, pops the entry, stores the program's current IP into it, and sets `instructionIndex = resume_ip`. Otherwise falls through. In `dotnet run` builds, `RUNTO_YIELD` is omitted entirely — zero production cost. Labels that aren't runto targets carry no overhead in test builds either. + - **`runtoStack`** — a new stack on the VM, same family as `scope.deferredJumps`. Just addresses-on-a-stack. + - **`entryPointAddress` constructor parameter.** Each test's compiled body is a contiguous block in the program blob; its start address lives in the manifest. Running test `foo`: `new VirtualMachine(program, manifest.tests[foo].entryPointAddress)`. Default of `4` is preserved for `dotnet run`. +- **Unified address space.** Test and program bytecode coexist in one `byte[] program` blob. The VM doesn't distinguish contexts; it just executes addresses. `methodStack`, `scopeStack`, `heap` all work as-is — test functions call test functions, program functions call program functions, and the cross-boundary case is handled exclusively by `RUNTO` / `RUNTO_YIELD`. Read/write of program variables by the test goes directly to shared memory; a variable "exists" iff program execution has declared it, type-checked statically by `scope_at(L)` and enforced trivially by memory layout. +- **DAP/debugger flow is preserved.** Because tests run in a single VM with a single `instructionIndex`, attaching the existing debugger works as it does today. Breakpoints in test code and program code both fire normally — they're addresses in the same unified bytecode blob, and the debugger just observes the IP. No multi-target debug session, no new protocol work. The shared-VM model collapses what looked like a multi-process problem into a no-op. +- **Mock dispatch table.** The one place test runs mutate VM-adjacent state, and it's bounded — a single table the command dispatcher consults (per Section 3). Lives at the host boundary, not in the VM core. +- **Manifest emission.** Each `TestNode` emits one entry into a generated `__test_manifest`, including its bytecode `entryPointAddress`, name, optional `from` parent, and source location. Codegen for the test body is mostly the same as a function body, with `runto` lowering to the `RUNTO` opcode described above. + +### 9. C# host state resets via `[FadeTestReset]` + +Per-test VM isolation handles Fade-side state (variables, heap, stacks). It does *not* handle C# host-side state. Real Fade command implementations frequently carry state — static texture caches, connection pools, allocated GPU resources, logging buffers, singleton subsystems. That state survives the VM's death and leaks into the next test. + +For tests against trivial programs (no stateful commands), a fresh VM per test is enough. For real projects, it isn't. + +**The `[FadeTestReset]` attribute.** Command authors mark a static method that clears their state. The test runner auto-invokes every method tagged with this attribute before each test runs. + +```csharp +public partial class FadeCommands { + public static int x = 0; + + [FadeCommand("get and up")] + public static int GetAndUp() { return x++; } + + [FadeCommand("reset get and up")] // optional — makes it Fade-callable too + [FadeTestReset] // auto-invoked before every test + public static void ResetGetAndUp() { x = 0; } +} +``` + +The attribute pattern matches what command authors already know — `[FadeCommand]` is a tag on a static method; `[FadeTestReset]` is another tag on a static method. No new interface, no `IFadeResettable` to inherit from, no `OnTestStart` / `OnTestEnd` lifecycle protocol. Just a method with an attribute. + +**Optional dual role.** Tagging the same method with both `[FadeCommand("reset X")]` and `[FadeTestReset]` makes it Fade-callable *and* auto-invoked. The test author can call it explicitly from a Fade test for fine-grained control; the system auto-invokes it for everyone else. Defaults are automatic; opt-out is one line. + +**Invocation rules.** +- All `[FadeTestReset]` methods are invoked before each top-level test execution, in registration order. +- For a `from`-chain (e.g., `test child from root`), resets fire once at the start of the chain, then `root`'s body runs, then `child`'s body runs. Resets do not re-fire between parent and child within one execution — the chain is one logical scenario. +- If a reset throws, the test fails fast with `"reset for X failed: ..."` rather than running with stale state. + +**Detection.** At command-registration time, walk each `[FadeCommand]`-bearing class for mutable static fields. If a class has any AND no method on it carries `[FadeTestReset]`, emit a build warning: + +> *Command class `FadeCommands` has mutable static fields but no `[FadeTestReset]` method — tests using these commands may interfere with each other.* + +Detection isn't perfect (a `static Dictionary` looks "mutable" but the relevant state lives in the handles, not the dictionary; only the author knows which it is), but "any non-readonly static field" catches the overwhelmingly common case. Strict projects can promote the warning to an error via a project setting. + +**Sequential.** Tests run one at a time. Resets fire deterministically before each test; only one test touches host state at a time. Parallel execution is out of scope. + +## Open questions + +What's resolved: + +- **Runtime / coroutine implementation.** Two new opcodes (`RUNTO`, `RUNTO_YIELD`) plus one `runtoStack` plus an `entryPointAddress` constructor parameter. No second IP, no breakpoint table, no second VM. See Section 8. +- **Debugger model.** Single VM means the existing DAP flow continues to work without modification. No multi-target debug session needed. +- **Pre-runto function calls.** Reuses the existing function-callsite analysis. The test's `visible` set is just another callsite snapshot — error surfaces at the same place a regular Fade program would error. +- **Cross-file label resolution.** Non-issue. Files concat to one stream at lex time; labels resolve in the unified stream. +- **Parameterized test syntax.** Resolved by tests-as-tokenize-flavored-regions composing with `#macro` for-loops. No special parameterized-test grammar. +- **`runto` across stack frames.** Stack-agnostic. The VM resumes execution as-is from wherever the program was paused; the call stack is honored; `RUNTO_YIELD` fires whenever the IP reaches the target regardless of stack depth. `max cycles` guards against runaway cases. +- **Runto targets.** Any label in the program — top-level or function-internal. Tests can step into a function and assert about its state. `scope_at(:L)` adapts: top-level labels see globals + main-body declarations; function-internal labels see the function's locals plus globals visible at callsites. Both reuse the existing scope checker's per-line scope answer. +- **Mock model (v1).** FIFO queue of behavior entries; `returns ` and `forbid`; frequency words `once`, `n times`, `always` (default `always`); multi-block `mock` declarations append; `clear mock` / `clear mocks` reset; exhausted queue falls through to the real C# implementation; all overloads share a queue. See Section 3. +- **C# host state reset.** `[FadeTestReset]` attribute on a static method, auto-invoked before each test; optionally dual-tagged with `[FadeCommand]` to be Fade-callable too. Build-time warning when a `[FadeCommand]`-bearing class has mutable static fields with no `[FadeTestReset]` method. Sequential execution; parallelism out of scope. See Section 9. + +Still open: + +1. **Mock extensions beyond v1.** Section 3 lands the v1 surface: FIFO queue, `returns`/`forbid`, frequency words (`once`, `n times`, `always`), `clear mock`/`clear mocks`, exhausted-queue fall-through, all-overloads share a queue. Deferred to a later phase: argument matching (`mock screen width when w > 100`), per-overload disambiguation (`mock screen width(int)`), `body` blocks (Fade code computing the return), `passthrough` keyword (explicit fall-through entry), spy-style call recording for assertions. None of these block v1; they layer on cleanly when needed. +2. **Test discovery for IDE Test Explorer.** Manifest needs source locations so VS / Rider gutter buttons land correctly. For parameterized tests generated via `#macro` loops, locations should point at the originating `test` line, not the expanded output. Probably reuses Fade's existing macro source-mapping. +3. **Failure source-mapping for macro-generated tests.** When `assert` fails inside a macro-generated test, the failure must point at the originating `test` line (and ideally the iteration values via `[name]` substitution preserved in the message), not at the unfathomable expanded location. +4. **`from`-chain implementation.** Section 2 documents replay vs snapshot as observable-equivalent strategies. Replay is the chosen default. Snapshot is deferred until a perf measurement says it matters — but the contract should already be defined in terms of observable behavior so either implementation remains valid. +5. **`endrunto` block clauses beyond `max cycles`.** The block form is extensible by design, but no other clauses are spec'd. Candidates as needs emerge: `unless `, `while `, `record events to `, `forbid command X`. Keep deferred until a real test feels the gap. +6. **Runto failure error messages.** When `max cycles` is exceeded, the failure message should capture the program's call stack at the time of failure — *"`runto :sync` exhausted budget; program was inside `update_position()` at the time"* — so the user can see *why* the runto didn't complete. Small piece of runtime plumbing. + +Pending decisions, lower priority: + +- Whether the `assert` macro should support custom assertion words (`assert close`, `assert in_range`, `assert call_order`) at v1 or evolve them later. diff --git a/FadeBasic/Tests/ArraySpreadParamsTests.cs b/FadeBasic/Tests/ArraySpreadParamsTests.cs new file mode 100644 index 0000000..251e750 --- /dev/null +++ b/FadeBasic/Tests/ArraySpreadParamsTests.cs @@ -0,0 +1,165 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class ArraySpreadParamsTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunMain(string src) + { + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + vm.Execute3(); + return vm; + } + + private List ParseErrors(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + return prog.GetAllErrors(); + } + + [Test] + public void Spread_IntArray_IntoSumParams_AddsCorrectly() + { + // `sum(params int[])` in TestCommands returns the sum. Spreading + // a 3-element int array should produce the same result as calling + // sum(10, 20, 30). + var src = @" +dim xs(3) +xs(0) = 10 +xs(1) = 20 +xs(2) = 30 +n = sum(xs) +"; + var vm = RunMain(src); + // The `n` global register should hold 60. + // dataRegisters[register for n] = 60. We don't know the exact + // register here; assert via the program's debug view. Simpler: + // use a known TestCommands hook. But the cleanest: re-bind and + // check via the static-print buffer. Let me just inspect register 0 + // since n is the first declaration after the array. + // Actually we can ask via the runtime: look up by scope. + var found = false; + for (var i = 0; i < vm.globalScope.dataRegisters.Length; i++) + { + if (vm.globalScope.dataRegisters[i] == 60) + { + found = true; + break; + } + } + Assert.That(found, Is.True, "expected sum(xs) = 60 stored somewhere in globals"); + } + + [Test] + public void Spread_EmptyArray_PushesZeroCount() + { + // `sum` on a 0-element array sums to 0. + var src = @" +dim xs(0) +n = sum(xs) +"; + var vm = RunMain(src); + // n should be 0; that's the default so we just check no crash. + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + } + + [Test] + public void Spread_InlineAndArray_BothWork() + { + // The existing inline-list call shape must still work alongside + // the new spread shape. + var src = @" +dim xs(2) +xs(0) = 5 +xs(1) = 7 +a = sum(xs) ` spread +b = sum(1, 2, 3) ` inline +"; + var vm = RunMain(src); + // a should be 12, b should be 6 — both live in globals. + var foundA = false; var foundB = false; + for (var i = 0; i < vm.globalScope.dataRegisters.Length; i++) + { + if (vm.globalScope.dataRegisters[i] == 12) foundA = true; + if (vm.globalScope.dataRegisters[i] == 6) foundB = true; + } + Assert.That(foundA, Is.True, "expected sum(xs) = 12"); + Assert.That(foundB, Is.True, "expected sum(1,2,3) = 6"); + } + + [Test] + public void Spread_RankTwoArray_Errors() + { + // 2D arrays can't be spread. + var src = @" +dim xs(3, 2) +n = sum(xs) +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ParamsArrayMustBeRankOne)), + Is.True, + "expected ParamsArrayMustBeRankOne; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Spread_StringArray_IntoObjectParams_Works() + { + // `static print` is `params object[]` (TypeCode.ANY at the params + // slot). Spreading a string array into it should NOT error — + // an object[] params slot accepts any element type, matching the + // same tolerance the inline-arg path grants. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +dim x$(2) +x$(0) = ""a"" +x$(1) = ""b"" +static print x$ +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ParamsArrayElementTypeMismatch)), + Is.False, + "expected no ParamsArrayElementTypeMismatch on params object[]; got: " + + string.Join(", ", errs.Select(e => e.Display))); + + RunMain(src); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "a", "b" }), + "string-array spread into params object[] should print each element"); + } + + [Test] + public void Spread_MixingArrayAndInline_Errors() + { + // Can't mix `Foo(arr, 99)` — array spread is exclusive at the + // params position. + var src = @" +dim xs(2) +xs(0) = 5 +xs(1) = 7 +n = sum(xs, 99) +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ParamsCannotMixArrayWithInline)), + Is.True, + "expected ParamsCannotMixArrayWithInline; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/AssertMacroTests.cs b/FadeBasic/Tests/AssertMacroTests.cs new file mode 100644 index 0000000..4ad9316 --- /dev/null +++ b/FadeBasic/Tests/AssertMacroTests.cs @@ -0,0 +1,525 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Sdk; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class AssertMacroTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + // Generate debug data so stack-frame resolution works in tests that + // exercise FadeTestExecutor.BuildFrames; the SDK enables this by default. + var compiler = new Compiler(TestCommands.CommandsForTesting, + new CompilerOptions { GenerateDebugData = true }); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunTest(string src, string testName) + { + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == testName); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + // Mirror the SDK test runner so assert behavior matches production: + // failures record TestFailure instead of throwing. + vm.isTestExecution = true; + vm.Execute3(); + return vm; + } + + private VirtualMachine RunMain(string src) + { + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + return vm; + } + + [Test] + public void Assert_True_TestPasses() + { + var src = @" +test foo + assert 1 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null, "assert 1 should pass"); + } + + [Test] + public void Assert_NonZeroExpression_TestPasses() + { + var src = @" +test foo + local x as integer = 5 + assert x +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void Assert_Zero_TestFails() + { + var src = @" +test foo + assert 0 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null, "assert 0 should fail the test"); + } + + [Test] + public void Assert_FalseExpression_TestFails() + { + var src = @" +test foo + local x as integer = 5 + assert x = 6 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + } + + [Test] + public void Assert_TrueComparison_TestPasses() + { + var src = @" +test foo + local x as integer = 5 + assert x = 5 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void Assert_Failure_CapturesSourceText() + { + var src = @" +test foo + local x as integer = 5 + assert x = 99 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + // Source text should mention `x` and `99` somewhere. + Assert.That(vm.assertionFailure.sourceText, Does.Contain("x")); + Assert.That(vm.assertionFailure.sourceText, Does.Contain("99")); + } + + [Test] + public void Assert_FailureHaltsExecution() + { + // After a failed assert, subsequent statements should not execute. + // We verify by making the failing assert come BEFORE another statement + // that would otherwise alter VM state observably. Since we can only + // observe via assertionFailure (no execution-side-effect to Fade-side) + // for now, we settle for: failure halt means no second assert runs. + var src = @" +test foo + assert 0 + assert 1 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null, + "first assert fails — second assert should not run, but failure should be present"); + } + + [Test] + public void Assert_OutsideTest_Passing_RunsWithoutCrash() + { + // A truthy assert in the main program runs cleanly — no VM crash, no + // assertionFailure recorded. + var src = @" +assert 1 +end +"; + var vm = RunMain(src); + Assert.That(vm.assertionFailure, Is.Null); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + } + + [Test] + public void Assert_OutsideTest_Failing_CrashesVm() + { + // A failing assert in the main program triggers a VM runtime crash + // (VirtualRuntimeException), the same shape as divide-by-zero etc. + var src = @" +assert 0, ""kaboom"" +end +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + var ex = Assert.Throws(() => vm.Execute3()); + Assert.That(ex.Error.type, Is.EqualTo(VirtualRuntimeErrorType.ASSERT_FAILED)); + Assert.That(ex.Error.message, Does.Contain("kaboom"), + "crash message should surface the assert's reason"); + } + + [Test] + public void Assert_OutsideTest_Failing_NoReason_StillCrashesVm() + { + var src = @" +assert 0 +end +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + var ex = Assert.Throws(() => vm.Execute3()); + Assert.That(ex.Error.type, Is.EqualTo(VirtualRuntimeErrorType.ASSERT_FAILED)); + } + + [Test] + public void Assert_InMainProgram_ViaRunto_FailsTheTest() + { + // When a test runtos into main-program code that contains a failing + // assert, the test should record the failure (not crash the VM). + var src = @" +assert 0, ""main-program assert"" +checkpoint: +end + +test runto_test + runto checkpoint +endtest +"; + var vm = RunTest(src, "runto_test"); + Assert.That(vm.assertionFailure, Is.Not.Null, + "main-program assert reached via runto must mark the test as failed"); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("main-program assert")); + } + + [Test] + public void Assert_InMainProgram_ViaRunto_Passing_DoesNotFailTest() + { + var src = @" +assert 1 +checkpoint: +end + +test runto_test + runto checkpoint +endtest +"; + var vm = RunTest(src, "runto_test"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void Assert_TwoSequentialPasses_BothExecute() + { + var src = @" +test foo + assert 1 + assert 2 + assert 1 + 1 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void Assert_WithReasonLiteral_FailureCapturesReason() + { + var src = @" +test foo + local x as integer = 5 + assert x = 99, ""x should be 99"" +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("x should be 99")); + Assert.That(vm.assertionFailure.sourceText, Does.Contain("x")); + } + + [Test] + public void Assert_WithReasonLiteral_PassDoesNotPopulateReason() + { + // When the assert passes, no failure is recorded — and the reason + // expression must not have run side-effects (no observable way to + // check from a pure-eval literal, but at minimum no failure exists). + var src = @" +test foo + assert 1 = 1, ""never seen"" +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void Assert_WithReasonVariable_FailureCapturesReason() + { + var src = @" +test foo + local msg as string = ""boom"" + assert 0, msg +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("boom")); + } + + [Test] + public void Assert_WithoutReason_HasEmptyReason() + { + var src = @" +test foo + assert 0 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("")); + } + + [Test] + public void Assert_ReasonMustBeString_NonStringReportsError() + { + // Passing a non-string reason (here, an integer) should fail + // type-checking, surfacing AssertReasonMustBeString. + var src = @" +test foo + assert 1 = 1, 42 +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var allErrors = prog.GetAllErrors(); + Assert.That(allErrors.Any(e => e.errorCode.code == ErrorCodes.AssertReasonMustBeString.code), + "expected AssertReasonMustBeString error; got: " + + string.Join(", ", allErrors.Select(e => e.errorCode.code.ToString()))); + } + + // ── Defer-on-assert-failure tests ────────────────────────────────────── + // These verify the unwind trampoline: on a failed assert inside a test, + // every live scope's defers run (LIFO), then the failure is reported. + // Main-program asserts (running standalone) deliberately skip defers. + + [Test] + public void Assert_TestBodyDefer_RunsOnFailure() + { + TestCommands.staticPrintBuffer.Clear(); + var src = @" +test defer_runs_on_fail + defer static print ""cleanup"" + assert 0, ""boom"" +endtest +"; + var vm = RunTest(src, "defer_runs_on_fail"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("boom")); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "cleanup" }), + "test-body defer must run during assert-unwind"); + } + + [Test] + public void Assert_TestBodyDefers_RunInLifoOrder() + { + TestCommands.staticPrintBuffer.Clear(); + var src = @" +test multi_defer + defer static print ""a"" + defer static print ""b"" + defer static print ""c"" + assert 0 +endtest +"; + var vm = RunTest(src, "multi_defer"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "c", "b", "a" }), + "defers must run in LIFO order during unwind"); + } + + [Test] + public void Assert_FunctionDefer_AndTestDefer_BothRun() + { + TestCommands.staticPrintBuffer.Clear(); + var src = @" +function helper() + defer static print ""func"" + assert 0, ""inside helper"" +endfunction + +test cross_scope + defer static print ""test"" + helper() +endtest +"; + var vm = RunTest(src, "cross_scope"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("inside helper")); + // helper's scope drains first (innermost), then test's scope. + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "func", "test" }), + "function-scope defers must drain before unwinding back to test scope"); + } + + [Test] + public void Assert_PassingTest_StillRunsDefers() + { + // Sanity: defers also run on the success path (existing behavior; + // this just guards against the trampoline accidentally bypassing + // normal scope-exit defer draining for passing tests). + TestCommands.staticPrintBuffer.Clear(); + var src = @" +test passing_with_defer + defer static print ""cleanup"" + assert 1 +endtest +"; + var vm = RunTest(src, "passing_with_defer"); + Assert.That(vm.assertionFailure, Is.Null); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "cleanup" })); + } + + [Test] + public void Assert_MainProgramFailure_SkipsDefers() + { + // Non-test execution: a failed assert is a hard crash; defers do + // NOT run (matches divide-by-zero etc.). + TestCommands.staticPrintBuffer.Clear(); + var src = @" +defer static print ""never seen"" +assert 0, ""crash"" +end +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + Assert.Throws(() => vm.Execute3()); + Assert.That(TestCommands.staticPrintBuffer, Is.Empty, + "main-program assert is a hard crash; defers must not run"); + } + + // ── Call-stack capture & source-location resolution ──────────────────── + // These exercise BuildFrames against a real compile+run so we know the + // VM's methodStack snapshot survives the unwind and that DebugData + // resolves it to the expected lines. + + private FadeTestResult RunTestThroughExecutor(string src, string testName) + { + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == testName); + return FadeTestExecutor.RunTest(program, compiler.methodTable, entry, compiler.DebugData); + } + + [Test] + public void Assert_StackTrace_ReportsAssertLine_NotTestLine() + { + // Mirrors the user-reported scenario: assert lives inside a function + // called from the main program, which is reached via runto from a + // test. The innermost frame must point at the assert's actual line, + // not the test entry's line. Line numbers are 0-based (lexer's + // coordinate space); displayed as 1-based by adapters that add 1. + var src = @"function ex(x) + assert x > 0, ""x must be positive"" +endfunction +ex(0) +checkpoint: +end + +test sample + runto checkpoint +endtest +"; + var result = RunTestThroughExecutor(src, "sample"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureFrames, Is.Not.Empty, + "DebugData was enabled — frames should resolve"); + + // Innermost frame = the assert inside `ex`. Source line 1 (0-based), + // which is line 2 when displayed. + var innermost = result.failureFrames[0]; + Assert.That(innermost.functionName, Is.EqualTo("ex")); + Assert.That(innermost.lineNumber, Is.EqualTo(1)); + } + + [Test] + public void Assert_StackTrace_IncludesCallerOfFunction() + { + // The frame above the assert is the caller (`ex(0)`), on 0-based + // line 3 (displayed as line 4). + var src = @"function ex(x) + assert x > 0, ""x must be positive"" +endfunction +ex(0) +checkpoint: +end + +test sample + runto checkpoint +endtest +"; + var result = RunTestThroughExecutor(src, "sample"); + Assert.That(result.failureFrames.Count, Is.GreaterThanOrEqualTo(2)); + + var outermost = result.failureFrames[^1]; + Assert.That(outermost.functionName, Is.Empty, + "outermost frame has no function name (it's the main program / test entry)"); + Assert.That(outermost.lineNumber, Is.EqualTo(3)); + } + + [Test] + public void Assert_StackTrace_AssertInTestBody_OneFrame() + { + // No function calls — the entire failure is at the test entry level. + // We still get one frame (the assert site at 0-based line 1). + var src = @"test sample + assert 0, ""boom"" +endtest +"; + var result = RunTestThroughExecutor(src, "sample"); + Assert.That(result.failureFrames, Is.Not.Empty); + Assert.That(result.failureFrames.Count, Is.EqualTo(1)); + Assert.That(result.failureFrames[0].functionName, Is.Empty); + Assert.That(result.failureFrames[0].lineNumber, Is.EqualTo(1)); + } + + [Test] + public void Assert_StackTrace_EmptyWhenNoDebugData() + { + // Without DebugData the runner can't resolve frames; failureFrames + // stays empty and the adapter falls back to entry.sourceLine. + var src = @"test sample + assert 0 +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, + new CompilerOptions { GenerateDebugData = false }); + compiler.Compile(prog); + + var entry = compiler.TestManifest.First(t => t.name == "sample"); + var result = FadeTestExecutor.RunTest( + compiler.Program.ToArray(), compiler.methodTable, entry, debugData: null); + + Assert.That(result.passed, Is.False); + Assert.That(result.failureFrames, Is.Empty); + } +} diff --git a/FadeBasic/Tests/BenchmarkCompilerTests.cs b/FadeBasic/Tests/BenchmarkCompilerTests.cs new file mode 100644 index 0000000..409686c --- /dev/null +++ b/FadeBasic/Tests/BenchmarkCompilerTests.cs @@ -0,0 +1,71 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; +using System.Linq; + +namespace Tests; + +[TestFixture] +public class BenchmarkCompilerTests +{ + private static CommandCollection Commands() => new CommandCollection(new FadeBasicCommands()); + + private static (LexerResults lex, ProgramNode prog, List bytecode) Compile(string src) + { + var commands = Commands(); + var lex = new Lexer().TokenizeWithErrors(src, commands); + var stream = new TokenStream(lex.tokens, lex.tokenErrors); + var prog = new Parser(stream, commands).ParseProgram(); + var compiler = new Compiler(commands, CompilerOptions.Default); + compiler.Compile(prog); + return (lex, prog, compiler.Program); + } + + [Test] + public void Short_NoCompileErrors() + { + var (_, prog, _) = Compile(BenchmarkCorpus.Short); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.EqualTo(0), + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Short_ProducesBytecode() + { + var (_, _, bytecode) = Compile(BenchmarkCorpus.Short); + Assert.That(bytecode.Count, Is.GreaterThan(0)); + } + + [Test] + public void Medium_NoCompileErrors() + { + var (_, prog, _) = Compile(BenchmarkCorpus.Medium); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.EqualTo(0), + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Medium_ProducesBytecode() + { + var (_, _, bytecode) = Compile(BenchmarkCorpus.Medium); + Assert.That(bytecode.Count, Is.GreaterThan(0)); + } + + [Test] + public void Large_NoCompileErrors() + { + var (_, prog, _) = Compile(BenchmarkCorpus.Large); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.EqualTo(0), + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Large_ProducesBytecode() + { + var (_, _, bytecode) = Compile(BenchmarkCorpus.Large); + Assert.That(bytecode.Count, Is.GreaterThan(0)); + } +} diff --git a/FadeBasic/Tests/BenchmarkCorpusTests.cs b/FadeBasic/Tests/BenchmarkCorpusTests.cs new file mode 100644 index 0000000..4529aff --- /dev/null +++ b/FadeBasic/Tests/BenchmarkCorpusTests.cs @@ -0,0 +1,71 @@ +using FadeBasic; +using FadeBasic.Ast; +using System.Linq; + +namespace Tests; + +[TestFixture] +public class BenchmarkCorpusTests +{ + private static CommandCollection Commands() => new CommandCollection(new FadeBasicCommands()); + + private static (LexerResults lex, ProgramNode prog) Compile(string src) + { + var commands = Commands(); + var lex = new Lexer().TokenizeWithErrors(src, commands); + var stream = new TokenStream(lex.tokens, lex.tokenErrors); + var prog = new Parser(stream, commands).ParseProgram(); + return (lex, prog); + } + + [Test] + public void Short_NoLexErrors() + { + var (lex, _) = Compile(BenchmarkCorpus.Short); + Assert.That(lex.tokenErrors?.Count ?? 0, Is.EqualTo(0), + string.Join(", ", lex.tokenErrors?.Select(e => e.Display) ?? Enumerable.Empty())); + } + + [Test] + public void Short_NoParseErrors() + { + var (_, prog) = Compile(BenchmarkCorpus.Short); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.EqualTo(0), + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Medium_NoLexErrors() + { + var (lex, _) = Compile(BenchmarkCorpus.Medium); + Assert.That(lex.tokenErrors?.Count ?? 0, Is.EqualTo(0), + string.Join(", ", lex.tokenErrors?.Select(e => e.Display) ?? Enumerable.Empty())); + } + + [Test] + public void Medium_NoParseErrors() + { + var (_, prog) = Compile(BenchmarkCorpus.Medium); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.EqualTo(0), + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Large_NoLexErrors() + { + var (lex, _) = Compile(BenchmarkCorpus.Large); + Assert.That(lex.tokenErrors?.Count ?? 0, Is.EqualTo(0), + string.Join(", ", lex.tokenErrors?.Select(e => e.Display) ?? Enumerable.Empty())); + } + + [Test] + public void Large_NoParseErrors() + { + var (_, prog) = Compile(BenchmarkCorpus.Large); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.EqualTo(0), + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/BenchmarkVmTests.cs b/FadeBasic/Tests/BenchmarkVmTests.cs new file mode 100644 index 0000000..3ba9f2f --- /dev/null +++ b/FadeBasic/Tests/BenchmarkVmTests.cs @@ -0,0 +1,95 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; +using System.Linq; + +namespace Tests; + +[TestFixture] +public class BenchmarkVmTests +{ + private static CommandCollection Commands() => new CommandCollection(new FadeBasicCommands()); + + private static (byte[] bytecode, HostMethodTable methodTable) Compile(string src) + { + var commands = Commands(); + var lex = new Lexer().TokenizeWithErrors(src, commands); + var stream = new TokenStream(lex.tokens, lex.tokenErrors); + var prog = new Parser(stream, commands).ParseProgram(); + var compiler = new Compiler(commands, CompilerOptions.Default); + compiler.Compile(prog); + return (compiler.Program.ToArray(), compiler.methodTable); + } + + private static VirtualMachine Run(string src) + { + var (bytecode, methodTable) = Compile(src); + var vm = new VirtualMachine(bytecode); + vm.hostMethods = methodTable; + vm.Execute3(0); + return vm; + } + + private static VirtualMachine RunBudgeted(string src, int budget) + { + var (bytecode, methodTable) = Compile(src); + var vm = new VirtualMachine(bytecode); + vm.hostMethods = methodTable; + while (vm.instructionIndex < vm.program.Length && + vm.error.type == VirtualRuntimeErrorType.NONE) + vm.Execute3(budget); + return vm; + } + + // ── Full-run tests ─────────────────────────────────────────────────────── + + [Test] + public void Short_RunsToCompletion() + { + var vm = Run(BenchmarkCorpus.Short); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + Assert.That(vm.instructionIndex, Is.GreaterThanOrEqualTo(vm.program.Length)); + } + + [Test] + public void Medium_RunsToCompletion() + { + var vm = Run(BenchmarkCorpus.Medium); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + Assert.That(vm.instructionIndex, Is.GreaterThanOrEqualTo(vm.program.Length)); + } + + [Test] + public void Large_RunsToCompletion() + { + var vm = Run(BenchmarkCorpus.Large); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + Assert.That(vm.instructionIndex, Is.GreaterThanOrEqualTo(vm.program.Length)); + } + + // ── Budgeted tight-loop tests ──────────────────────────────────────────── + + [Test] + public void Short_Budget100_RunsToCompletion() + { + var vm = RunBudgeted(BenchmarkCorpus.Short, 100); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + Assert.That(vm.instructionIndex, Is.GreaterThanOrEqualTo(vm.program.Length)); + } + + [Test] + public void Medium_Budget100_RunsToCompletion() + { + var vm = RunBudgeted(BenchmarkCorpus.Medium, 100); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + Assert.That(vm.instructionIndex, Is.GreaterThanOrEqualTo(vm.program.Length)); + } + + [Test] + public void Large_Budget100_RunsToCompletion() + { + var vm = RunBudgeted(BenchmarkCorpus.Large, 100); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + Assert.That(vm.instructionIndex, Is.GreaterThanOrEqualTo(vm.program.Length)); + } +} diff --git a/FadeBasic/Tests/CompletionHandlerTests.cs b/FadeBasic/Tests/CompletionHandlerTests.cs new file mode 100644 index 0000000..c5ed5e3 --- /dev/null +++ b/FadeBasic/Tests/CompletionHandlerTests.cs @@ -0,0 +1,191 @@ +// Behavioral tests for CompletionHandler.Compute. The completion path +// is multi-layered (LSPUtil.GetCompletions switch + our cursor-position +// rescues + safety-net fallback) and previously had no test coverage — +// each fix surfaces a new edge case in the other layers. These tests +// pin down the cases that have shipped regressions so we can iterate +// the handler without re-breaking the same flows. +// +// Test commands come from TestCommands.CommandsForTesting (single-word +// commands like `print`/`inc`/`add` + multi-word commands like +// `wait key`, `wait ms`, `screen width`, `any input`). All assertions +// are about LABELS — the wire shape isn't relevant here, just whether +// the expected items make it past the routing. + +using System.Linq; +using FadeBasic.LSP.Core; +using FadeBasic.LSP.Core.Handlers; +using NUnit.Framework; + +namespace Tests +{ + [TestFixture] + public class CompletionHandlerTests + { + private static FadeDocument BuildDoc(string source) + { + var workspace = new FadeWorkspace(TestCommands.CommandsForTesting); + return workspace.SetDocument("test://completion.fbasic", source); + } + + // Run Compute on a source string with `|` marking the cursor. + // Less error-prone than passing raw (line, char) tuples in every + // test, and the cursor marker stays visually adjacent to the + // surrounding code in the test source. + private static System.Collections.Generic.List CompleteAt(string sourceWithCursor) + { + var cursorIdx = sourceWithCursor.IndexOf('|'); + Assert.That(cursorIdx, Is.GreaterThanOrEqualTo(0), "test source must contain a '|' cursor marker"); + var source = sourceWithCursor.Remove(cursorIdx, 1); + // Walk to (line, char) — 0-based LSP positions. + int line = 0, ch = 0; + for (var i = 0; i < cursorIdx; i++) + { + if (source[i] == '\n') { line++; ch = 0; } + else { ch++; } + } + var doc = BuildDoc(source); + return CompletionHandler.Compute(doc, line, ch); + } + + private static bool HasLabel(System.Collections.Generic.IEnumerable items, string label) + { + return items.Any(i => i.Label == label); + } + + // ─── Statement-start contexts (commands should be visible) ────── + + [Test] + public void Cursor_OnEmptyLineAfterAssignment_ReturnsCommands() + { + // Plain `EndStatement` case in the AST switch: leftToken is + // the newline, group is ProgramNode. GetStatementCompletions + // fires directly. + var items = CompleteAt("x = 5\n|"); + Assert.That(HasLabel(items, "print"), Is.True, "expected `print` after newline"); + Assert.That(HasLabel(items, "wait key"), Is.True, "expected multi-word `wait key` after newline"); + } + + [Test] + public void Cursor_AtStartOfDocument_ReturnsCommands() + { + // Bare empty document — leftToken is null/missing. Today the + // handler short-circuits to empty when there's no leftToken. + // Captured as a baseline; if we ever change the early-out, + // this test catches it. + var items = CompleteAt("|"); + Assert.That(items, Is.Empty, "empty doc: no completions because there's no leftToken"); + } + + [Test] + public void Cursor_AfterSingleLetterAtLineStart_FallbackReturnsCommands() + { + // User typed `s` from scratch. AST routes to an unfinished + // AssignmentStatement; GetAssignmentCompletions early-returns + // because LeftToken isn't `=`. The safety-net fallback in + // CompletionHandler.Compute should kick in and surface the + // statement-level command list. Monaco then filters by what + // was typed. + var items = CompleteAt("s|"); + Assert.That(items, Is.Not.Empty, "fallback should populate something for `s|`"); + Assert.That(HasLabel(items, "screen width"), Is.True, + "expected at least one `s...` command via the safety-net fallback"); + } + + // ─── End-of-complete-command-word (Case A: list commands) ────── + + [Test] + public void Cursor_AtEndOfSingleWordCommand_ReturnsStatementCompletions() + { + // `print` is a complete command — lexer rewrites the token to + // CommandWord. cursorAtCommandEnd branch fires; we add the + // statement-level command list so Monaco can keep filtering. + var items = CompleteAt("print|"); + Assert.That(HasLabel(items, "print"), Is.True); + // Multiple commands should be in the list for filtering. + Assert.That(items.Count, Is.GreaterThan(1), + "expected full command list at end-of-CommandWord, got just one"); + } + + [Test] + public void Cursor_AtEndOfMultiWordCommand_ReturnsStatementCompletions() + { + // `wait key` is a complete two-word command. The lexer + // collapses both tokens into one CommandWord. Same rescue + // path as the single-word case. + var items = CompleteAt("wait key|"); + Assert.That(HasLabel(items, "wait key"), Is.True); + Assert.That(HasLabel(items, "wait ms"), Is.True); + } + + // ─── Past-end-of-command (Case B: arg-slot variables) ────────── + + [Test] + public void Cursor_AfterSingleWordCommandAndSpace_ReturnsArgVariables() + { + // `inc` takes (ref int variable, int amount = 1). After + // `inc ` the user is in the first-arg slot — int variables + // in scope should be suggested. + var items = CompleteAt("a = 4\ninc |"); + Assert.That(HasLabel(items, "a"), Is.True, + "expected int variable `a` in first-arg-slot of `inc `"); + } + + [Test] + public void Cursor_AfterCompleteCommandAndSpace_SuppressesMultiWordContinuations() + { + // `inc` is also a prefix of no other commands in TestCommands, + // so this is mostly a regression-safety test. The multi-word + // rescue should be SKIPPED when leftToken is a complete + // CommandWord and the prefix ends in a space — otherwise it'd + // outrank the variables. (If we ever add a command that + // happens to start with `inc `, this test will help us + // remember to verify ordering.) + var items = CompleteAt("a = 4\ninc |"); + Assert.That(HasLabel(items, "a"), Is.True); + } + + // ─── Partial multi-word command (continuation suggestions) ───── + + [Test] + public void Cursor_AfterPartialMultiWordCommandWithSpace_ReturnsContinuations() + { + // `wait` alone is NOT a registered command — only `wait key` + // and `wait ms` are. The lexer leaves `wait` as a plain + // identifier. The multi-word prefix rescue should fire and + // surface both continuations. + var items = CompleteAt("wait |"); + Assert.That(HasLabel(items, "wait key"), Is.True); + Assert.That(HasLabel(items, "wait ms"), Is.True); + } + + [Test] + public void Cursor_AfterPartialMultiWordCommand_NoTrailingSpace_ReturnsContinuations() + { + // `screen` is a partial of `screen width`. With no trailing + // space we expect to still see `screen width` so Monaco can + // filter as the user types. + var items = CompleteAt("screen|"); + // `screen` itself isn't a CommandWord here (no leaf node); + // the multi-word rescue is what would surface it — but its + // condition is `prefix.Contains(' ')`, which is false for the + // single word `screen`. The safety-net fallback ought to + // catch this: portable is empty → fall back to statement + // completions → `screen width` appears for Monaco to filter. + Assert.That(HasLabel(items, "screen width"), Is.True, + "expected `screen width` via the safety-net fallback when typing the prefix `screen`"); + } + + // ─── Symbol-visibility rules ──────────────────────────────────── + + [Test] + public void Variable_DeclaredAfterCursor_NotSuggestedAsArg() + { + // GetSymbolCompletions skips symbols whose declaration is + // strictly AFTER the cursor (IsLocationBefore check). Verify + // the rule survives our rescue path. + var items = CompleteAt("inc |\ny = 7"); + Assert.That(HasLabel(items, "y"), Is.False, + "variable declared after the cursor should not be offered"); + } + } +} diff --git a/FadeBasic/Tests/CooperativePumpTests.cs b/FadeBasic/Tests/CooperativePumpTests.cs new file mode 100644 index 0000000..6266148 --- /dev/null +++ b/FadeBasic/Tests/CooperativePumpTests.cs @@ -0,0 +1,217 @@ +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Lib.Web; +using FadeBasic.Sdk; +using FadeBasic.Virtual; +using NUnit.Framework; + +namespace Tests +{ + // Validates the cooperative-pump architecture end-to-end: + // 1. WebCommands.Prompt fires HostBridge.PostMessage with the + // right channel + payload (so plugin authors can rely on the + // contract). + // 2. HostBridge.SuspendVm pauses execution mid-program. + // 3. After a deposit, the next tick resumes and the deposited + // value lands in the destination variable (proving the + // placeholder swap works end-to-end). + // + // These tests don't need a browser or Export.Web — they exercise + // the platform-neutral parts of the model (HostBridge in core, + // HostStackOps in core, WebCommands in Lib.Web). + [TestFixture] + public class CooperativePumpTests + { + [SetUp] + public void ResetBridge() + { + // HostBridge slots are static. Reset between tests so a + // failure in one doesn't bleed into the next. + HostBridge.PostMessage = null; + HostBridge.SuspendVm = null; + // The print buffer is a static StringBuilder on WebCommands; + // drain anything left over from a previous test. + WebCommands.DrainPrintBuffer(); + } + + private static FadeRuntimeContext Compile(string src) + { + var commands = new CommandCollection(new WebCommands()); + var ok = Fade.TryCreateFromString(src, commands, out var ctx, out var errors); + Assert.That(ok, Is.True, + "compile failed: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + [Test] + public void Prompt_FiresPostMessage_WithRightChannelAndPayload() + { + var channels = new List(); + var payloads = new List(); + HostBridge.PostMessage = (ch, p) => { channels.Add(ch); payloads.Add(p); }; + HostBridge.SuspendVm = () => { /* don't suspend — let it run to end */ }; + + var ctx = Compile("y$ = prompt$(\"what is your name?\")"); + ctx.Machine.Execute3(0); + + Assert.That(channels, Is.EqualTo(new[] { "fade-web/prompt" })); + Assert.That(payloads, Is.EqualTo(new[] { "what is your name?" })); + } + + [Test] + public void Prompt_NoHostInstalled_ReturnsEmptyAndContinues() + { + // No HostBridge handlers — Prompt should still safely return + // and the program should continue with y$ = "" (the + // placeholder that the source-generated executor pushes). + var ctx = Compile(@" +y$ = prompt$(""?"") +print y$ +print ""after"" +"); + ctx.Machine.Execute3(0); + + var printed = WebCommands.DrainPrintBuffer().Replace("\r\n", "\n"); + // First print is the empty placeholder (one blank line), + // second print is the literal "after". + Assert.That(printed, Is.EqualTo("\nafter\n")); + } + + [Test] + public void SuspendVm_PausesExecution_BeforeFinishingProgram() + { + VirtualMachine vm = null; + var suspendCount = 0; + HostBridge.PostMessage = (_, __) => { }; + HostBridge.SuspendVm = () => { suspendCount++; vm?.Suspend(); }; + + var ctx = Compile(@" +y$ = prompt$(""?"") +print y$ +"); + vm = ctx.Machine; + vm.Execute3(0); + + Assert.That(suspendCount, Is.EqualTo(1), + "SuspendVm should fire exactly once during the prompt$ call"); + Assert.That(vm.instructionIndex, Is.LessThan(vm.program.Length), + "VM should have stopped mid-program (didn't reach the print)"); + Assert.That(vm.isSuspendRequested, Is.True); + } + + [Test] + public void Pump_Suspend_DepositString_Resume_AssignsRealAnswer() + { + // The headline test: drive the cooperative pump exactly the + // way FadeBridge does in production. After deposit, the + // program should see "Chris", not the empty placeholder. + VirtualMachine vm = null; + HostBridge.PostMessage = (_, __) => { }; + HostBridge.SuspendVm = () => vm?.Suspend(); + + var ctx = Compile(@" +y$ = prompt$(""?"") +print y$ +"); + vm = ctx.Machine; + + // Tick 1: runs until prompt$ → HostBridge.SuspendVm → exit. + vm.Execute3(0); + Assert.That(vm.instructionIndex, Is.LessThan(vm.program.Length), + "tick 1 should have suspended, not completed"); + + // Deposit the answer onto the operand stack. This is the + // same call FadeBridge.DepositResultString makes after the + // page replies — exercising the shared HostStackOps helper. + Assert.That(HostStackOps.SwapTopString(vm, "Chris"), Is.True); + + // Drain anything the executor may have streamed already + // (shouldn't be anything — print fires AFTER the deposit). + WebCommands.DrainPrintBuffer(); + + // Tick 2: should run to completion now, printing "Chris". + vm.Execute3(0); + Assert.That(vm.instructionIndex, Is.GreaterThanOrEqualTo(vm.program.Length), + "tick 2 should have completed the program"); + + var printed = WebCommands.DrainPrintBuffer().Replace("\r\n", "\n"); + Assert.That(printed.TrimEnd('\n'), Is.EqualTo("Chris"), + "expected the deposited answer to be what `print y$` saw"); + } + + [Test] + public void Pump_MultiplePrompts_DepositEachInTurn() + { + // Two prompts in sequence: each should suspend, get a + // separate deposit, and the program should see both answers. + VirtualMachine vm = null; + HostBridge.PostMessage = (_, __) => { }; + HostBridge.SuspendVm = () => vm?.Suspend(); + + var ctx = Compile(@" +a$ = prompt$(""first?"") +b$ = prompt$(""second?"") +print a$ +print b$ +"); + vm = ctx.Machine; + + vm.Execute3(0); + Assert.That(HostStackOps.SwapTopString(vm, "alpha"), Is.True); + + vm.Execute3(0); + Assert.That(HostStackOps.SwapTopString(vm, "beta"), Is.True); + + WebCommands.DrainPrintBuffer(); + vm.Execute3(0); + + var printed = WebCommands.DrainPrintBuffer().Replace("\r\n", "\n"); + Assert.That(printed, Is.EqualTo("alpha\nbeta\n")); + } + + [Test] + public void SwapTopPrimitive_Int_OverwritesPlaceholder() + { + // Direct unit test of the primitive-swap path. Use a real + // (trivially compiled) VM so the constructor's interned-data + // setup succeeds — the actual program body doesn't matter, + // we just need a valid FastStack to mutate. + var vm = TinyVm(); + VmUtil.PushSpan(ref vm.stack, System.BitConverter.GetBytes(0), TypeCodes.INT); + + Assert.That(HostStackOps.SwapTopPrimitive(vm, TypeCodes.INT, + System.BitConverter.GetBytes(42)), Is.True); + + VmUtil.ReadAsInt(ref vm.stack, out var read); + Assert.That(read, Is.EqualTo(42)); + Assert.That(vm.stack.ptr, Is.EqualTo(0), + "swap shouldn't change the stack depth"); + } + + [Test] + public void SwapTopPrimitive_WrongSize_Refuses() + { + var vm = TinyVm(); + VmUtil.PushSpan(ref vm.stack, System.BitConverter.GetBytes(0), TypeCodes.INT); + + // INT is 4 bytes; passing 8 should be rejected and the + // stack should be untouched. + var before = vm.stack.ptr; + var ok = HostStackOps.SwapTopPrimitive(vm, TypeCodes.INT, + System.BitConverter.GetBytes(0L)); + Assert.That(ok, Is.False); + Assert.That(vm.stack.ptr, Is.EqualTo(before)); + } + + // Tiniest valid VM we can build: compile an empty program with + // the WebCommands command set. The VM constructor expects a + // properly-formatted bytecode blob (with the interned-data + // section the compiler always emits); building a stub by hand + // is fragile, so use the real compiler. + private static VirtualMachine TinyVm() + { + var ctx = Compile("x = 1"); + return ctx.Machine; + } + } +} diff --git a/FadeBasic/Tests/DapIntegrationTests.cs b/FadeBasic/Tests/DapIntegrationTests.cs new file mode 100644 index 0000000..4135ff0 --- /dev/null +++ b/FadeBasic/Tests/DapIntegrationTests.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Tests; + +/// +/// Integration tests for the FadeBasic DAP adapter. +/// Starts the adapter as a child process and speaks the DAP protocol over stdin/stdout. +/// +public class DapIntegrationTests +{ + private Process _dap; + private int _seq = 1; + private readonly List _events = new(); + private readonly List _reverseRequests = new(); + private readonly Dictionary _responses = new(); + private CancellationTokenSource _cts; + private Task _readerTask; + + private string TestProjectPath => + Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "Tests", "Fixtures", "Projects", "Primitive", "prim.csproj")); + + private string DapDll + { + get + { + var dll = Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "DAP", "bin", "Debug", "net8.0", "DAP.dll")); + if (!File.Exists(dll)) + { + var proj = Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "DAP", "DAP.csproj")); + var build = Process.Start(new ProcessStartInfo("dotnet", $"build \"{proj}\" -c Debug") + { + RedirectStandardOutput = true, RedirectStandardError = true, + }); + build!.WaitForExit(); + Assert.That(build.ExitCode, Is.EqualTo(0), "DAP build failed"); + } + return dll; + } + } + + [SetUp] + public void Setup() + { + _seq = 1; + _events.Clear(); + _reverseRequests.Clear(); + _responses.Clear(); + _cts = new CancellationTokenSource(); + + _dap = new Process + { + StartInfo = new ProcessStartInfo("dotnet", DapDll) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + Environment = + { + ["FADE_DAP_LOG_PATH"] = Path.Combine(TestContext.CurrentContext.WorkDirectory, "dap_test.log"), + ["FADE_DOTNET_PATH"] = "dotnet", + }, + }, + }; + _dap.Start(); + _readerTask = Task.Run(() => ReadMessages(_cts.Token)); + } + + [TearDown] + public void TearDown() + { + _cts?.Cancel(); + try { _dap?.Kill(true); } catch { } + _dap?.Dispose(); + } + + [Test] + public async Task InitializeReturnsCapabilities() + { + var resp = await SendRequest("initialize", BuildInitArgs()); + Assert.That(resp["success"]?.GetValue(), Is.True, $"initialize failed: {resp}"); + var body = resp["body"]?.AsObject(); + Assert.That(body?["supportsConfigurationDoneRequest"]?.GetValue(), Is.True); + } + + [Test] + public async Task LaunchWithBadProjectReturnsError() + { + await SendRequest("initialize", BuildInitArgs()); + var resp = await SendRequest("launch", new JsonObject { ["program"] = "/nonexistent/fake.csproj" }); + Assert.That(resp["success"]?.GetValue(), Is.False, "launch should fail for bad project"); + Assert.That(resp["message"]?.GetValue(), Is.Not.Empty); + } + + [Test] + public async Task FullDebugSessionWithBreakpoint() + { + if (!File.Exists(TestProjectPath)) + Assert.Ignore($"Fixture not found: {TestProjectPath}"); + + // 1. Initialize + var initResp = await SendRequest("initialize", BuildInitArgs()); + Assert.That(initResp["success"]?.GetValue(), Is.True, "initialize failed"); + + // 2. Launch (async -- adapter will send runInTerminal + initialized) + var launchTask = SendRequest("launch", new JsonObject + { + ["program"] = TestProjectPath, + }); + + // 3. Wait for initialized event + runInTerminal reverse request + var ritReq = await WaitForReverseRequest("runInTerminal", TimeSpan.FromSeconds(30)); + Assert.That(ritReq, Is.Not.Null, "Never received runInTerminal"); + + var ritArgs = ritReq["arguments"]!.AsObject(); + var env = ritArgs["env"]!.AsObject(); + Assert.That(env.ContainsKey("FADE_BASIC_DEBUG"), "missing FADE_BASIC_DEBUG env var"); + Assert.That(env.ContainsKey("FADE_BASIC_DEBUG_PORT"), "missing FADE_BASIC_DEBUG_PORT env var"); + + // Verify the port is present (may be int or string in JSON) + var portNode = env["FADE_BASIC_DEBUG_PORT"]; + int port; + if (portNode is JsonValue jv && jv.TryGetValue(out var intPort)) + port = intPort; + else + port = int.Parse(portNode!.GetValue()); + Assert.That(port, Is.GreaterThan(0), "port should be a positive integer"); + + var argv = ritArgs["args"]!.AsArray().Select(a => a!.GetValue()).ToList(); + Assert.That(argv, Has.Count.GreaterThan(0)); + TestContext.Out.WriteLine($"runInTerminal argv: {string.Join(" ", argv)}"); + TestContext.Out.WriteLine($"runInTerminal env: {env.ToJsonString()}"); + + // 4. Respond to runInTerminal (simulate -- don't actually start the process) + await SendReverseResponse(ritReq["seq"]!.GetValue(), "runInTerminal", + new JsonObject { ["processId"] = 99999 }); + + // 5. Wait for launch response + var launchResp = await launchTask; + Assert.That(launchResp["success"]?.GetValue(), Is.True, $"launch failed: {launchResp}"); + + // 6. Verify initialized event was sent + var initialized = await WaitForEvent("initialized", TimeSpan.FromSeconds(5)); + Assert.That(initialized, Is.Not.Null, "Never received initialized event"); + + // Note: setBreakpoints / configurationDone / stopped / stackTrace require a real + // debuggee process connected via TCP (the adapter blocks on Connect() after + // runInTerminal). Those flows are verified by the DAP logs from real IDE sessions + // and by the StoppedEventIncludesThreadId source-level test. + } + + [Test] + public async Task RunInTerminalEnvPortIsUsable() + { + // The DAP sends FADE_BASIC_DEBUG_PORT as a JSON integer in the env map. + // Clients must convert it to a string for environment variables. + // This test verifies the port value is a valid integer regardless of JSON type. + if (!File.Exists(TestProjectPath)) + Assert.Ignore($"Fixture not found: {TestProjectPath}"); + + await SendRequest("initialize", BuildInitArgs()); + var launchTask = SendRequest("launch", new JsonObject { ["program"] = TestProjectPath }); + + var ritReq = await WaitForReverseRequest("runInTerminal", TimeSpan.FromSeconds(30)); + Assert.That(ritReq, Is.Not.Null); + + var env = ritReq["arguments"]!.AsObject()["env"]!.AsObject(); + var portNode = env["FADE_BASIC_DEBUG_PORT"]; + + // The port may arrive as a JSON number (integer) -- clients need to .toString() it + string portStr; + if (portNode is JsonValue jv) + { + if (jv.TryGetValue(out var intVal)) + portStr = intVal.ToString(); + else + portStr = jv.GetValue(); + } + else + { + portStr = portNode!.ToString(); + } + + Assert.That(int.TryParse(portStr, out var port), Is.True, + $"Port '{portStr}' must be parseable as integer"); + Assert.That(port, Is.GreaterThan(1024).And.LessThan(65536), + $"Port {port} should be in ephemeral range"); + + TestContext.Out.WriteLine($"Port value: {port} (from JSON type: {portNode!.GetType().Name})"); + + // Clean up - respond to runInTerminal so launch completes + await SendReverseResponse(ritReq["seq"]!.GetValue(), "runInTerminal", + new JsonObject { ["processId"] = 1 }); + await launchTask; + } + + // ---- helpers ---- + + static JsonObject BuildInitArgs() => new() + { + ["clientID"] = "test", + ["clientName"] = "DapIntegrationTest", + ["adapterID"] = "fade-basic", + ["linesStartAt1"] = true, + ["columnsStartAt1"] = true, + ["pathFormat"] = "path", + ["supportsRunInTerminalRequest"] = true, + }; + + async Task WaitForReverseRequest(string command, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + lock (_reverseRequests) + { + var found = _reverseRequests.Find(r => r["command"]?.GetValue() == command); + if (found != null) return found; + } + await Task.Delay(50); + } + return null; + } + + async Task WaitForEvent(string eventName, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + lock (_events) + { + var found = _events.Find(e => e["event"]?.GetValue() == eventName); + if (found != null) return found; + } + await Task.Delay(50); + } + return null; + } + + async Task SendRequest(string command, JsonObject arguments) + { + var seq = _seq++; + var msg = new JsonObject + { + ["seq"] = seq, ["type"] = "request", ["command"] = command, ["arguments"] = arguments, + }; + await WriteMessage(msg); + + var deadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < deadline) + { + lock (_responses) + { + if (_responses.TryGetValue(seq, out var resp)) + { + _responses.Remove(seq); + return resp; + } + } + await Task.Delay(50); + } + Assert.Fail($"Timeout waiting for response to '{command}' (seq={seq})"); + return null!; + } + + async Task SendReverseResponse(int requestSeq, string command, JsonObject body) + { + await WriteMessage(new JsonObject + { + ["seq"] = _seq++, ["type"] = "response", ["request_seq"] = requestSeq, + ["success"] = true, ["command"] = command, ["body"] = body, + }); + } + + async Task WriteMessage(JsonObject msg) + { + var json = msg.ToJsonString(); + var header = $"Content-Length: {Encoding.UTF8.GetByteCount(json)}\r\n\r\n"; + await _dap.StandardInput.WriteAsync(header); + await _dap.StandardInput.WriteAsync(json); + await _dap.StandardInput.FlushAsync(); + } + + void ReadMessages(CancellationToken ct) + { + try + { + var stream = _dap.StandardOutput.BaseStream; + var buffer = new byte[65536]; + var pending = new MemoryStream(); + while (!ct.IsCancellationRequested) + { + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) break; + pending.Write(buffer, 0, read); + while (TryParseMessage(pending, out var msg)) + { + var type = msg["type"]?.GetValue(); + switch (type) + { + case "response": + lock (_responses) { _responses[msg["request_seq"]!.GetValue()] = msg; } + break; + case "event": + lock (_events) { _events.Add(msg); } + break; + case "request": + lock (_reverseRequests) { _reverseRequests.Add(msg); } + break; + } + } + } + } + catch when (ct.IsCancellationRequested) { } + catch (IOException) { } + } + + static bool TryParseMessage(MemoryStream pending, out JsonObject msg) + { + msg = null; + var data = pending.ToArray(); + var text = Encoding.UTF8.GetString(data); + var headerEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (headerEnd < 0) return false; + int contentLength = -1; + foreach (var line in text.Substring(0, headerEnd).Split("\r\n")) + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + contentLength = int.Parse(line.Substring("Content-Length:".Length).Trim()); + if (contentLength < 0) return false; + var bodyStart = Encoding.UTF8.GetByteCount(text.Substring(0, headerEnd + 4)); + if (data.Length < bodyStart + contentLength) return false; + var bodyBytes = new byte[contentLength]; + Array.Copy(data, bodyStart, bodyBytes, 0, contentLength); + msg = JsonNode.Parse(Encoding.UTF8.GetString(bodyBytes))?.AsObject(); + if (msg == null) return false; + var consumed = bodyStart + contentLength; + var remaining = data.Length - consumed; + pending.SetLength(0); + if (remaining > 0) pending.Write(data, consumed, remaining); + return true; + } +} diff --git a/FadeBasic/Tests/DebuggerTests.cs b/FadeBasic/Tests/DebuggerTests.cs index cfc9075..455f83f 100644 --- a/FadeBasic/Tests/DebuggerTests.cs +++ b/FadeBasic/Tests/DebuggerTests.cs @@ -527,7 +527,7 @@ wait ms 500 } [Test] - public async Task DebugServerTest() + public async Task DebugServerDemo() { var port = LaunchUtil.FreeTcpPort(); var src = @$" diff --git a/FadeBasic/Tests/DeferTests.cs b/FadeBasic/Tests/DeferTests.cs index 602d702..ff9224d 100644 --- a/FadeBasic/Tests/DeferTests.cs +++ b/FadeBasic/Tests/DeferTests.cs @@ -51,7 +51,7 @@ public void GosubInFunction() var src = @" ghost: a = 1 -function test() +function demo() static print ""a"" @@ -64,7 +64,7 @@ static print ""b"" return endfunction - test() + demo() "; Setup(src, out var compiler, out var prog); diff --git a/FadeBasic/Tests/DotnetTestIntegrationDemo.cs b/FadeBasic/Tests/DotnetTestIntegrationDemo.cs new file mode 100644 index 0000000..6af5f02 --- /dev/null +++ b/FadeBasic/Tests/DotnetTestIntegrationDemo.cs @@ -0,0 +1,90 @@ +using FadeBasic; +using FadeBasic.Sdk; + +namespace Tests; + +/// +/// Demonstration of the recipe for surfacing Fade tests in a `dotnet test` +/// run. Each Fade `test ... endtest` block becomes a separate NUnit test case +/// via TestCaseSource, so it shows up individually in IDE Test +/// Explorer and CI logs. +/// +/// Real-world consumer projects would replace SampleFadeSource with +/// either an embedded resource or a file path that points at the project's +/// .fbasic files, and would reference their own CommandCollection. +/// +[TestFixture] +public class DotnetTestIntegrationDemo +{ + private const string SampleFadeSource = @" +counter = 0 +counter = counter + 1 +checkpoint: +end + +test counter_increments + runto checkpoint + assert counter = 1 +endtest + +test math_works + assert 2 + 2 = 4 +endtest + +test string_compare + local s as string + s = ""hello"" + assert s = ""hello"" +endtest +"; + + // Cache the runtime context across cases so we only compile once. Each + // test still gets a fresh VM (RunTest builds one per call), so state is + // isolated between Fade tests. + private static FadeRuntimeContext _cachedContext; + private static FadeRuntimeContext SharedContext + { + get + { + if (_cachedContext != null) return _cachedContext; + var ok = Fade.TryCreateFromString(SampleFadeSource, TestCommands.CommandsForTesting, + out var ctx, out var errors); + if (!ok) + { + throw new Exception("Fade compile failed: " + errors.ToDisplay()); + } + return _cachedContext = ctx; + } + } + + // NUnit calls this static method to populate test cases. Each yielded + // value becomes a parameter to the test method. Returning test names as + // strings produces test cases like + // `RunFadeTest(\"counter_increments\")` in the Test Explorer. + public static IEnumerable DiscoverFadeTests() + { + foreach (var t in SharedContext.Tests) + { + yield return t.name; + } + } + + [Test] + [TestCaseSource(nameof(DiscoverFadeTests))] + public void RunFadeTest(string fadeTestName) + { + var result = SharedContext.RunTest(fadeTestName); + Assert.That(result.passed, Is.True, + $"Fade test `{fadeTestName}` failed: {result.failureMessage}"); + } + + // Companion test: confirm that the discovery returns the expected set. + [Test] + public void DiscoverFadeTests_ReturnsAllTopLevelTests() + { + var names = DiscoverFadeTests().ToList(); + Assert.That(names, Does.Contain("counter_increments")); + Assert.That(names, Does.Contain("math_works")); + Assert.That(names, Does.Contain("string_compare")); + } +} diff --git a/FadeBasic/Tests/ExpressionTests.cs b/FadeBasic/Tests/ExpressionTests.cs index 701a90f..db91b53 100644 --- a/FadeBasic/Tests/ExpressionTests.cs +++ b/FadeBasic/Tests/ExpressionTests.cs @@ -36,7 +36,7 @@ public Parser BuildParser(string src, out List tokens) [TestCase("\"a\"", "(\"a\")")] [TestCase("\"a\" + \"b\" ", "(+ (\"a\"),(\"b\"))")] [TestCase("refDbl x", "(xcall refDbl (ref x))")] - [TestCase("a.b + len(a.c)", "(+ ((ref a).(ref b)),(xcall len ((ref a).(ref c))))")] + [TestCase("a.b + len(a.c)", "(+ ((ref a).(ref b)),(len ((ref a).(ref c))))")] [TestCase("*x", "(derefExpr (ref x))")] [TestCase("*x(3)", "(derefExpr (ref x[(3)]))")] [TestCase("x", "(ref x)")] diff --git a/FadeBasic/Tests/FadeTestAdapterTests.cs b/FadeBasic/Tests/FadeTestAdapterTests.cs new file mode 100644 index 0000000..6680f33 --- /dev/null +++ b/FadeBasic/Tests/FadeTestAdapterTests.cs @@ -0,0 +1,550 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.TestAdapter; +using FadeBasic.Sdk; +using FadeBasic.Testing; +using FadeBasic.Virtual; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using NUnit.Framework; + +namespace Tests; + +/// +/// Unit tests for the VSTest adapter (Stage 11H — see TEST_ADAPTER.md). +/// We exercise the discoverer's internal entry-point and the executor's +/// helper methods directly, without invoking the VSTest pipeline. Integration +/// tests that drive vstest.console end-to-end live in a separate fixture +/// (deferred — see TEST_ADAPTER.md "Tests" section). +/// +[TestFixture] +public class FadeTestAdapterTests +{ + // ---- Discoverer --------------------------------------------------- + + [Test] + public void Discoverer_FindsConcreteTests_SkipsAbstract() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "alpha", entryPointAddress = 100, sourceLine = 5, sourceFilePath = "/proj/main.fbasic" }, + new TestManifestEntry { name = "parent", entryPointAddress = 200, isAbstract = true }, + new TestManifestEntry { name = "beta", entryPointAddress = 300, sourceLine = 12, sourceFilePath = "/proj/main.fbasic", fromParent = "parent" }, + }); + + var cases = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).ToList(); + + Assert.That(cases.Count, Is.EqualTo(2), + "exactly the two concrete entries should surface; abstract entries are not run"); + var names = cases.Select(c => c.DisplayName).ToList(); + Assert.That(names, Is.EquivalentTo(new[] { "alpha", "beta" })); + } + + [Test] + public void Discoverer_PopulatesFadeFlavoredFields() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "wraps_at_right_edge", entryPointAddress = 42, sourceLine = 17, sourceFilePath = "/proj/fish.fbasic" }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + Assert.That(tc.DisplayName, Is.EqualTo("wraps_at_right_edge")); + // FQN aligns with ManagedType.ManagedMethod — see Discoverer_ManagedType_BuildsFadeBasenamePath. + Assert.That(tc.FullyQualifiedName, Is.EqualTo("Fade.fish.wraps_at_right_edge")); + Assert.That(tc.ExecutorUri.ToString(), Is.EqualTo(FadeTestConstants.ExecutorUriString)); + Assert.That(tc.Source, Is.EqualTo("/proj/MyApp.dll")); + Assert.That(tc.CodeFilePath, Is.EqualTo("/proj/fish.fbasic"), + "double-clicking the test should jump to the .fbasic file, not the assembly"); + Assert.That(tc.LineNumber, Is.EqualTo(17)); + } + + [Test] + public void Discoverer_TagsEveryCaseWithCategoryFade() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "a", entryPointAddress = 1, sourceFilePath = "/proj/x.fbasic" }, + new TestManifestEntry { name = "b", entryPointAddress = 2, sourceFilePath = "/proj/x.fbasic" }, + }); + + var cases = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).ToList(); + + foreach (var tc in cases) + { + Assert.That(tc.Traits.Any(t => t.Name == "Category" && t.Value == "Fade"), + Is.True, + $"case {tc.DisplayName} is missing the Category=Fade trait"); + } + } + + [Test] + public void Discoverer_StampsEntryPointAddressOnTestCase() + { + // The executor uses this to look up the matching manifest entry + // without re-walking the launchable. Verifying it round-trips. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "go", entryPointAddress = 9999, sourceFilePath = "/proj/x.fbasic" }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + var addr = tc.GetPropertyValue(FadeTestCaseProperties.EntryPointAddress, defaultValue: -1); + Assert.That(addr, Is.EqualTo(9999)); + } + + [Test] + public void Discoverer_FromParent_BecomesTrait() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "child", entryPointAddress = 1, fromParent = "fixture", sourceFilePath = "/proj/x.fbasic" }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + Assert.That(tc.Traits.Any(t => t.Name == "FromParent" && t.Value == "fixture"), Is.True); + var fromParent = tc.GetPropertyValue(FadeTestCaseProperties.FromParent, defaultValue: null!); + Assert.That(fromParent, Is.EqualTo("fixture")); + } + + [Test] + public void Discoverer_OmitsCodeFilePath_WhenSourceUnknown() + { + // No source path on the manifest entry → don't guess; better to omit + // than send a path the IDE will fail to open. (This is the case for + // single-string SDK callers that didn't supply a SourceMap.) + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "x", entryPointAddress = 1, sourceLine = 3 }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + Assert.That(tc.CodeFilePath, Is.Null.Or.Empty); + Assert.That(tc.LineNumber, Is.EqualTo(3), + "LineNumber alone is still useful — Test Explorer shows it in the details pane"); + } + + [Test] + public void Discoverer_ManagedType_BuildsFadeBasenamePath() + { + // ManagedType + ManagedMethod build the test tree in IDE Test + // Explorers / `dotnet test` structured output. Format is + // "Fade."; the test name becomes + // ManagedMethod. Tooling that falls back to parsing FullyQualifiedName + // gets the same grouping because FQN = ManagedType + "." + ManagedMethod. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "wraps_at_right_edge", entryPointAddress = 1, sourceFilePath = "/proj/fish.fbasic", sourceLine = 5 }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + var managedType = tc.GetPropertyValue(FadeTestCaseProperties.ManagedType, defaultValue: null!); + var managedMethod = tc.GetPropertyValue(FadeTestCaseProperties.ManagedMethod, defaultValue: null!); + + Assert.That(managedType, Is.EqualTo("Fade.fish"), + "ManagedType groups tests by their .fbasic source file under a `Fade` namespace"); + Assert.That(managedMethod, Is.EqualTo("wraps_at_right_edge"), + "ManagedMethod is the test name verbatim"); + Assert.That(tc.FullyQualifiedName, Is.EqualTo("Fade.fish.wraps_at_right_edge")); + } + + [Test] + public void Discoverer_ManagedType_SanitizesNonIdentifierCharacters() + { + // .fbasic basenames can include dashes, dots in names, etc. — invalid + // in dotted-identifier paths. We sanitize to [A-Za-z0-9_]. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "t", entryPointAddress = 1, sourceFilePath = "/proj/my-game.fbasic" }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + var managedType = tc.GetPropertyValue(FadeTestCaseProperties.ManagedType, defaultValue: null!); + Assert.That(managedType, Is.EqualTo("Fade.my_game")); + } + + [Test] + public void Discoverer_ManagedType_FallsBackToAssemblyName_WhenNoSourceFile() + { + // Single-string SDK callers won't have sourceFilePath populated. + // Fall back to the assembly basename so the tree still groups + // sensibly. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "t", entryPointAddress = 1 }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/CoolApp.dll", launchable).Single(); + + var managedType = tc.GetPropertyValue(FadeTestCaseProperties.ManagedType, defaultValue: null!); + Assert.That(managedType, Is.EqualTo("Fade.CoolApp")); + } + + [Test] + public void Discoverer_ToManagedIdentifier_CoercesEmptyToTests() + { + Assert.That(FadeTestDiscoverer.ToManagedIdentifier(""), Is.EqualTo("Tests")); + Assert.That(FadeTestDiscoverer.ToManagedIdentifier(null!), Is.EqualTo("Tests")); + Assert.That(FadeTestDiscoverer.ToManagedIdentifier("9digit"), Does.StartWith("_"), + "C# identifiers cannot start with a digit"); + } + + [Test] + public void Discoverer_MultipleFiles_EachTestKeepsItsOwnPath() + { + // Multi-`.fbasic` projects: each entry's sourceFilePath drives its + // CodeFilePath. This is the whole point of the per-entry plumbing. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "a", entryPointAddress = 1, sourceFilePath = "/proj/foo.fbasic", sourceLine = 5 }, + new TestManifestEntry { name = "b", entryPointAddress = 2, sourceFilePath = "/proj/bar.fbasic", sourceLine = 10 }, + }); + + var cases = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).ToList(); + + var a = cases.First(c => c.DisplayName == "a"); + var b = cases.First(c => c.DisplayName == "b"); + Assert.That(a.CodeFilePath, Is.EqualTo("/proj/foo.fbasic")); + Assert.That(b.CodeFilePath, Is.EqualTo("/proj/bar.fbasic")); + } + + // ---- Executor: ResolveEntry -------------------------------------- + + [Test] + public void Executor_ResolveEntry_PrefersAddressOverDisplayName() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "duplicate_name", entryPointAddress = 100, isAbstract = true }, + new TestManifestEntry { name = "duplicate_name", entryPointAddress = 200 }, + }); + + var tc = new TestCase("Fade.x.duplicate_name", FadeTestConstants.ExecutorUri, "/proj/MyApp.dll"); + tc.SetPropertyValue(FadeTestCaseProperties.EntryPointAddress, 200); + + var resolved = FadeTestExecutorAdapter.ResolveEntry(tc, launchable); + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved!.entryPointAddress, Is.EqualTo(200), + "name collisions are resolved by entry-point address, not by name"); + } + + [Test] + public void Executor_ResolveEntry_FallsBackToDisplayName_WhenNoAddress() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "named", entryPointAddress = 50 }, + }); + var tc = new TestCase("Fade.x.named", FadeTestConstants.ExecutorUri, "/proj/MyApp.dll") + { + DisplayName = "named" + }; + // Deliberately do NOT set EntryPointAddress — exercise the fallback. + + var resolved = FadeTestExecutorAdapter.ResolveEntry(tc, launchable); + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved!.name, Is.EqualTo("named")); + } + + [Test] + public void Executor_ResolveEntry_ReturnsNull_WhenUnresolvable() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "exists", entryPointAddress = 1 }, + }); + var tc = new TestCase("Fade.x.missing", FadeTestConstants.ExecutorUri, "/proj/MyApp.dll") + { + DisplayName = "missing" + }; + + var resolved = FadeTestExecutorAdapter.ResolveEntry(tc, launchable); + Assert.That(resolved, Is.Null); + } + + // ---- Executor: failure formatting -------------------------------- + + [Test] + public void Executor_BuildErrorMessage_IncludesFbasicSourceAndLine() + { + // Failure pane should read as a Fade error, not a generic dump. + var entry = new TestManifestEntry { name = "wraps", sourceLine = 42 }; + var result = new FadeTestResult + { + passed = false, + failureMessage = "x is 0", + failureSourceText = "assert x = 1", + }; + var msg = FadeTestExecutorAdapter.BuildErrorMessage(result, "/proj/fish.fbasic", entry); + + Assert.That(msg, Does.Contain("x is 0")); + Assert.That(msg, Does.Contain("source: assert x = 1")); + Assert.That(msg, Does.Contain("at fish.fbasic:42")); + } + + [Test] + public void Executor_BuildErrorMessage_GracefulWhenNoSourceText() + { + var entry = new TestManifestEntry { name = "x", sourceLine = 7 }; + var result = new FadeTestResult { passed = false, failureMessage = "vm boom" }; + var msg = FadeTestExecutorAdapter.BuildErrorMessage(result, "/p/main.fbasic", entry); + + Assert.That(msg, Does.Contain("vm boom")); + Assert.That(msg, Does.Not.Contain("source:"), + "the source: line should be omitted when failureSourceText is empty"); + } + + [Test] + public void Executor_BuildErrorStackTrace_ProducesClickableFormat() + { + // The exact format ("at NAME in FILE:line N") is the contract that + // both Rider and VS Code parse to make stack lines clickable. + // Legacy fallback path: no resolved frames, uses entry.sourceLine. + var entry = new TestManifestEntry { name = "wraps_at_right_edge", sourceLine = 42 }; + var result = new FadeTestResult(); + var stack = FadeTestExecutorAdapter.BuildErrorStackTrace(result, "/proj/fish.fbasic", entry); + + Assert.That(stack, Does.Match(@"\s+at wraps_at_right_edge in /proj/fish\.fbasic:line 42")); + } + + [Test] + public void Executor_BuildErrorStackTrace_RendersResolvedFrames() + { + // When the runner supplied resolved frames, the adapter emits one + // "at NAME in FILE:line N" line per frame, innermost first. The + // outermost frame uses the test name as its label. + var entry = new TestManifestEntry { name = "sample", sourceLine = 7 }; + var result = new FadeTestResult + { + failureFrames = new List + { + new FadeStackFrame { functionName = "ex", lineNumber = 2 }, + new FadeStackFrame { functionName = string.Empty, lineNumber = 5 }, + } + }; + var stack = FadeTestExecutorAdapter.BuildErrorStackTrace(result, "/proj/fish.fbasic", entry); + + Assert.That(stack, Does.Match(@"\s+at ex in /proj/fish\.fbasic:line 2")); + Assert.That(stack, Does.Match(@"\s+at sample in /proj/fish\.fbasic:line 5")); + // Innermost (ex) must precede outermost (sample). + Assert.That(stack.IndexOf("ex"), Is.LessThan(stack.IndexOf("sample"))); + } + + [Test] + public void Executor_BuildErrorStackTrace_EmptyWhenSourceUnknown() + { + // Without source info we can't synthesize a useful frame; emit + // empty rather than a half-frame the IDE will mis-parse. + var entry = new TestManifestEntry { name = "x", sourceLine = 0 }; + var result = new FadeTestResult(); + var stack = FadeTestExecutorAdapter.BuildErrorStackTrace(result, "/p/main.fbasic", entry); + Assert.That(stack, Is.Empty); + + var entry2 = new TestManifestEntry { name = "x", sourceLine = 5 }; + var stack2 = FadeTestExecutorAdapter.BuildErrorStackTrace(result, string.Empty, entry2); + Assert.That(stack2, Is.Empty); + } + + // ---- Executor: stdout/stderr capture ------------------------------ + + [Test] + public void Executor_CapturesStdout_FromTestRun_AsTestResultMessage() + { + // The Fade standard library's `print` lands on Console.WriteLine + // (FadeBasicCommands.cs); the adapter redirects Console.Out around + // the run so the IDE's test details pane gets the output. Verifying + // the pipe end-to-end with a fake host that prints during its run. + var entry = new TestManifestEntry { name = "prints", entryPointAddress = 1, sourceFilePath = "/p/x.fbasic", sourceLine = 5 }; + var launchable = new ManifestLaunchable(new[] { entry }); + var host = new PrintingHost(stdoutText: "hello from test", stderrText: "warning text"); + + var captured = RunOneAndCapture(launchable, entry, host); + + var stdout = captured.Messages.SingleOrDefault(m => m.Category == TestResultMessage.StandardOutCategory); + Assert.That(stdout, Is.Not.Null, "stdout message should be attached when the test prints"); + Assert.That(stdout!.Text, Does.Contain("hello from test")); + + var stderr = captured.Messages.SingleOrDefault(m => m.Category == TestResultMessage.StandardErrorCategory); + Assert.That(stderr, Is.Not.Null); + Assert.That(stderr!.Text, Does.Contain("warning text")); + } + + [Test] + public void Executor_OmitsMessages_WhenTestPrintsNothing() + { + // Defensive: don't pollute the result with empty StandardOut entries. + // Some IDEs render a blank "Output" tab for any non-null message, + // so emitting nothing is the right behavior. + var entry = new TestManifestEntry { name = "silent", entryPointAddress = 1, sourceFilePath = "/p/x.fbasic", sourceLine = 1 }; + var launchable = new ManifestLaunchable(new[] { entry }); + var host = new PrintingHost(stdoutText: "", stderrText: ""); + + var captured = RunOneAndCapture(launchable, entry, host); + + Assert.That(captured.Messages, Is.Empty, + "no stdout/stderr captured → no message entries on the result"); + } + + private static TestResult RunOneAndCapture( + ManifestLaunchable launchable, + TestManifestEntry entry, + IFadeTestHost host) + { + // Use a path that exists so the loader's GetFullPath/cache lookup + // resolves stably. The actual file content is never read because + // we pre-register the in-memory launchable on the loader cache. + var assemblyPath = System.IO.Path.GetTempFileName(); + var tc = FadeTestDiscoverer.EnumerateTestCases(assemblyPath, launchable).Single(); + var handle = new CapturingFrameworkHandle(); + var executor = new FadeTestExecutorAdapter(); + + // Inject the host AND pre-load the launchable. The executor's + // RunGroup re-loads launchables from disk via FadeTestLaunchableLoader; + // the test seam shortcircuits that to our in-memory instance. + using (FadeTestHostResolver.OverrideForTests(host)) + using (FadeTestLaunchableLoader.RegisterForTests(assemblyPath, launchable)) + { + executor.RunTests(new[] { tc }, runContext: null, frameworkHandle: handle); + } + + try + { + if (handle.Results.Count != 1) + { + Assert.Fail($"Expected 1 TestResult, got {handle.Results.Count}. Handle messages: " + + string.Join("; ", handle.Messages)); + } + return handle.Results[0]; + } + finally + { + try { System.IO.File.Delete(assemblyPath); } catch { /* best effort */ } + } + } + + /// + /// Test host that writes to Console.Out / Console.Error during its + /// RunTestAsync, mimicking what a real Fade test would do via `print`. + /// + private sealed class PrintingHost : IFadeTestHost + { + private readonly string _stdout; + private readonly string _stderr; + public PrintingHost(string stdoutText, string stderrText) { _stdout = stdoutText; _stderr = stderrText; } + public Task InitializeAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + public Task BeforeAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + public Task RunTestAsync(FadeTestRunContext ctx, CancellationToken ct) + { + if (_stdout.Length > 0) Console.Write(_stdout); + if (_stderr.Length > 0) Console.Error.Write(_stderr); + return Task.FromResult(new FadeTestResult { testName = ctx.Entry.name, passed = true }); + } + public Task AfterAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + public ValueTask DisposeAsync() => default; + } + + private sealed class CapturingFrameworkHandle : IFrameworkHandle + { + public List Results { get; } = new List(); + public List Messages { get; } = new List(); + public void RecordStart(TestCase testCase) { } + public void RecordResult(TestResult testResult) => Results.Add(testResult); + public void RecordEnd(TestCase testCase, TestOutcome outcome) { } + public void RecordAttachments(IList attachmentSets) { } + public bool EnableShutdownAfterTestRun { get; set; } + public int LaunchProcessWithDebuggerAttached(string filePath, string workingDirectory, string arguments, + IDictionary environmentVariables) => 0; + public void SendMessage(TestMessageLevel testMessageLevel, string message) => Messages.Add(message); + } + + // ---- Loader: mtime-based cache invalidation ---------------------- + + [Test] + public void Loader_ReinspectsAfterAssemblyMtimeChange() + { + // The whole point of the new loader is to pick up `dotnet build` + // output without restarting vstest.console. The mtime sentinel is + // what drives that — first call caches the inspection result with + // a timestamp, repeat calls hit the cache, but a fresher mtime + // forces a re-inspection (and an unload of the previous ALC). + // + // We verify by feeding the loader a non-Fade file (random bytes), + // which logs a warning each time it's actually inspected. Cache + // hits do NOT log; mtime-driven reloads DO. So warning count is + // the observable witness. + var tmpPath = Path.Combine( + Path.GetTempPath(), + "FadeAdapterMtimeTest_" + Guid.NewGuid().ToString("N") + ".dll"); + File.WriteAllBytes(tmpPath, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); + + var logger = new CountingLogger(); + + try + { + FadeTestLaunchableLoader.ResetCacheForTests(); + + // First call inspects, fails (not a real PE), caches the + // negative result with the current mtime. + FadeTestLaunchableLoader.TryLoad(tmpPath, logger, out _); + var warningsAfterFirst = logger.WarningCount; + Assert.That(warningsAfterFirst, Is.GreaterThan(0), + "an invalid DLL should log a warning on inspection"); + + // Same mtime → cache hit, no new warning. + FadeTestLaunchableLoader.TryLoad(tmpPath, logger, out _); + Assert.That(logger.WarningCount, Is.EqualTo(warningsAfterFirst), + "second TryLoad with unchanged mtime should hit the cache without re-inspecting"); + + // Touch — newer mtime forces a fresh inspection on the next call. + File.SetLastWriteTimeUtc(tmpPath, DateTime.UtcNow.AddMinutes(1)); + FadeTestLaunchableLoader.TryLoad(tmpPath, logger, out _); + Assert.That(logger.WarningCount, Is.GreaterThan(warningsAfterFirst), + "after the file's mtime advances, the loader must re-inspect"); + } + finally + { + try { File.Delete(tmpPath); } catch { /* best-effort */ } + FadeTestLaunchableLoader.ResetCacheForTests(); + } + } + + private sealed class CountingLogger : IMessageLogger + { + public int WarningCount { get; private set; } + public int ErrorCount { get; private set; } + public void SendMessage(TestMessageLevel level, string message) + { + if (level == TestMessageLevel.Warning) WarningCount++; + else if (level == TestMessageLevel.Error) ErrorCount++; + } + } + + // ---- Helpers ----------------------------------------------------- + + private sealed class ManifestLaunchable : ITestLaunchable + { + private static readonly byte[] _bytes = new byte[] { (byte)OpCodes.RETURN }; + private static readonly CommandCollection _commands = new CommandCollection(); + private readonly IReadOnlyList _entries; + public ManifestLaunchable(IEnumerable entries) + { + _entries = entries.ToList(); + } + public byte[] Bytecode => _bytes; + public CommandCollection CommandCollection => _commands; + public DebugData DebugData => new DebugData(); + public IReadOnlyList TestManifest => _entries; + } +} diff --git a/FadeBasic/Tests/FadeTestRunnerTests.cs b/FadeBasic/Tests/FadeTestRunnerTests.cs new file mode 100644 index 0000000..8712c52 --- /dev/null +++ b/FadeBasic/Tests/FadeTestRunnerTests.cs @@ -0,0 +1,179 @@ +using FadeBasic; +using FadeBasic.Sdk; + +namespace Tests; + +[TestFixture] +public class FadeTestRunnerTests +{ + private FadeRuntimeContext CreateContext(string src) + { + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, out var ctx, out var errors); + Assert.That(ok, Is.True, + "expected clean compile; got: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + [Test] + public void RunTest_PassingTest_Passes() + { + var src = @" +end + +test foo + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("foo"); + Assert.That(result.passed, Is.True, + "expected pass; failure: " + result.failureMessage); + Assert.That(result.testName, Is.EqualTo("foo")); + Assert.That(result.failureMessage, Is.Null); + } + + [Test] + public void RunTest_FailingAssert_Fails() + { + var src = @" +end + +test foo + assert 1 = 2 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("foo"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureSourceText, Does.Contain("1 = 2"), + "captured assert text should appear in failure source text; got: " + + result.failureSourceText); + } + + [Test] + public void RunTest_UnknownName_ReturnsFailureResult() + { + var src = @" +end + +test foo + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("does_not_exist"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureMessage, Does.Contain("does_not_exist")); + } + + [Test] + public void RunAllTests_MixedResults_CountsCorrect() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest + +test beta + assert 1 = 2 +endtest + +test gamma + assert 5 > 0 +endtest +"; + var ctx = CreateContext(src); + var run = ctx.RunAllTests(); + Assert.That(run.tests.Count, Is.EqualTo(3)); + Assert.That(run.passedCount, Is.EqualTo(2)); + Assert.That(run.failedCount, Is.EqualTo(1)); + Assert.That(run.AllPassed, Is.False); + + var betaResult = run.tests.First(r => r.testName == "beta"); + Assert.That(betaResult.passed, Is.False); + } + + [Test] + public void RunAllTests_AllPassing_AllPassedTrue() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest + +test beta + assert 2 = 2 +endtest +"; + var ctx = CreateContext(src); + var run = ctx.RunAllTests(); + Assert.That(run.AllPassed, Is.True); + Assert.That(run.passedCount, Is.EqualTo(2)); + Assert.That(run.failedCount, Is.EqualTo(0)); + } + + [Test] + public void Tests_Property_ListsManifestEntries() + { + var src = @" +end + +test alpha +endtest + +test beta +endtest +"; + var ctx = CreateContext(src); + var names = ctx.Tests.Select(t => t.name).ToList(); + Assert.That(names, Does.Contain("alpha")); + Assert.That(names, Does.Contain("beta")); + } + + [Test] + public void RunTest_CalledTwice_StatePerCallIsIsolated() + { + // Each RunTest spins up a fresh VM at the test entry point. Running + // twice in a row should produce identical results (no leftover state). + var src = @" +end + +test counter + local n as integer = 0 + n = n + 1 + assert n = 1 +endtest +"; + var ctx = CreateContext(src); + var first = ctx.RunTest("counter"); + var second = ctx.RunTest("counter"); + Assert.That(first.passed, Is.True); + Assert.That(second.passed, Is.True); + } + + [Test] + public void RunTest_AfterRunto_VisibleProgramStateIsAvailable() + { + // Verifies the runto path: the program runs up to the label, then the + // test body asserts on the resulting program state. + var src = @" +x = 0 +x = 42 +checkpoint: +end + +test usesRunto + runto checkpoint + assert x = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("usesRunto"); + Assert.That(result.passed, Is.True, + "expected pass; failure: " + result.failureMessage); + } +} diff --git a/FadeBasic/Tests/FadeTestingAdapterTests.cs b/FadeBasic/Tests/FadeTestingAdapterTests.cs new file mode 100644 index 0000000..1fe5a0b --- /dev/null +++ b/FadeBasic/Tests/FadeTestingAdapterTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Testing; +using FadeBasic.Virtual; +using NUnit.Framework; + +namespace Tests; + +[TestFixture] +public class FadeTestingAdapterTests +{ + [Test] + public void IsTestInvocation_RecognizesCommonMtpFlags() + { + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--list-tests" }), Is.True); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--server" }), Is.True); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--filter", "DisplayName=foo" }), Is.True); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--results-directory=/tmp" }), Is.True); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--diagnostic" }), Is.True); + } + + [Test] + public void IsTestInvocation_DoesNotMatchProgramArgs() + { + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(System.Array.Empty()), Is.False); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "hello", "world" }), Is.False); + // --fade-test=name is the legacy CLI shape; it should NOT route through + // MTP — Launcher.Main handles it. + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--fade-test=foo" }), Is.False); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--fade-test", "foo" }), Is.False); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--fade-list-tests" }), Is.False); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--fade-test-all" }), Is.False); + } + + [Test] + public void Resolver_FallsBackToDefaultHost_WhenNoAttribute() + { + // Tests assembly has no [FadeTestHost] class attribute; the resolver + // should hand back DefaultFadeTestHost when nothing else is provided. + var host = FadeTestHostResolver.Resolve(null); + Assert.That(host, Is.InstanceOf()); + } + + [Test] + public void Resolver_PrefersExplicitHost() + { + var explicitHost = new StubHost(); + var resolved = FadeTestHostResolver.Resolve(explicitHost); + Assert.That(resolved, Is.SameAs(explicitHost)); + } + + [Test] + public async Task DefaultHost_PassesThroughCustomImplementation() + { + // Verify the contract: a custom IFadeTestHost gets the right fields + // on FadeTestRunContext and can return a result that the framework + // would surface back through MTP. + var stub = new RecordingHost(); + var stubLaunchable = new EmptyLaunchable(); + var entry = new TestManifestEntry { name = "noop" }; + var hostMethods = HostMethodTable.FromCommandCollection(stubLaunchable.CommandCollection); + var ctx = new FadeTestRunContext(stubLaunchable, entry, hostMethods); + + var result = await stub.RunTestAsync(ctx, CancellationToken.None); + Assert.That(stub.LastEntry, Is.SameAs(entry)); + Assert.That(stub.LastLaunchable, Is.SameAs(stubLaunchable)); + Assert.That(result.passed, Is.True); + } + + [Test] + public void DefaultHost_AbstractEntry_IsRejected() + { + var stubLaunchable = new EmptyLaunchable(); + var entry = new TestManifestEntry { name = "abstract_one", isAbstract = true }; + var hostMethods = HostMethodTable.FromCommandCollection(stubLaunchable.CommandCollection); + + var result = FadeTestExecutor.RunTest(stubLaunchable.Bytecode, hostMethods, entry); + Assert.That(result.passed, Is.False); + Assert.That(result.failureMessage, Does.Contain("abstract")); + } + + private sealed class StubHost : IFadeTestHost + { + public Task InitializeAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public Task BeforeAllTestsAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public Task RunTestAsync(FadeTestRunContext c, CancellationToken ct) + => Task.FromResult(new FadeTestResult { testName = c.Entry.name, passed = true }); + public Task AfterAllTestsAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public ValueTask DisposeAsync() => default; + } + + private sealed class RecordingHost : IFadeTestHost + { + public TestManifestEntry? LastEntry; + public ITestLaunchable? LastLaunchable; + + public Task InitializeAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public Task BeforeAllTestsAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public Task RunTestAsync(FadeTestRunContext c, CancellationToken ct) + { + LastEntry = c.Entry; + LastLaunchable = c.Launchable; + return Task.FromResult(new FadeTestResult { testName = c.Entry.name, passed = true }); + } + public Task AfterAllTestsAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public ValueTask DisposeAsync() => default; + } + + private sealed class EmptyLaunchable : ITestLaunchable + { + // A single HALT byte (opcode value mirrors what the compiler emits at + // the end of every program). The VM is expected to halt immediately + // when its IP starts here, which is good enough for "does the host + // round-trip a result" coverage. + private static readonly byte[] _bytes = new byte[] { (byte)OpCodes.RETURN }; + private static readonly CommandCollection _commands = new CommandCollection(); + + public byte[] Bytecode => _bytes; + public CommandCollection CommandCollection => _commands; + public DebugData DebugData => new DebugData(); + public IReadOnlyList TestManifest => new System.Collections.Generic.List(); + } +} diff --git a/FadeBasic/Tests/FormatTests.cs b/FadeBasic/Tests/FormatTests.cs index a493cef..6fb17a0 100644 --- a/FadeBasic/Tests/FormatTests.cs +++ b/FadeBasic/Tests/FormatTests.cs @@ -146,6 +146,33 @@ public void Format_CaseIgnore(string src, string expected, int editCount=1) if x n endif +")] + [TestCase(@" +test x +print 1 +endtest +", @" +test x + print 1 +endtest +")] + [TestCase(@" +#macro x +print 1 +#endmacro +", @" +#macro x + print 1 +#endmacro +")] + [TestCase(@" +#tokenize x +print 1 +#endtokenize +", @" +#tokenize x + print 1 +#endtokenize ")] public void Format_SpaceSizes(string src, string expected, int editCount=1) { diff --git a/FadeBasic/Tests/FunctionParserTests.cs b/FadeBasic/Tests/FunctionParserTests.cs index 525f519..f5fa74f 100644 --- a/FadeBasic/Tests/FunctionParserTests.cs +++ b/FadeBasic/Tests/FunctionParserTests.cs @@ -8,7 +8,7 @@ public partial class ParserTests public void Invoke_Simple() { var input = @" -x = Test() +x = Demo() "; var parser = MakeParser(input); var prog = parser.ParseProgram(); @@ -17,7 +17,7 @@ public void Invoke_Simple() var code = prog.ToString(); Console.WriteLine(code); Assert.That(code, Is.EqualTo(@"( -(= (ref x),(ref test[])) +(= (ref x),(ref demo[])) )".ReplaceLineEndings(""))); } @@ -26,7 +26,7 @@ public void Invoke_Simple() public void Invoke_WithArg() { var input = @" -x = Test(1) +x = Demo(1) "; var parser = MakeParser(input); var prog = parser.ParseProgram(); @@ -35,7 +35,7 @@ public void Invoke_WithArg() var code = prog.ToString(); Console.WriteLine(code); Assert.That(code, Is.EqualTo(@"( -(= (ref x),(ref test[(1)])) +(= (ref x),(ref demo[(1)])) )".ReplaceLineEndings(""))); } @@ -44,7 +44,7 @@ public void Invoke_WithArg() public void Invoke_Statement() { var input = @" -Test(1) +Demo(1) "; var parser = MakeParser(input); var prog = parser.ParseProgram(); @@ -53,7 +53,7 @@ public void Invoke_Statement() var code = prog.ToString(); Console.WriteLine(code); Assert.That(code, Is.EqualTo(@"( -(expr (ref test[(1)])) +(expr (ref demo[(1)])) )".ReplaceLineEndings(""))); } diff --git a/FadeBasic/Tests/FunctionVmTests.cs b/FadeBasic/Tests/FunctionVmTests.cs index ebc41e1..29c9ef6 100644 --- a/FadeBasic/Tests/FunctionVmTests.cs +++ b/FadeBasic/Tests/FunctionVmTests.cs @@ -11,10 +11,10 @@ public partial class TokenVm public void Function_NoReturn() { var src = @" -x = Test() +x = Demo() y = x END -Function Test() +Function Demo() EndFunction @@ -35,10 +35,10 @@ public void Function_GotoHell() // TODO: how do we stop people from jumping INTO a function? // probably by adding a restrictino that you cannot goto between function scopes ? var src = @" -x = Test() +x = Demo() goto truck ` this line causes execution to jump into a function, which is bad bad bad ` the important part is that no END expression exists, but the compiler should auto-end -Function Test() +Function Demo() a = 1 + 2 truck: EndFunction a @@ -53,9 +53,9 @@ Function Death() public void Function_AutoEnd() { var src = @" -x = Test() +x = Demo() ` the important part is that no END expression exists, but the compiler should auto-end -Function Test() +Function Demo() a = 1 + 2 EndFunction a "; @@ -72,10 +72,10 @@ EndFunction a public void Function_Simple() { var src = @" -x = Test() +x = Demo() END -Function Test() +Function Demo() a = 1 + 2 EndFunction a "; @@ -87,8 +87,8 @@ EndFunction a Assert.That(vm.dataRegisters[0], Is.EqualTo(3)); Assert.That(vm.typeRegisters[0], Is.EqualTo(TypeCodes.INT)); - Assert.That(vm.internedData.functions["test"].typeId, Is.EqualTo(0)); - Assert.That(vm.internedData.functions["test"].typeCode, Is.EqualTo(TypeCodes.INT)); + Assert.That(vm.internedData.functions["demo"].typeId, Is.EqualTo(0)); + Assert.That(vm.internedData.functions["demo"].typeCode, Is.EqualTo(TypeCodes.INT)); } @@ -98,10 +98,10 @@ public void Function_Global() var src = @" global y as integer y = 1 -x = Test() +x = Demo() END -Function Test() +Function Demo() a = y EndFunction a "; @@ -126,10 +126,10 @@ TYPE egg GLOBAL albert AS egg ` declare as global, so it can be used in function albert.x = 42 -z = Test() ` put the result onto a variable so we can validate it +z = Demo() ` put the result onto a variable so we can validate it END -Function Test() +Function Demo() EndFunction albert.x ` just access the global value "; Setup(src, out _, out var prog); @@ -153,10 +153,10 @@ TYPE egg GLOBAL albert AS egg ` declare as global, so it can be used in function albert.x = 42 z = 1 -z = Test() ` put the result onto a variable so we can validate it +z = Demo() ` put the result onto a variable so we can validate it END -Function Test() +Function Demo() EndFunction albert.x ` just access the global value "; Setup(src, out _, out var prog); @@ -174,10 +174,10 @@ public void Function_Local() var src = @" local y as integer y = 1 -x = Test() +x = Demo() END -Function Test() +Function Demo() a = y EndFunction a "; @@ -196,10 +196,10 @@ public void Function_ExplicitTypedArg() { var src = @" x as byte -x = Test(2) +x = Demo(2) END -Function Test(a as byte) +Function Demo(a as byte) EndFunction a * 2 "; Setup(src, out _, out var prog); @@ -216,10 +216,10 @@ EndFunction a * 2 public void Function_String() { var src = @" -x$ = Test(""world"") +x$ = Demo(""world"") END -Function Test(a as string) +Function Demo(a as string) EndFunction a + ""hello"" "; Setup(src, out _, out var prog); @@ -240,9 +240,9 @@ public void Function_Return_String() { var src = @" x$ = """" -x$ = Test() +x$ = Demo() END -Function Test() +Function Demo() a$ = ""hello"" EndFunction a$ "; @@ -258,8 +258,8 @@ EndFunction a$ Assert.That(vm.typeRegisters[0], Is.EqualTo(TypeCodes.STRING)); - Assert.That(vm.internedData.functions["test"].typeId, Is.EqualTo(0)); - Assert.That(vm.internedData.functions["test"].typeCode, Is.EqualTo(TypeCodes.STRING)); + Assert.That(vm.internedData.functions["demo"].typeId, Is.EqualTo(0)); + Assert.That(vm.internedData.functions["demo"].typeCode, Is.EqualTo(TypeCodes.STRING)); } @@ -274,11 +274,11 @@ TYPE egg e1 as egg e1.x = 1 -e2 = Test(e1) +e2 = Demo(e1) e2.x = e2.x * 2 END -Function Test(e as egg) +Function Demo(e as egg) e.x = e.x + 1 e.x = e.x + 1 EndFunction e @@ -339,10 +339,10 @@ dim cards(3) as integer cards(1) = 200 cards(2) = 300 -x = Test() +x = Demo() END -Function Test() +Function Demo() g = cards(0) + cards(1) + cards(2) EndFunction g "; @@ -374,10 +374,10 @@ dim cards(3) as cardType cards(2).suit = 5 cards(2).value = 8 -x = Test(2) +x = Demo(2) END -Function Test(index) +Function Demo(index) ct as cardType ct = cards(index) `IF ct.suit = 5 then returnValue$ = ""of pie"" @@ -415,10 +415,10 @@ dim cards(3) as cardType cards(2).suit = 5 cards(2).value = 8 -x$ = Test(2) +x$ = Demo(2) print x$ END -Function Test(index) +Function Demo(index) ct as cardType ct = cards(index) IF ct.suit = 5 then returnValue$ = ""of pie"" @@ -461,10 +461,10 @@ e as egg e.x = 32 e.y = 66 -derp = Test(e) +derp = Demo(e) END -Function Test(a as egg) +Function Demo(a as egg) EndFunction a.x + a.y "; Setup(src, out _, out var prog); @@ -500,10 +500,10 @@ TYPE egg e as egg e.x = 32 e.y = 66 -Test(e) +Demo(e) END -Function Test(a as egg) +Function Demo(a as egg) a.x = a.x * 2 a.y = a.y - 10 EndFunction @@ -532,10 +532,10 @@ Function Test(a as egg) public void Function_ArgOrder_1() { var src = @" -x = Test(1, 2) +x = Demo(1, 2) END -Function Test(a, b) +Function Demo(a, b) EndFunction a "; Setup(src, out _, out var prog); @@ -552,10 +552,10 @@ EndFunction a public void Function_ArgOrder_2() { var src = @" -x = Test(1, 2) +x = Demo(1, 2) END -Function Test(a, b) +Function Demo(a, b) EndFunction b "; Setup(src, out var compiler, out var prog); @@ -572,10 +572,10 @@ EndFunction b public void Function_Args() { var src = @" -x = Test(1, 2) +x = Demo(1, 2) END -Function Test(a, b) +Function Demo(a, b) EndFunction a + b "; Setup(src, out _, out var prog); @@ -589,11 +589,11 @@ EndFunction a + b // parameters are in reverse order of index - Assert.That(vm.internedData.functions["test"].parameters[1].index, Is.EqualTo(0)); - Assert.That(vm.internedData.functions["test"].parameters[1].name, Is.EqualTo("a")); + Assert.That(vm.internedData.functions["demo"].parameters[1].index, Is.EqualTo(0)); + Assert.That(vm.internedData.functions["demo"].parameters[1].name, Is.EqualTo("a")); - Assert.That(vm.internedData.functions["test"].parameters[0].index, Is.EqualTo(1)); - Assert.That(vm.internedData.functions["test"].parameters[0].name, Is.EqualTo("b")); + Assert.That(vm.internedData.functions["demo"].parameters[0].index, Is.EqualTo(1)); + Assert.That(vm.internedData.functions["demo"].parameters[0].name, Is.EqualTo("b")); } @@ -601,10 +601,10 @@ EndFunction a + b public void Function_Args_Cast() { var src = @" -x = Test(1.2) +x = Demo(1.2) END -Function Test(a) +Function Demo(a) EndFunction a + 1 "; Setup(src, out _, out var prog); @@ -622,10 +622,10 @@ EndFunction a + 1 public void Function_Args_TypeCast() { var src = @" -x = Test(1.2) +x = Demo(1.2) END -Function Test(a#) +Function Demo(a#) EndFunction a# + 1 "; Setup(src, out _, out var prog); @@ -643,10 +643,10 @@ EndFunction a# + 1 public void Function_Args_TypeCast_IntToFloat() { var src = @" -x# = Test(1) +x# = Demo(1) END -Function Test(a#) +Function Demo(a#) EndFunction a# + 1 "; Setup(src, out _, out var prog); @@ -667,10 +667,10 @@ EndFunction a# + 1 public void Function_Args_TypeCast_OrderFlip() { var src = @" -x = Test(5.2) +x = Demo(5.2) END -Function Test(a#) +Function Demo(a#) EndFunction 1 + a# "; Setup(src, out _, out var prog); @@ -688,10 +688,10 @@ EndFunction 1 + a# public void Function_Args_TypeCast_NoCast() { var src = @" -x# = Test(1.2) +x# = Demo(1.2) END -Function Test(a#) +Function Demo(a#) EndFunction a# + 1 "; Setup(src, out _, out var prog); @@ -712,10 +712,10 @@ public void Function_Scoping() { var src = @" y = 1 -Test() +Demo() END -Function Test() +Function Demo() y = 2 EndFunction "; @@ -733,12 +733,12 @@ Function Test() public void Function_Recursion() { var src = @" -x = Test(1) +x = Demo(1) END -Function Test(a) +Function Demo(a) IF a < 10 - a = Test(a + 1) + a = Demo(a + 1) ENDIF EndFunction a "; @@ -758,9 +758,9 @@ EndFunction a public void Function_UnusedReturnValue() { var src = @" -Test(1) +Demo(1) -Function Test(a) +Function Demo(a) ` the value a will be put onto the stack due to the return, but nothing takes it off? EndFunction a "; diff --git a/FadeBasic/Tests/JsonTests.cs b/FadeBasic/Tests/JsonTests.cs index 3d8be76..392060e 100644 --- a/FadeBasic/Tests/JsonTests.cs +++ b/FadeBasic/Tests/JsonTests.cs @@ -12,7 +12,7 @@ class RecurseList : IJsonable { public int n; public List l = new List(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(n), ref n); op.IncludeField(nameof(l), ref l); @@ -22,7 +22,7 @@ public void ProcessJson(IJsonOperation op) class ByteArray : IJsonable { public byte[] numbers; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(numbers), ref numbers); } @@ -33,7 +33,7 @@ class StringInt : IJsonable public string reason; public int status; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(reason), ref reason); op.IncludeField(nameof(status), ref status); @@ -44,7 +44,7 @@ class Dud : IJsonable { public int x; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField("x", ref x); } @@ -53,7 +53,7 @@ public void ProcessJson(IJsonOperation op) class DictTest : IJsonable { public Dictionary duds = new Dictionary(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField("duds", ref duds); } @@ -63,7 +63,7 @@ class DictStringInt : IJsonable { public Dictionary duds = new Dictionary(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(duds), ref duds); } @@ -74,7 +74,7 @@ class DoubleDict : IJsonable public Dictionary beeps = new Dictionary(); public Dictionary boops = new Dictionary(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(beeps), ref beeps); op.IncludeField(nameof(boops), ref boops); @@ -82,7 +82,7 @@ public void ProcessJson(IJsonOperation op) } [Test] - public void DebugScopeTest() + public void DebugScopeDemo() { var msg = new ScopesMessage { @@ -112,7 +112,7 @@ public void DebugScopeTest() } [Test] - public void StringIntTest() + public void StringIntDemo() { var x = new StringInt { @@ -142,7 +142,7 @@ public void DoubleDictTest_Empty() } [Test] - public void DictStringIntTest() + public void DictStringIntDemo() { var x = new DictStringInt { @@ -179,7 +179,7 @@ public void Dict() } [Test] - public void ByteArray_Test() + public void ByteArray_Demo() { var x = new ByteArray { diff --git a/FadeBasic/Tests/Jsonable2Tests.cs b/FadeBasic/Tests/Jsonable2Tests.cs index 018de03..34d51ea 100644 --- a/FadeBasic/Tests/Jsonable2Tests.cs +++ b/FadeBasic/Tests/Jsonable2Tests.cs @@ -5,7 +5,7 @@ namespace Tests; public class Jsonable2Tests { [Test] - public void Test() + public void Demo() { var json = "{\"value\":\" \\\\\"}"; var data = Jsonable2.Parse(json); diff --git a/FadeBasic/Tests/LauncherTestArgsTests.cs b/FadeBasic/Tests/LauncherTestArgsTests.cs new file mode 100644 index 0000000..9fdacc1 --- /dev/null +++ b/FadeBasic/Tests/LauncherTestArgsTests.cs @@ -0,0 +1,182 @@ +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.Sdk; + +namespace Tests; + +[TestFixture] +public class LauncherTestArgsTests +{ + private FadeRuntimeContext CreateContext(string src) + { + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, out var ctx, out var errors); + Assert.That(ok, Is.True, + "expected clean compile; got: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + private (int exit, string stdout, string stderr) DispatchWithCapture(ITestLaunchable launchable, string[] args) + { + var stdout = new StringWriter(); + var stderr = new StringWriter(); + var savedOut = Console.Out; + var savedErr = Console.Error; + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var handled = Launcher.TryDispatchTestArgs(launchable, args, out var exit); + Assert.That(handled, Is.True, "expected test args to be handled"); + return (exit, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(savedOut); + Console.SetError(savedErr); + } + } + + [Test] + public void Dispatch_FadeListTests_PrintsManifestAndReturnsZero() + { + var src = @" +end + +test alpha +endtest + +test beta +endtest + +abstract test fixture +endtest +"; + var ctx = CreateContext(src); + var (exit, stdout, _) = DispatchWithCapture(ctx, new[] { "--fade-list-tests" }); + Assert.That(exit, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("alpha")); + Assert.That(stdout, Does.Contain("beta")); + Assert.That(stdout, Does.Not.Contain("fixture"), + "abstract tests should not appear in --fade-list-tests output"); + } + + [Test] + public void Dispatch_FadeTestEqualsName_RunsSingleTest_ExitsZero() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var (exit, stdout, _) = DispatchWithCapture(ctx, new[] { "--fade-test=alpha" }); + Assert.That(exit, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("PASS")); + Assert.That(stdout, Does.Contain("alpha")); + } + + [Test] + public void Dispatch_FadeTestSpaceName_AlsoSupported() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var (exit, _, _) = DispatchWithCapture(ctx, new[] { "--fade-test", "alpha" }); + Assert.That(exit, Is.EqualTo(0)); + } + + [Test] + public void Dispatch_FadeTest_FailingTest_ExitsOne() + { + var src = @" +end + +test broken + assert 1 = 2 +endtest +"; + var ctx = CreateContext(src); + var (exit, stdout, _) = DispatchWithCapture(ctx, new[] { "--fade-test=broken" }); + Assert.That(exit, Is.EqualTo(1)); + Assert.That(stdout, Does.Contain("FAIL")); + } + + [Test] + public void Dispatch_FadeTestAll_RunsAllTests() + { + var src = @" +end + +test passes + assert 1 = 1 +endtest + +test fails + assert 1 = 2 +endtest +"; + var ctx = CreateContext(src); + var (exit, stdout, _) = DispatchWithCapture(ctx, new[] { "--fade-test-all" }); + Assert.That(exit, Is.EqualTo(1), "any failure should produce exit code 1"); + Assert.That(stdout, Does.Contain("PASS")); + Assert.That(stdout, Does.Contain("FAIL")); + Assert.That(stdout, Does.Contain("1 passed")); + Assert.That(stdout, Does.Contain("1 failed")); + } + + [Test] + public void Dispatch_FadeTestAll_AllPassing_ExitZero() + { + var src = @" +end + +test a + assert 1 = 1 +endtest + +test b + assert 2 = 2 +endtest +"; + var ctx = CreateContext(src); + var (exit, _, _) = DispatchWithCapture(ctx, new[] { "--fade-test-all" }); + Assert.That(exit, Is.EqualTo(0)); + } + + [Test] + public void Dispatch_UnknownTestName_ExitsOne() + { + var src = @" +end + +test foo +endtest +"; + var ctx = CreateContext(src); + var (exit, _, stderr) = DispatchWithCapture(ctx, new[] { "--fade-test=does_not_exist" }); + Assert.That(exit, Is.EqualTo(1)); + Assert.That(stderr, Does.Contain("does_not_exist")); + } + + [Test] + public void Dispatch_NoTestArgs_ReturnsFalse() + { + var src = @" +end + +test foo +endtest +"; + var ctx = CreateContext(src); + var handled = Launcher.TryDispatchTestArgs(ctx, new[] { "--something-else" }, out var exit); + Assert.That(handled, Is.False, + "non-test args should NOT be handled by the test dispatcher"); + } +} diff --git a/FadeBasic/Tests/LenKeywordTests.cs b/FadeBasic/Tests/LenKeywordTests.cs new file mode 100644 index 0000000..054b5ad --- /dev/null +++ b/FadeBasic/Tests/LenKeywordTests.cs @@ -0,0 +1,124 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Sdk; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class LenKeywordTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunMain(string src) + { + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + vm.Execute3(); + return vm; + } + + private List ParseErrors(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + return prog.GetAllErrors(); + } + + private bool TryFindGlobalValue(VirtualMachine vm, ulong value) + { + for (var i = 0; i < vm.globalScope.dataRegisters.Length; i++) + { + if (vm.globalScope.dataRegisters[i] == value) return true; + } + return false; + } + + [Test] + public void Len_IntArray_ReturnsElementCount() + { + var src = @" +dim xs(3) +n = len(xs) +"; + var vm = RunMain(src); + Assert.That(TryFindGlobalValue(vm, 3), Is.True, "expected len(xs) = 3"); + } + + [Test] + public void Len_String_ReturnsCharCount() + { + var src = @" +n = len(""hello"") +"; + var vm = RunMain(src); + Assert.That(TryFindGlobalValue(vm, 5), Is.True, "expected len(\"hello\") = 5"); + } + + [Test] + public void Len_EmptyString_ReturnsZero() + { + var src = @" +n = len("""") +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + vm.Execute3(); + + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + Assert.That(compiler.globalScope.TryGetVariable("n", out var nVar), Is.True, + "compiler should have allocated a register for `n`"); + Assert.That(vm.globalScope.dataRegisters[nVar.registerAddress], Is.EqualTo(0UL), + "expected len(\"\") to write 0 into n's register"); + } + + [Test] + public void Len_InAssignmentToLong_Works() + { + var src = @" +dim xs(7) +m as long = len(xs) +"; + var vm = RunMain(src); + Assert.That(TryFindGlobalValue(vm, 7), Is.True, "expected len(xs) = 7 stored as long"); + } + + [Test] + public void Len_OnNonArrayNonString_Errors() + { + // `len` on an int variable should error at validation time. + var src = @" +x = 5 +n = len(x) +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.LenInvalidType)), + Is.True, + "expected LenInvalidType; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Len_MissingParens_Errors() + { + // `len xs` (no parens) should fail to parse. + var src = @" +dim xs(3) +n = len xs +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.LenMissingParens)), + Is.True, + "expected LenMissingParens; got: " + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/MockExecutionTests.cs b/FadeBasic/Tests/MockExecutionTests.cs new file mode 100644 index 0000000..4d819e4 --- /dev/null +++ b/FadeBasic/Tests/MockExecutionTests.cs @@ -0,0 +1,914 @@ +using FadeBasic; +using FadeBasic.Sdk; + +namespace Tests; + +[TestFixture] +public class MockExecutionTests +{ + [SetUp] + public void Reset() + { + TestCommands.waitMsCallCount = 0; + } + + private FadeRuntimeContext CreateContext(string src) + { + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out var ctx, out var errors); + Assert.That(ok, Is.True, + "expected clean compile; got: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + [Test] + public void MockEmpty_SuppressesRealCall() + { + // `mock wait ms / endmock` installs a void mock. The C# WaitMs + // method should NOT be called, so waitMsCallCount stays at 0. + var src = @" +checkpoint: +wait ms 50 +end + +test no_real_wait + mock wait ms + endmock + runto checkpoint + wait ms 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("no_real_wait"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.waitMsCallCount, Is.EqualTo(0), + "wait ms should have been mocked away both in main-body (via runto) and test body"); + } + + [Test] + public void MockReturns_OverridesReturnValue() + { + var src = @" +end + +test mocked_screen_width + mock screen width + exitmock 42 + endmock + assert screen width() = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("mocked_screen_width"); + Assert.That(result.passed, Is.True, + "expected screen width() to return 42; failure: " + result.failureMessage); + } + + [Test] + public void NoMock_RealCommandRuns() + { + // Without any mock, screen width returns its default (5 per TestCommands). + var src = @" +end + +test no_mock + assert screen width() = 5 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("no_mock"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockForbid_FailsTestWhenCommandCalled() + { + var src = @" +end + +test forbidden + mock wait ms + forbid + endmock + wait ms 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("forbidden"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureMessage, Does.Contain("forbidden")); + Assert.That(result.failureMessage, Does.Contain("wait ms")); + } + + [Test] + public void MockReturns_AppliesToProgramRunByRunto() + { + // The mock installs first; then runto drives the program past a call + // to screen width. The program-side call must also see the mocked value. + var src = @" +local w as integer +w = screen width() +checkpoint: +end + +test mocked_via_runto + mock screen width + exitmock 99 + endmock + runto checkpoint + assert w = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("mocked_via_runto"); + Assert.That(result.passed, Is.True, + "program code should observe the mocked value when run via runto; failure: " + + result.failureMessage); + } + + [Test] + public void ClearMock_RestoresRealBehavior() + { + var src = @" +end + +test clear_mock + mock screen width + exitmock 42 + endmock + assert screen width() = 42 + clear mock screen width + assert screen width() = 5 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("clear_mock"); + Assert.That(result.passed, Is.True, + "after `clear mock`, real implementation should run again; failure: " + + result.failureMessage); + } + + [Test] + public void ClearMocks_RemovesAllRegistrations() + { + var src = @" +end + +test clear_all + mock screen width + exitmock 42 + endmock + mock wait ms + endmock + clear mocks + assert screen width() = 5 + wait ms 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("clear_all"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.waitMsCallCount, Is.EqualTo(1), + "wait ms should have been called once after `clear mocks`"); + } + + [Test] + public void MockForbid_WithReason_CapturesReason() + { + var src = @" +end + +test forbid_with_reason + mock wait ms + forbid ""no waiting in tests"" + endmock + wait ms 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("forbid_with_reason"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureReason, Is.EqualTo("no waiting in tests")); + Assert.That(result.failureMessage, Does.Contain("no waiting in tests"), + "user-supplied reason should surface in the failure message"); + } + + [Test] + public void MockForbid_RunsDefersOnFailure() + { + // Forbid now goes through the assert-unwind trampoline, so defers + // in every live scope drain before the test runner sees the result. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test forbid_drains_defers + defer static print ""cleanup"" + mock wait ms + forbid + endmock + wait ms 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("forbid_drains_defers"); + Assert.That(result.passed, Is.False); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "cleanup" }), + "forbid failure must drain test-scope defers"); + } + + [Test] + public void MockForbid_CapturesCallStack() + { + // Forbid carries a source-located stack like an assert does. + var src = @" +function trigger() + wait ms 1 +endfunction +trigger() +checkpoint: +end + +test forbid_stack + mock wait ms + forbid ""nope"" + endmock + runto checkpoint +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("forbid_stack"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureFrames, Is.Not.Empty, + "forbid failure should resolve to source frames when DebugData is present"); + // Phase B: the body itself is a dispatched bytecode block, so the + // innermost frame is the mock body (named after the command). The + // frame immediately below shows where the forbidden call originated + // — `trigger()` in this case. + Assert.That(result.failureFrames.Count, Is.GreaterThanOrEqualTo(2), + "expected at least mock-body frame + caller frame"); + Assert.That(result.failureFrames[0].functionName, Is.EqualTo("wait ms"), + "innermost frame is the mock body, named after the mocked command"); + Assert.That(result.failureFrames[1].functionName, Is.EqualTo("trigger"), + "frame below the mock body shows where the forbidden call originated"); + } + + // ── call count ─────────────────────────────────────────────── + + [Test] + public void CallCount_CountsHostInvocations() + { + // No mock installed — the real command runs and gets counted. The + // counter increments on every CALL_HOST in test mode. + var src = @" +end + +test count_real_calls + wait ms 1 + wait ms 1 + wait ms 1 + assert call count wait ms = 3 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("count_real_calls"); + Assert.That(result.passed, Is.True, + "expected count=3; failure: " + result.failureMessage); + } + + [Test] + public void CallCount_ZeroForUncalledCommand() + { + // A command that's never called returns 0. No mock needed; the + // counter starts empty and the runtime treats missing keys as 0. + var src = @" +end + +test never_called + assert call count wait ms = 0 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("never_called"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void CallCount_CountsMockedCalls() + { + // Mocking doesn't suppress counting — the count includes calls that + // hit a mock too. + var src = @" +end + +test count_mocked + mock wait ms + endmock + wait ms 1 + wait ms 2 + assert call count wait ms = 2 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("count_mocked"); + Assert.That(result.passed, Is.True, + "mocked calls should still be counted; failure: " + result.failureMessage); + } + + [Test] + public void CallCount_IsolatedBetweenTests() + { + // Counts reset per test (each test gets a fresh VM). + var src = @" +end + +test first + wait ms 1 + assert call count wait ms = 1 +endtest + +test second + assert call count wait ms = 0 +endtest +"; + var ctx = CreateContext(src); + var first = ctx.RunTest("first"); + var second = ctx.RunTest("second"); + Assert.That(first.passed, Is.True, first.failureMessage); + Assert.That(second.passed, Is.True, + "second test must see count=0; failure: " + second.failureMessage); + } + + // ── Phase B: mock body as mini-function ──────────────────────────────── + + [Test] + public void MockBody_RunsStatementsAtCallTime() + { + // The body executes every time the mocked command is called, not at + // install time. We use the host-side staticPrintBuffer to observe. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test body_runs + mock screen width + static print ""called"" + exitmock 7 + endmock + local w as integer = screen width() + local w2 as integer = screen width() + assert w = 7 + assert w2 = 7 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("body_runs"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "called", "called" }), + "body should run once per call, not at install time"); + } + + [Test] + public void MockBody_LocalAndIf_Work() + { + // Arbitrary test-block statements (local, if/then) inside a body. + var src = @" +end + +test body_with_local + mock screen width + local result as integer + result = 100 + if result > 50 then result = 99 + exitmock result + endmock + assert screen width() = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("body_with_local"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_ParamsBoundToArgs() + { + // `mock ` binds the command's arg to a local named + // inside the body. The body can read it to compute a return + // value based on the input. + var src = @" +end + +test param_binding + mock prim test di n + exitmock n * 3 + endmock + local x as long = prim test di(5) + assert x = 15 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("param_binding"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + // ── Ref-arg writes inside a mock body ────────────────────────────────── + + [Test] + public void MockBody_RefArgWrite_BackToCaller() + { + // `inc` takes `(ref int variable, int amount = 1)`. A mock body that + // names the ref param and writes to it via plain assignment should + // mutate the caller's variable. + var src = @" +end + +test ref_write + mock inc target, amount + target = 99 + endmock + local x as integer = 5 + inc x, 1 + assert x = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("ref_write"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_RefArg_CoercesType() + { + // `target = 200` writes an int literal through a ref to an integer; + // the same coercion rules as `local n as long = 5` apply. + var src = @" +end + +test ref_coerce + mock inc target, amount + target = 200 + endmock + local x as integer + x = 0 + inc x, 1 + assert x = 200 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("ref_coerce"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_EndmockWithExpression_ReturnsValue() + { + // `endmock ` provides the fall-through return value, mirroring + // `endfunction ` for functions. No `exitmock` needed for the + // simple case. + var src = @" +end + +test endmock_expr + mock screen width + endmock 7 + assert screen width() = 7 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("endmock_expr"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_ExitmockEarlyExit_OverridesEndmock() + { + // `exitmock` is an early return. If hit, the fall-through + // `endmock ` is bypassed. + var src = @" +end + +test exitmock_short_circuit + mock screen width + exitmock 100 + endmock 200 + assert screen width() = 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("exitmock_short_circuit"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_RefParamReadsInitialCallerValue() + { + // The body's value-register for a ref param is seeded from the + // caller's variable at body entry, so `static print target` shows + // whatever the caller passed in — not the pointer bytes. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test ref_read_initial + mock inc target, amount + static print str$(target) + target = 99 + endmock + local x as integer = 5 + inc x, 1 + assert x = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("ref_read_initial"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "5" }), + "body should read the caller's pre-call value through the ref param"); + } + + [Test] + public void MockBody_RefParamReadsAfterWrite() + { + // After the body assigns `target`, subsequent reads of `target` + // inside the body see the new value (it's a normal local). + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test ref_read_after_write + mock inc target, amount + target = 99 + static print str$(target) + endmock + local x as integer = 5 + inc x, 1 + assert x = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("ref_read_after_write"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "99" }), + "body should read its own latest write to the ref param"); + } + + // ── Params arg gathered into a Fade array inside a mock body ────────── + + [Test] + public void MockBody_ParamsArg_LenReturnsCount() + { + // `sum(params int[] numbers)` — a mock body that names the params + // arg should receive it as a Fade array. `len(nums)` returns the + // count the caller passed. + var src = @" +end + +test params_len + mock sum(nums) + endmock len(nums) + assert sum(10, 20, 30) = 3 + assert sum() = 0 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("params_len"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_ParamsArg_IndexedAccess() + { + // The body can read individual elements by index. Returning the + // first element proves indexing works. + var src = @" +end + +test params_index + mock sum(nums) + endmock nums(0) + assert sum(42, 100, 7) = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("params_index"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_ParamsArg_SumViaIteration() + { + // Sum the elements via for/len inside the body. Verifies the + // gathered array round-trips length + indexing + control flow. + var src = @" +end + +test params_sum_via_iter + mock sum(nums) + total = 0 + for i = 0 to len(nums) - 1 + total = total + nums(i) + next i + endmock total + assert sum(1, 2, 3, 4) = 10 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("params_sum_via_iter"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockIsolation_BetweenTestRuns() + { + // Each RunTest gets a fresh VM. A mock installed in one test must + // not affect a sibling test in the same context. + var src = @" +end + +test installs_mock + mock screen width + exitmock 42 + endmock + assert screen width() = 42 +endtest + +test sees_no_mock + assert screen width() = 5 +endtest +"; + var ctx = CreateContext(src); + var first = ctx.RunTest("installs_mock"); + var second = ctx.RunTest("sees_no_mock"); + Assert.That(first.passed, Is.True, first.failureMessage); + Assert.That(second.passed, Is.True, + "second test must not see the first test's mock; failure: " + second.failureMessage); + } + + // ── Self-recursive call: mocked command name inside body → real ──────── + + [Test] + public void MockBody_SelfCall_VoidCommand_RunsRealCommand() + { + // Inside the mock for `inc`, writing `inc target, amount` calls + // the real underlying C# Inc rather than recursing into the mock. + var src = @" +end + +test selfcall_void + mock inc target, amount + inc target, amount + endmock + local x as integer = 10 + inc x, 5 + assert x = 15 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_void"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_ReturningCommand_CapturesValue() + { + // `screen width()` returns 5 in TestCommands. Inside the mock, the + // same expression invokes the real command. + var src = @" +end + +test selfcall_return + mock screen width + real_width = screen width() + exitmock real_width + 100 + endmock + assert screen width() = 105 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_return"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_RefArgFlushesUserChangeThenRealRuns() + { + // User writes `target = 100` then self-calls. The compiler flushes + // the value-reg through the hidden ptr first, so the real Inc reads + // 100 and adds 1 → caller's x = 101. + var src = @" +end + +test selfcall_ref_flush + mock inc target, amount + target = 100 + inc target, amount + endmock + local x as integer = 0 + inc x, 1 + assert x = 101 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_ref_flush"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_RefRefreshesLocalAfterCall() + { + // After the self-call runs the real Inc, the body's `target` value + // reg is refreshed from the caller — subsequent reads observe the + // real output. A trailing user write to `target` still wins at exit. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test selfcall_ref_refresh + mock inc target, amount + inc target, amount + static print str$(target) + target = 999 + endmock + local x as integer = 10 + inc x, 5 + assert x = 999 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_ref_refresh"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "15" }), + "after the self-call the body should observe the real-Inc result (10+5)"); + } + + [Test] + public void MockBody_SelfCall_ModifiedValueArg() + { + // The user supplies any expression at value positions. Here the + // mock calls the real Inc with a doubled amount. + var src = @" +end + +test selfcall_modified_value + mock inc target, amount + inc target, amount * 2 + endmock + local x as integer = 0 + inc x, 5 + assert x = 10 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_modified_value"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_LiteralOverride() + { + // The mock ignores the user's `amount` entirely, calling the real + // Inc with a hard-coded literal. + var src = @" +end + +test selfcall_literal + mock inc target, amount + inc target, 100 + endmock + local x as integer = 0 + inc x, 5 + assert x = 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_literal"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_ParamsArgSpread() + { + // The body owns the gathered array as `nums`. Passing it as the + // sole arg at the params position spreads it through to the real + // sum, which sums the mutated values. + var src = @" +end + +test selfcall_params_spread + mock sum(nums) + for i = 0 to len(nums) - 1 + nums(i) = nums(i) * 10 + next i + exitmock sum(nums) + endmock + assert sum(1, 2, 3) = 60 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_params_spread"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_RefArgMustBeBoundParam() + { + // A self-recursive call must pass one of the mock's bound ref + // params at each ref position. A body-local int would yield a + // PTR_REG into the body's scope, which the scope swap in + // CALL_HOST_REAL turns into a write to the wrong cell. + var src = @" +end + +test bad_selfcall_ref + mock inc target, amount + local fake as integer + inc fake, amount + endmock +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, "expected compile failure when ref arg isn't a bound ref param"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.MockBodyRefArgMustBeBoundRefParam)), + Is.True, + "expected MockBodyRefArgMustBeBoundRefParam; got: " + errors.ToDisplay()); + } + + [Test] + public void MockBody_ParamsObjectArray_NamingFails_Cleanly() + { + // `static print` is `params object[]`. Naming the params slot + // (e.g. `mock static print(args)`) requires mixed-type element + // storage that the body array model doesn't support — surface a + // clean error rather than crashing the compiler on SIZE_TABLE[ANY]. + var src = @" +end + +test bad_params_object_named + mock static print(args) + endmock +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure on named params object[] slot"); + var match = errors.ParserErrors.FirstOrDefault( + e => e.errorCode.Equals(ErrorCodes.MockParamsObjectArrayUnnamable)); + Assert.That(match, Is.Not.Null, + "expected MockParamsObjectArrayUnnamable; got: " + errors.ToDisplay()); + // The site-specific detail names the offending param, the command + // being mocked, and the rewrite the user should reach for. + Assert.That(match.message, Does.Contain("args"), + "error should name the param the user tried to bind"); + Assert.That(match.message, Does.Contain("static print"), + "error should name the command being mocked"); + Assert.That(match.message, Does.Contain("params object[]"), + "error should call out the param shape causing the limitation"); + Assert.That(match.message, Does.Contain("mock static print"), + "error should show the rewrite (mock with no param name)"); + } + + [Test] + public void MockBody_ParamsObjectArray_UnnamedFormCompiles() + { + // The workaround: don't name the params slot. The mock still + // installs and the real call is suppressed/handled. + var src = @" +end + +test params_object_unnamed + mock static print + endmock + static print ""a"", ""b"" +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("params_object_unnamed"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_SatisfiesRefAssignedCheck() + { + // A bare self-call writes through every ref it's handed, so the + // mock body doesn't need a separate `target = ...` assignment. + var src = @" +end + +test selfcall_satisfies_ref_check + mock inc target, amount + inc target, amount + endmock + local x as integer = 3 + inc x, 4 + assert x = 7 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_satisfies_ref_check"); + Assert.That(result.passed, Is.True, result.failureMessage); + } +} diff --git a/FadeBasic/Tests/MockParserTests.cs b/FadeBasic/Tests/MockParserTests.cs new file mode 100644 index 0000000..c200ac8 --- /dev/null +++ b/FadeBasic/Tests/MockParserTests.cs @@ -0,0 +1,687 @@ +using FadeBasic; +using FadeBasic.Ast; + +namespace Tests; + +[TestFixture] +public class MockParserTests +{ + private ProgramNode Parse(string src, out List errors) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + errors = prog.GetAllErrors(); + return prog; + } + + private MockStatement FindFirstMock(ProgramNode prog) + { + foreach (var t in prog.tests) + { + foreach (var stmt in t.testProgram.statements) + { + if (stmt is MockStatement m) return m; + } + } + return null; + } + + private ClearMockStatement FindFirstClearMock(ProgramNode prog) + { + foreach (var t in prog.tests) + { + foreach (var stmt in t.testProgram.statements) + { + if (stmt is ClearMockStatement c) return c; + } + } + return null; + } + + // ── Block-form shape ─────────────────────────────────────────────────── + + [Test] + public void Mock_Empty_Body_ParsesAsVoidMock() + { + // Empty block = suppress the call. No inline form: `endmock` is + // required even for void mocks. + var src = @" +test foo + mock wait ms + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock, Is.Not.Null); + Assert.That(mock.commandName, Is.EqualTo("wait ms")); + Assert.That(mock.body, Is.Empty); + } + + [Test] + public void Mock_Returns_ParsesAsReturnsStatement() + { + var src = @" +test foo + mock screen width + exitmock 10 + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.body.Count, Is.EqualTo(1)); + Assert.That(mock.body[0], Is.TypeOf()); + var rs = (MockExitMockStatement)mock.body[0]; + Assert.That(rs.expression, Is.Not.Null); + } + + [Test] + public void Mock_Forbid_ParsesAsForbidStatement() + { + var src = @" +test foo + mock screen width + forbid + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty); + var mock = FindFirstMock(prog); + Assert.That(mock.body.Count, Is.EqualTo(1)); + Assert.That(mock.body[0], Is.TypeOf()); + var fs = (MockForbidStatement)mock.body[0]; + Assert.That(fs.reason, Is.Null); + } + + [Test] + public void Mock_ForbidWithReason_ParsesReason() + { + var src = @" +test foo + mock wait ms + forbid ""no waiting in tests"" + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + var fs = (MockForbidStatement)mock.body[0]; + Assert.That(fs.reason, Is.Not.Null); + } + + // ── Error paths ──────────────────────────────────────────────────────── + + [Test] + public void Mock_InlineForm_NoLongerSupported_Errors() + { + // Every mock requires its own `endmock`. Even when a body has a + // single `exitmock ` on the same line as the mock header, + // the parser will still keep looking for `endmock` and eventually + // run into the surrounding `endtest`, surfacing MockMissingEndMock. + var src = @" +test foo + mock screen width exitmock 10 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), + Is.True, + "inline mock should still require endmock; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_BareForm_NoLongerSupported_Errors() + { + // `mock cmd` with no `endmock` used to install a void mock. Now + // every mock requires `endmock`. + var src = @" +test foo + mock wait ms +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), + Is.True, + "bare mock should now require endmock; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_MissingEndMock_Errors() + { + var src = @" +test foo + mock screen width + exitmock 10 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), + Is.True, + "expected MockMissingEndMock; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_UnknownCommand_Errors() + { + // `not_a_real_command` won't merge into a CommandWord token, so the + // parser sees a missing command name. + var src = @" +test foo + mock not_a_real_command + exitmock 10 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingCommandName)), + Is.True, + "expected MockMissingCommandName; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_MultipleReturns_Errors() + { + // A body may have at most one `returns`. (Frequency is gone, so the + // old "stacked returns with different frequencies" use case is too.) + var src = @" +test foo + mock screen width + exitmock 10 + exitmock 20 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMultipleReturns)), + Is.True, + "expected MockMultipleReturns; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_MultipleForbid_Errors() + { + var src = @" +test foo + mock screen width + forbid + forbid + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMultipleForbid)), + Is.True, + "expected MockMultipleForbid; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ReturnsAndForbid_Errors() + { + // `returns` + `forbid` together is nonsensical: forbid prevents the + // return path from ever running. + var src = @" +test foo + mock screen width + exitmock 10 + forbid + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsAndForbid)), + Is.True, + "expected MockReturnsAndForbid; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ForbidReasonMustBeString() + { + var src = @" +test foo + mock wait ms + forbid 42 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockForbidReasonMustBeString)), + Is.True, + "expected MockForbidReasonMustBeString; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_BlockForm_MissingEndMock_DoesNotConsumeEndTest() + { + // When `endmock` is missing, the mock parser must NOT consume the + // surrounding `endtest`; the missing-endmock error is reported and + // the test parser still terminates correctly. + var src = @" +test foo + mock screen width + exitmock 10 +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), + Is.True, + "expected MockMissingEndMock; got: " + string.Join(", ", errs.Select(e => e.Display))); + // The test should still be properly closed. + Assert.That(prog.tests.Count, Is.EqualTo(1)); + } + + // ── ClearMock ────────────────────────────────────────────────────────── + + [Test] + public void ClearMock_SingleCommand_Parses() + { + var src = @" +test foo + clear mock screen width +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var clear = FindFirstClearMock(prog); + Assert.That(clear, Is.Not.Null); + Assert.That(clear.commandName, Is.EqualTo("screen width")); + } + + [Test] + public void ClearMocks_All_Parses() + { + var src = @" +test foo + clear mocks +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty); + var clear = FindFirstClearMock(prog); + Assert.That(clear, Is.Not.Null); + Assert.That(clear.commandName, Is.Null, "clear mocks should have null commandName (= clear all)"); + } + + [Test] + public void Clear_WithoutMockOrMocks_Errors() + { + var src = @" +test foo + clear something +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ClearMockMissingTarget)), + Is.True, + "expected ClearMockMissingTarget; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + // ── Scope enforcement ────────────────────────────────────────────────── + + [Test] + public void Mock_OutsideTest_Errors() + { + var src = @" +mock screen width + exitmock 10 +endmock +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockOutsideTest)), + Is.True, + "expected MockOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void ClearMock_OutsideTest_Errors() + { + var src = @" +clear mocks +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ClearMockOutsideTest)), + Is.True, + "expected ClearMockOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + // ── Type validation (Phase C) ────────────────────────────────────────── + + [Test] + public void Mock_ReturnsOnVoidCommand_Errors() + { + // `wait ms` is void — `returns 0` against it must error rather than + // silently degrade (the old behavior). + var src = @" +test foo + mock wait ms + exitmock 0 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsOnVoidCommand)), + Is.True, + "expected MockReturnsOnVoidCommand; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ReturnsTypeMismatch_Errors() + { + // `screen width` returns an int — returning a string should error. + var src = @" +test foo + mock screen width + exitmock ""nope"" + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsTypeMismatch)), + Is.True, + "expected MockReturnsTypeMismatch; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ReturnsNumericCoercion_Ok() + { + // `now` returns a long. `returns 5` (int literal) should coerce + // cleanly — same rule that lets `local n as long = 5` work. We use + // EnforceTypeAssignment so the coercion semantics stay consistent + // with the rest of the language. + var src = @" +test foo + mock now + exitmock 5 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsTypeMismatch)), + Is.False, + "int → long coercion should be allowed in mock returns; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_EndmockExprTypeMismatch_Errors() + { + // `screen width` returns int. `endmock ""3""` (string literal) is + // not assignable to int — should error like `exitmock` does. + var src = @" +test foo + mock screen width + endmock ""3"" +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsTypeMismatch)), + Is.True, + "expected MockReturnsTypeMismatch on endmock string→int; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ReturnsMatchingType_Ok() + { + // `screen width` returns int; `returns 42` should be fine. + var src = @" +test foo + mock screen width + exitmock 42 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => + e.errorCode.Equals(ErrorCodes.MockReturnsOnVoidCommand) + || e.errorCode.Equals(ErrorCodes.MockReturnsTypeMismatch)), + Is.False, + "no type errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_EmptyBodyOnVoidCommand_Ok() + { + // Empty mock body = suppress the call. Legal for void commands — + // the caller doesn't read a return, so there's nothing to leak. + var src = @" +test foo + mock wait ms + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "empty mock body on void command should parse cleanly; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_EmptyBodyOnValueCommand_Errors() + { + // A value-returning command's mock body must contain `returns` or + // `forbid`. An empty body would leave the caller's expected return + // value missing on the stack — that's now a compile-time error. + var src = @" +test foo + mock screen width + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockValueCommandMissingReturns)), + Is.True, + "expected MockValueCommandMissingReturns; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RefParamNotAssigned_Errors() + { + // A ref parameter must be assigned in the mock body — otherwise the + // caller's variable is left undefined. `forbid` short-circuits the + // check, but otherwise every ref param needs at least one top-level + // assignment. + var src = @" +test foo + mock inc target, amount + ` target (ref) never assigned + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockRefParamNotAssigned)), + Is.True, + "expected MockRefParamNotAssigned; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RefParamAssigned_Ok() + { + // `inc` takes `(ref int variable, int amount = 1)`. Assigning to + // `target` (the ref param) inside the body is the happy path and + // should produce no validation errors. + var src = @" +test foo + mock inc target, amount + target = 99 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockRefParamNotAssigned)), + Is.False, + "no ref errors expected; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RefParamUnassigned_WithForbid_Ok() + { + // `forbid` halts the test before the caller observes any output, + // so unassigned ref params are fine when forbid is present. + var src = @" +test foo + mock inc target, amount + forbid ""nope"" + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockRefParamNotAssigned)), + Is.False, + "forbid should suppress the ref-assignment requirement; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RuntoInBody_Errors() + { + // `runto` is a test-control primitive and must not appear inside a + // mock body. The body is mini-function bytecode run on dispatch, + // not a test-navigation context. + var src = @" +checkpoint: +end + +test foo + mock screen width + runto checkpoint + endmock 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.RuntoInsideMockBody)), + Is.True, + "expected RuntoInsideMockBody; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RuntoNestedInBody_Errors() + { + // Even wrapped in an `if`, runto inside a mock body is illegal — + // we walk the body tree recursively. + var src = @" +checkpoint: +end + +test foo + mock screen width + if 1 then runto checkpoint + endmock 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.RuntoInsideMockBody)), + Is.True, + "expected RuntoInsideMockBody (nested in if); got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ParamsInParens_ParseOk() + { + // `mock inc(target, amount)` should parse identically to the bare + // `mock inc target, amount` form. + var src = @" +test foo + mock inc(target, amount) + target = 99 + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.parameters.Count, Is.EqualTo(2)); + Assert.That(mock.parameters[0].variableName, Is.EqualTo("target")); + Assert.That(mock.parameters[1].variableName, Is.EqualTo("amount")); + } + + [Test] + public void Mock_ParamsInParens_MissingClose_Errors() + { + var src = @" +test foo + mock inc(target, amount + target = 99 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockParamsMissingCloseParen)), + Is.True, + "expected MockParamsMissingCloseParen; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ParamCountNoMatchingOverload_Errors() + { + // `inc` has one overload: `(ref int variable, int amount = 1)` — 2 + // args (the optional one still counts). A mock with 3 named params + // matches no overload and should error. + var src = @" +test foo + mock inc(a, b, c) + a = 1 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockParamCountNoMatchingOverload)), + Is.True, + "expected MockParamCountNoMatchingOverload; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_StringParamName_ParseOk() + { + // String-suffixed param names (`s$`) must be accepted as identifiers. + // The actual type comes from the command metadata. + var src = @" +test foo + mock tuna_echo a, x$ + x$ = ""hello"" + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.parameters.Count, Is.EqualTo(2)); + Assert.That(mock.parameters[1].variableName.ToLowerInvariant(), Does.Contain("x")); + } + + [Test] + public void Assert_OutsideTest_IsAllowed() + { + // Unrelated to mock but lives in this fixture historically. + // `assert` is legal in the main program; the VM crashes at runtime + // when one fails outside a test. + var src = @" +assert 1 = 1 +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.AssertOutsideTest)), + Is.False, + "AssertOutsideTest should no longer be raised; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/MusicFbasicReproTests.cs b/FadeBasic/Tests/MusicFbasicReproTests.cs new file mode 100644 index 0000000..55f5716 --- /dev/null +++ b/FadeBasic/Tests/MusicFbasicReproTests.cs @@ -0,0 +1,113 @@ +using FadeBasic; +using FadeBasic.Sdk; +using FadeBasic.Lib.Standard; +using NUnit.Framework; + +namespace Tests; + +[TestFixture] +public class MusicFbasicReproTests +{ + private const string Src = @" +print ""starting"" + +global x = 4 + +wait ms 1000 +lbl1: + +y = add(2) +lbl2: + +function add(a) + sum = a + x + + addfinal: +endfunction sum + +test abc + print ""running test"" + mock wait ms + endmock + + runto lbl1 + assert x = 4 + runto lbl2: + assert y > x +endtest +"; + + [Test] + public void MusicFbasic_RunsCleanly() + { + var commands = new CommandCollection(new ConsoleCommands(), new StandardCommands()); + var ok = Fade.TryCreateFromString(Src, commands, out var ctx, out var errors); + Assert.That(ok, Is.True, errors?.ToDisplay() ?? "(null errors)"); + + var result = ctx.RunTest("abc"); + Assert.That(result.passed, Is.True, + "expected pass; failure: " + result.failureMessage); + } + + // Mirrors the user-reported scenario: mock the 1-arg overload of INPUT + // and verify it writes back through the ref param. INPUT has two + // overloads (`input(ref string)` and `input(string, ref string)`), so + // the compiler must filter to the 1-arg version based on param count + // — otherwise the body's prelude binds against the wrong signature + // and the VM stack underflows at dispatch. + // The user's "I expected this to error" scenario: mock the 1-arg + // ref overload of `input` but never assign `val$` in the body. The + // ref-not-assigned check should fire — but only if the visitor picks + // the OVERLOAD MATCHING the user's param count, not just overloads[0] + // (which for input is the 2-arg `(prompt, ref output)` form, where + // overload[0].arg0 is a value `prompt`, not a ref). + [Test] + public void InputOverloadMock_RefUnassigned_Errors() + { + var src = @" +input x$ +_L1: +end + +test sample + mock input(val$) + ` val$ never assigned + endmock + runto _L1 + assert x$ = ""toast"" +endtest +"; + var commands = new CommandCollection(new ConsoleCommands(), new StandardCommands()); + Fade.TryCreateFromString(src, commands, out _, out var errors); + var hasRefError = errors != null + && errors.ParserErrors.Any(e => e.errorCode.Equals(ErrorCodes.MockRefParamNotAssigned)); + Assert.That(hasRefError, Is.True, + "expected MockRefParamNotAssigned on `val$`; got: " + + (errors == null ? "(null errors)" : errors.ToDisplay())); + } + + [Test] + public void InputOverloadMock_WritesBackToCaller() + { + var src = @" +input x$ +_L1: +end + +test sample + mock input(val$) + val$ = ""toast"" + endmock + runto _L1 + assert x$ = ""toast"" +endtest +"; + var commands = new CommandCollection(new ConsoleCommands(), new StandardCommands()); + var ok = Fade.TryCreateFromString(src, commands, out var ctx, out var errors); + Assert.That(ok, Is.True, errors?.ToDisplay() ?? "(null errors)"); + + var result = ctx.RunTest("sample"); + Assert.That(result.passed, Is.True, + "expected pass; failure: " + result.failureMessage); + } +} diff --git a/FadeBasic/Tests/ParserTests.cs b/FadeBasic/Tests/ParserTests.cs index 2e68a8c..39acc68 100644 --- a/FadeBasic/Tests/ParserTests.cs +++ b/FadeBasic/Tests/ParserTests.cs @@ -383,14 +383,14 @@ public void MultiStatement() [Test] public void CallHostStatement() { - var input = @"callTest"; + var input = @"callDemo"; var tokenStream = new TokenStream(_lexer.Tokenize(input, TestCommands.CommandsForTesting)); var parser = new Parser(tokenStream, TestCommands.CommandsForTesting); var prog = parser.ParseProgram(); Assert.That(prog.statements.Count, Is.EqualTo(1)); var code = prog.ToString(); - Assert.That(code, Is.EqualTo("((call callTest))")); + Assert.That(code, Is.EqualTo("((call callDemo))")); } @@ -713,7 +713,7 @@ public void DeclareFromSymbol_Variable_Lhs() [Test] - public void AnasUnfunTest() + public void AnasUnfunDemo() { var input = @" x = 1 + 2 > 3 @@ -727,6 +727,22 @@ public void AnasUnfunTest() } + [Test] + public void TestBlock_Function() + { + var input = @" +test block + x() + function x() + endfunction +endtest +"; + var parser = MakeParser(input); + var prog = parser.ParseProgram(); + prog.AssertNoParseErrors(); + } + + [Test] public void Default_int() { @@ -761,7 +777,7 @@ type egg } [Test] - public void Initializers_Test() + public void Initializers_Demo() { var input = @" type egg @@ -2058,7 +2074,7 @@ public void AssignmentWithCommandAndField() var code = prog.ToString(); Console.WriteLine(code); Assert.That(code, Is.EqualTo(@"( -(= (ref x),(+ ((ref a).(ref b)),(xcall len ((ref a).(ref c))))) +(= (ref x),(+ ((ref a).(ref b)),(len ((ref a).(ref c))))) )".ReplaceLineEndings(""))); } @@ -2369,4 +2385,4 @@ public void Decl_IntegerGlobal_3() prog.AssertNoParseErrors(); } -} \ No newline at end of file +} diff --git a/FadeBasic/Tests/ParserTests_Erros.cs b/FadeBasic/Tests/ParserTests_Erros.cs index 45ec2a8..52cb327 100644 --- a/FadeBasic/Tests/ParserTests_Erros.cs +++ b/FadeBasic/Tests/ParserTests_Erros.cs @@ -278,6 +278,22 @@ public void ParseError_Command_InvalidTypes() // Assert.That(errors[0].Display, Is.EqualTo($"[1:0] - {ErrorCodes.GotoMissingLabel}")); } + + [Test] + public void ParseError_TypeCheck_FunctionAnd() + { + var input = @" + FUNCTION evenAndPositive(n) + isEven = n mod 2 = 0 + isPositive = n > 0 + ENDFUNCTION isEven and isPositive + +"; + var parser = MakeParser(input); + var prog = parser.ParseProgram(); + prog.AssertNoParseErrors(); + // Assert.That(errors[0].Display, Is.EqualTo($"[1:0] - {ErrorCodes.GotoMissingLabel}")); + } [Test] public void ParseError_TypeCheck_IntToFloat() @@ -1555,8 +1571,8 @@ public void ParseError_Function_MissingName() public void ParseError_Function_CallBeforeDefined_Works() { var input = @" -x = test(1) -function test(a) +x = demo(1) +function demo(a) endfunction a "; var parser = MakeParser(input); @@ -1568,9 +1584,9 @@ endfunction a public void ParseError_Function_DefinedTwice() { var input = @" -function test() +function demo() endfunction -function test(a) +function demo(a) endfunction "; var parser = MakeParser(input); @@ -2126,8 +2142,8 @@ x as chicken TYPE chicken y ENDTYPE -test as egg -y = test.x +t1 as egg +y = t1.x z = y.y "; var parser = MakeParser(input); @@ -2732,7 +2748,7 @@ public void ParseError_Macro_ReturnWorks() { var input = @" #macro - n = macro return test() + n = macro return demo() #endmacro x = [n] "; @@ -3029,7 +3045,7 @@ public void ParseError_Macro_Subst_InvalidRef() public void ParseError_Macro_CommandAppearsOutsideOfMacro() { var input = @" -x = macro return test() +x = macro return demo() "; var parser = MakeParser(input); var prog = parser.ParseProgram(); diff --git a/FadeBasic/Tests/ParserTests_Macro_Taint.cs b/FadeBasic/Tests/ParserTests_Macro_Taint.cs index f13e7ae..aba470b 100644 --- a/FadeBasic/Tests/ParserTests_Macro_Taint.cs +++ b/FadeBasic/Tests/ParserTests_Macro_Taint.cs @@ -12,7 +12,7 @@ public void Haunted_ValidTokenization() { var input = @" #macro - x = macro return test() + x = macro return demo() # y = [x] #endmacro "; @@ -394,7 +394,7 @@ public void Haunted_Error_None() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x # a = 1 #endmacro @@ -407,7 +407,7 @@ public void Haunted_Error_None_CanTokenize() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x # a = [x] #endmacro @@ -420,7 +420,7 @@ public void Haunted_Error_InvalidAssignment() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y$ = ""a"" + str$(x) # [y$] = 1 #endmacro @@ -436,7 +436,7 @@ public void Haunted_Error_Concat() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x # a[x] = 1 #endmacro @@ -451,7 +451,7 @@ public void Haunted_Error_InsideNestedIf2() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x x2 = 3 if y @@ -471,7 +471,7 @@ public void Haunted_Error_InsideNestedIf() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x x2 = 3 if x2 @@ -491,7 +491,7 @@ public void Haunted_Error_InsideIf() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x if y # a = 1 diff --git a/FadeBasic/Tests/RuntoCompilerTests.cs b/FadeBasic/Tests/RuntoCompilerTests.cs new file mode 100644 index 0000000..e672eab --- /dev/null +++ b/FadeBasic/Tests/RuntoCompilerTests.cs @@ -0,0 +1,275 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class RuntoCompilerTests +{ + private Compiler Compile(string src, out ProgramNode prog) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return compiler; + } + + [Test] + public void Runto_Statement_Parses() + { + var src = @" +test foo + runto someLabel +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.EqualTo(1)); + var rt = prog.tests[0].testProgram.statements[0] as RuntoStatement; + Assert.That(rt, Is.Not.Null); + Assert.That(rt.targetLabel, Is.EqualTo("somelabel")); + } + + [Test] + public void Runto_BlockForm_WithMaxCycles_Parses() + { + var src = @" +test foo + runto someLabel + max cycles 1000 + endrunto +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var rt = prog.tests[0].testProgram.statements[0] as RuntoStatement; + Assert.That(rt, Is.Not.Null); + Assert.That(rt.targetLabel, Is.EqualTo("somelabel")); + Assert.That(rt.maxCyclesExpression, Is.Not.Null); + } + + [Test] + public void Runto_MissingLabel_Errors() + { + var src = @" +test foo + runto +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.RuntoMissingLabel)), Is.True); + } + + [Test] + public void Compiler_TestEntryPointsRecorded() + { + var src = @" +test alpha +endtest + +test beta +endtest +"; + var compiler = Compile(src, out _); + Assert.That(compiler.TestManifest.Count, Is.EqualTo(2)); + Assert.That(compiler.TestManifest[0].name, Is.EqualTo("alpha")); + Assert.That(compiler.TestManifest[1].name, Is.EqualTo("beta")); + // Different tests have distinct entry points. + Assert.That(compiler.TestManifest[0].entryPointAddress, + Is.Not.EqualTo(compiler.TestManifest[1].entryPointAddress)); + } + + [Test] + public void Compiler_AbstractTestRecorded() + { + var src = @" +abstract test root +endtest + +test child from root +endtest +"; + var compiler = Compile(src, out _); + Assert.That(compiler.TestManifest.Count, Is.EqualTo(2)); + Assert.That(compiler.TestManifest[0].name, Is.EqualTo("root")); + Assert.That(compiler.TestManifest[0].isAbstract, Is.True); + Assert.That(compiler.TestManifest[1].name, Is.EqualTo("child")); + Assert.That(compiler.TestManifest[1].isAbstract, Is.False); + Assert.That(compiler.TestManifest[1].fromParent, Is.EqualTo("root")); + } + + [Test] + public void Compiler_RuntoTargetLabel_EmitsYield() + { + // A label referenced by runto should have RUNTO_YIELD emitted right after + // its NOOP. We verify by inspecting the bytecode. + var src = @" +mylabel: +end + +test foo + runto mylabel +endtest +"; + var compiler = Compile(src, out var prog); + var program = compiler.Program.ToArray(); + + // Find the NOOP for 'mylabel' — search the bytecode for OpCodes.NOOP + // followed by OpCodes.RUNTO_YIELD. There should be exactly one such pair. + var pairs = 0; + for (var i = 0; i < program.Length - 1; i++) + { + if (program[i] == OpCodes.NOOP && program[i + 1] == OpCodes.RUNTO_YIELD) + { + pairs++; + } + } + Assert.That(pairs, Is.GreaterThanOrEqualTo(1), + "expected at least one NOOP+RUNTO_YIELD pair for the runto target label"); + } + + [Test] + public void Compiler_NonRuntoLabel_NoYield() + { + // Without any runto referencing it, the label should NOT have a RUNTO_YIELD + // following its NOOP. + var src = @" +mylabel: +end +"; + var compiler = Compile(src, out var prog); + var program = compiler.Program.ToArray(); + + for (var i = 0; i < program.Length - 1; i++) + { + if (program[i] == OpCodes.NOOP && program[i + 1] == OpCodes.RUNTO_YIELD) + { + Assert.Fail("found unexpected NOOP+RUNTO_YIELD pair (no test references this label)"); + } + } + } + + [Test] + public void Compiler_RunBuild_NoTests_NoYieldOpcodes() + { + // Programs with no tests should never emit RUNTO_YIELD opcodes anywhere. + // Zero production cost in `dotnet run` builds. + var src = @" +mylabel: +goto mylabel +end +"; + var compiler = Compile(src, out _); + var program = compiler.Program.ToArray(); + + // Need to bound the search to just the code section (before interned data), + // otherwise we'd find raw byte 65 inside the JSON tail. + var internedStart = System.BitConverter.ToInt32(program, 0); + for (var i = 0; i < internedStart; i++) + { + Assert.That(program[i], Is.Not.EqualTo(OpCodes.RUNTO_YIELD), + "no RUNTO_YIELD should be emitted when no tests reference labels"); + } + } + + [Test] + public void Runto_SingleLine_WithMaxCycles_NoEndRuntoNeeded() + { + // DEFER-style single-line form: `runto label max cycles N` on one line, + // no endrunto required. + var src = @" +mylabel: +end + +test foo + runto mylabel max cycles 1000 +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var rt = prog.tests[0].testProgram.statements[0] as RuntoStatement; + Assert.That(rt, Is.Not.Null); + Assert.That(rt.targetLabel, Is.EqualTo("mylabel")); + Assert.That(rt.maxCyclesExpression, Is.Not.Null); + } + + [Test] + public void Runto_OutsideTest_Errors() + { + var src = @" +mylabel: +end + +runto mylabel +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.RuntoOutsideTest)), + Is.True, + "expected RuntoOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_MaxCycles_IsRealKeyword_NotSoftStringMatch() + { + // `max` and `cycles` should NOT be treated as bare identifiers; the + // lexer recognizes `max cycles` as a single multi-word keyword token. + var src = @" +mylabel: +end + +test foo + runto mylabel + max cycles 1000 + endrunto +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + // A `KeywordMaxCycles` token should appear among the lexed tokens. + Assert.That(lex.tokens.Any(t => t.type == LexemType.KeywordMaxCycles), + Is.True, + "expected the lexer to emit a KeywordMaxCycles token"); + } + + [Test] + public void Compiler_RuntoStatement_EmitsRuntoOpCode() + { + var src = @" +mylabel: +end + +test foo + runto mylabel +endtest +"; + var compiler = Compile(src, out _); + var program = compiler.Program.ToArray(); + + // Look for at least one RUNTO opcode in the test region. + var found = false; + var internedStart = System.BitConverter.ToInt32(program, 0); + for (var i = 0; i < internedStart; i++) + { + if (program[i] == OpCodes.RUNTO) { found = true; break; } + } + Assert.That(found, Is.True, "expected a RUNTO opcode in the compiled output"); + } +} diff --git a/FadeBasic/Tests/RuntoNavigationTests.cs b/FadeBasic/Tests/RuntoNavigationTests.cs new file mode 100644 index 0000000..c438422 --- /dev/null +++ b/FadeBasic/Tests/RuntoNavigationTests.cs @@ -0,0 +1,91 @@ +using FadeBasic; +using FadeBasic.Ast; + +namespace Tests; + +[TestFixture] +public class RuntoNavigationTests +{ + private ProgramNode Parse(string src, out List errors) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + errors = prog.GetAllErrors(); + return prog; + } + + [Test] + public void Runto_DeclaredFromSymbol_PointsAtLabelDeclaration() + { + // After scope-resolution, a RuntoStatement's DeclaredFromSymbol should + // point at the LabelDeclarationNode. This is what powers LSP + // go-to-definition and find-references for runto sites. + var src = @" +x = 5 +checkpoint: +end + +test foo + runto checkpoint +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected clean parse; got: " + string.Join(", ", errs.Select(e => e.Display))); + + var runto = prog.tests[0].testProgram.statements + .OfType() + .First(); + Assert.That(runto.DeclaredFromSymbol, Is.Not.Null, + "runto should have its target label resolved into DeclaredFromSymbol"); + Assert.That(runto.DeclaredFromSymbol.source, Is.TypeOf()); + + var label = (LabelDeclarationNode)runto.DeclaredFromSymbol.source; + Assert.That(label.label, Is.EqualTo("checkpoint")); + } + + [Test] + public void Runto_UnknownTarget_HasNullDeclaredFromSymbol() + { + // If the label doesn't exist anywhere, DeclaredFromSymbol stays null. + // (The strictness visitor / compiler will surface the error.) + var src = @" +end + +test foo + runto does_not_exist +endtest +"; + var prog = Parse(src, out _); + var runto = prog.tests[0].testProgram.statements + .OfType() + .First(); + Assert.That(runto.DeclaredFromSymbol, Is.Null); + } + + [Test] + public void Runto_FunctionInternalLabel_ResolvesAcrossScopes() + { + // A test's `runto fnInner` should resolve to the function's internal + // label even though the label is declared inside a function body. + var src = @" +do_work() +end + +function do_work() +fnInner: +endfunction + +test foo + runto fnInner +endtest +"; + var prog = Parse(src, out var errs); + var runto = prog.tests[0].testProgram.statements.OfType().First(); + Assert.That(runto.DeclaredFromSymbol, Is.Not.Null); + var label = (LabelDeclarationNode)runto.DeclaredFromSymbol.source; + Assert.That(label.label, Is.EqualTo("fnInner").IgnoreCase); + } +} diff --git a/FadeBasic/Tests/SourceMapTests.cs b/FadeBasic/Tests/SourceMapTests.cs index 2db8d65..dfa23c1 100644 --- a/FadeBasic/Tests/SourceMapTests.cs +++ b/FadeBasic/Tests/SourceMapTests.cs @@ -1,7 +1,9 @@ using ApplicationSupport.Code; using FadeBasic; using FadeBasic.ApplicationSupport.Project; +using FadeBasic.Launch; using FadeBasic.Sdk; +using FadeBasic.Virtual; namespace Tests; @@ -9,7 +11,7 @@ public class SourceMapTests { [Test] - public void Test() + public void Demo() { var file = @"print ""hello"" x = 3 @@ -41,9 +43,77 @@ public void Test2() var expr = unit.program.statements[2]; var range = map.GetOriginalRange(new TokenRange { start = expr.StartToken, end = expr.EndToken }); - + // the fact that code reaches here is good! Assert.That(range.startLine, Is.EqualTo(2)); Assert.That(range.endLine, Is.EqualTo(2)); } + + [Test] + public void ApplySourceMap_StampsFilePath_AndRemapsLineNumbers() + { + // Two files concatenated; the manifest entry for `bar`'s test + // sits in the second file at its own (in-file) line number. + // ApplySourceMap should: + // - stamp sourceFilePath = "bar.fbasic" + // - rewrite sourceLine from concatenated-coords back to in-file coords + var fileA = @"print ""hello"" + +test alpha +endtest"; + var fileB = @" + + + +test beta +endtest"; + + var map = SourceMap.CreateSourceMap( + new List { "foo.fbasic", "bar.fbasic" }, + path => path.EndsWith("foo.fbasic") ? fileA.SplitNewLines() : fileB.SplitNewLines()); + + // Compile from the concatenated source. The compiler stamps each + // entry's sourceLine in concatenated coords. + FadeRuntimeContext.TryFromSource(map.fullSource, TestCommands.CommandsForTesting, + out var ctx, out var errs, map); + Assert.That(errs, Is.Null, + "expected a clean compile; got: " + (errs?.ToDisplay() ?? "")); + + var alpha = ctx.Compiler.TestManifest.First(t => t.name == "alpha"); + var beta = ctx.Compiler.TestManifest.First(t => t.name == "beta"); + + Assert.That(alpha.sourceFilePath, Does.EndWith("foo.fbasic"), + "alpha lives in foo.fbasic"); + Assert.That(beta.sourceFilePath, Does.EndWith("bar.fbasic"), + "beta lives in bar.fbasic — the per-entry plumbing is what enables this"); + // After ApplySourceMap, sourceLine is the in-file line, not the + // concatenated-source line. beta's `test` is on line 4 of bar.fbasic + // (0-based: 4), not somewhere far down in the concat. + Assert.That(beta.sourceLine, Is.LessThan(10), + "beta's sourceLine should be in bar.fbasic-local coordinates"); + } + + [Test] + public void ApplySourceMap_Idempotent_DoesNotDoubleShift() + { + // If a manifest entry has already been remapped (sourceFilePath set), + // a second call shouldn't move sourceLine again. The build pipeline + // and the SDK can both reach this code path; the two together must + // not double-shift. + var entry = new TestManifestEntry + { + name = "foo", + sourceLine = 3, + sourceFilePath = "already.fbasic" + }; + var map = SourceMap.CreateSourceMap( + new List { "x.fbasic" }, + _ => new[] { "a", "b", "c" }); + + LaunchUtil.ApplySourceMap(new[] { entry }, map); + + Assert.That(entry.sourceFilePath, Is.EqualTo("already.fbasic")); + Assert.That(entry.sourceLine, Is.EqualTo(3), + "an entry that already carries a source path must not be re-mapped"); + } } \ No newline at end of file diff --git a/FadeBasic/Tests/TestBlockParserTests.cs b/FadeBasic/Tests/TestBlockParserTests.cs new file mode 100644 index 0000000..94dc790 --- /dev/null +++ b/FadeBasic/Tests/TestBlockParserTests.cs @@ -0,0 +1,222 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestBlockParserTests +{ + private Lexer _lexer; + private CommandCollection _commands; + + [SetUp] + public void Setup() + { + _lexer = new Lexer(); + _commands = TestCommands.CommandsForTesting; + } + + private ProgramNode Parse(string src, out List errors) + { + var lex = _lexer.TokenizeWithErrors(src, _commands); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, _commands); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + errors = prog.GetAllErrors(); + return prog; + } + + private ProgramNode ParseClean(string src) + { + var prog = Parse(src, out var errs); + Assert.That(errs.Count, Is.EqualTo(0), + "expected no parse errors, got: " + string.Join("\n", errs.Select(e => e.Display))); + return prog; + } + + [Test] + public void Test_EmptyBlock_Parses() + { + var src = @" +test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].name, Is.EqualTo("foo")); + Assert.That(prog.tests[0].isAbstract, Is.False); + Assert.That(prog.tests[0].fromParent, Is.Null); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.EqualTo(0)); + } + + [Test] + public void Test_AbstractBlock_Parses() + { + var src = @" +abstract test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].isAbstract, Is.True); + Assert.That(prog.tests[0].name, Is.EqualTo("foo")); + } + + [Test] + public void Test_FromParent_Parses() + { + var src = @" +test child from root +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].name, Is.EqualTo("child")); + Assert.That(prog.tests[0].fromParent, Is.EqualTo("root")); + } + + [Test] + public void Test_AbstractFromParent_Parses() + { + var src = @" +abstract test base from grand +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].isAbstract, Is.True); + Assert.That(prog.tests[0].fromParent, Is.EqualTo("grand")); + } + + [Test] + public void Test_MissingEndtest_Errors() + { + var src = @" +test foo +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestMissingEndTest)), Is.True, + "expected TestMissingEndTest error"); + } + + [Test] + public void Test_MissingName_Errors() + { + var src = @" +test +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestMissingName)), Is.True, + "expected TestMissingName error"); + } + + [Test] + public void Test_AbstractWithoutTest_Errors() + { + var src = @" +abstract foo +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.AbstractRequiresTest)), Is.True, + "expected AbstractRequiresTest error"); + } + + [Test] + public void Test_NestedInsideTest_Errors() + { + var src = @" +test outer + test inner + endtest +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestDefinedInsideTest)), Is.True, + "expected TestDefinedInsideTest error"); + } + + [Test] + public void Test_MultipleBlocks_AllParsed() + { + var src = @" +test alpha +endtest + +test beta +endtest + +test gamma +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(3)); + Assert.That(prog.tests[0].name, Is.EqualTo("alpha")); + Assert.That(prog.tests[1].name, Is.EqualTo("beta")); + Assert.That(prog.tests[2].name, Is.EqualTo("gamma")); + } + + [Test] + public void Test_BlockContainsStatements() + { + var src = @" +test foo + x = 5 + y = 10 +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.EqualTo(2)); + } + + [Test] + public void Test_TestNodeNotInProgramFunctions() + { + var src = @" +test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.functions.Count, Is.EqualTo(0)); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + } + + [Test] + public void Test_ProgramAndTest_BothParsed() + { + var src = @" +x = 5 + +test foo + y = 10 +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.statements.Count, Is.GreaterThan(0)); + } + + [Test] + public void Test_ToString_ShowsTest() + { + var src = @" +test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.ToString(), Does.Contain("test foo")); + } + + [Test] + public void Test_Abstract_ToString_ShowsAbstract() + { + var src = @" +abstract test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.ToString(), Does.Contain("abstract test foo")); + } +} diff --git a/FadeBasic/Tests/TestCommmands.cs b/FadeBasic/Tests/TestCommmands.cs index 33aa81b..6e1e663 100644 --- a/FadeBasic/Tests/TestCommmands.cs +++ b/FadeBasic/Tests/TestCommmands.cs @@ -11,17 +11,17 @@ public partial class TestCommands { public static readonly CommandCollection CommandsForTesting = new CommandCollection(new TestCommands()); - [FadeBasicCommand("macroFuncTest", FadeBasicCommandUsage.Macro)] + [FadeBasicCommand("macroFuncDemo", FadeBasicCommandUsage.Macro)] public static void Example(int x, ref int id) { id = x * 2; } - [FadeBasicCommand("macro return test", FadeBasicCommandUsage.Macro)] + [FadeBasicCommand("macro return demo", FadeBasicCommandUsage.Macro)] public static int Example() { return 42; } - [FadeBasicCommand("macroReturnTest", FadeBasicCommandUsage.Macro)] + [FadeBasicCommand("macroReturnDemo", FadeBasicCommandUsage.Macro)] public static int Example2() { return 42; @@ -126,13 +126,13 @@ public static void WaitKey() [FadeBasicCommand("wait ms")] public static void WiatMs(int amount) { - + waitMsCallCount++; } // - [FadeBasicCommand("callTest")] + [FadeBasicCommand("callDemo")] public static void CallTest() { - + } [FadeBasicCommand("add")] public static int AddTest(int a, int b) @@ -169,6 +169,11 @@ public static void Tuna(params object[] variable) Console.WriteLine(string.Join("\n", variable)); } + // Counter incremented every time `wait ms` is invoked (real path only, + // mocks bypass the executor). Mock execution tests reset and inspect + // this to confirm the host method was/wasn't actually called. + public static int waitMsCallCount = 0; + public static List staticPrintBuffer = new List(); [FadeBasicCommand("static print")] public static void StaticPrint(params object[] variable) diff --git a/FadeBasic/Tests/TestExecutionTests.cs b/FadeBasic/Tests/TestExecutionTests.cs new file mode 100644 index 0000000..19b15e5 --- /dev/null +++ b/FadeBasic/Tests/TestExecutionTests.cs @@ -0,0 +1,211 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestExecutionTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunTest(string src, string testName) + { + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == testName); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + return vm; + } + + [Test] + public void Execute_EmptyTest_RunsToCompletion() + { + var src = @" +test empty +endtest +"; + var vm = RunTest(src, "empty"); + // Just verify no exceptions and the vm halted normally. + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } + + + [Test] + public void Execute_EmptyTest_CanHaveFunction() + { + var src = @" +test funcSupport + x() + + function x() + endfunction +endtest +"; + var vm = RunTest(src, "funcSupport"); + // Just verify no exceptions and the vm halted normally. + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } + + + [Test] + public void Execute_TestRunsToProgramLabel() + { + // A test that issues `runto :start`. The program top-level body has + // a `start:` label. After the test runs, programResumeIP should sit + // right after the label. + var src = @" +x = 1 +start: +x = 2 +end + +test foo + runto start +endtest +"; + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == "foo"); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + + // After yield, runtoStack is empty and programResumeIP is the post-yield IP. + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + Assert.That(vm.programResumeIP, Is.GreaterThan(4), + "programResumeIP should have advanced past the entry header"); + } + + [Test] + public void Execute_MultipleRuntos_ProgressThroughProgram() + { + var src = @" +first: +x = 1 +second: +x = 2 +end + +test foo + runto first + runto second +endtest +"; + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == "foo"); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } + + [Test] + public void Execute_DefaultEntryPoint_RunsProgramNotTest() + { + // Without specifying entry point, the VM starts at default (4) and + // runs the program body. The test body should not be entered. + var src = @" +somewhere: +x = 7 +end + +test foo + runto somewhere +endtest +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program); // default entry + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + + Assert.That(vm.runtoStack.Count, Is.EqualTo(0), + "default entry runs program code only; runtoStack should never get touched"); + } + + [Test] + public void Execute_TwoTests_IndependentVMs() + { + // Each test gets its own fresh VM. They shouldn't share state. + var src = @" +test alpha +endtest + +test beta +endtest +"; + var (compiler, program) = Compile(src); + var alpha = compiler.TestManifest.First(t => t.name == "alpha"); + var beta = compiler.TestManifest.First(t => t.name == "beta"); + Assert.That(alpha.entryPointAddress, Is.Not.EqualTo(beta.entryPointAddress)); + + var vmA = new VirtualMachine(program, alpha.entryPointAddress); + vmA.hostMethods = compiler.methodTable; + vmA.Execute3(); + + var vmB = new VirtualMachine(program, beta.entryPointAddress); + vmB.hostMethods = compiler.methodTable; + vmB.Execute3(); + + // Both halt cleanly. + Assert.That(vmA.runtoStack.Count, Is.EqualTo(0)); + Assert.That(vmB.runtoStack.Count, Is.EqualTo(0)); + } + + [Test] + public void Execute_NormalProgram_StillRunsUnchanged() + { + // Smoke test: a regular Fade program with no tests should compile and + // execute exactly as before. Tests-related code paths add no overhead + // and don't alter behavior when no tests are present. + var src = @" +x = 5 +y = x + 3 +end +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + + // y should have been computed as 8 + Assert.That(vm.dataRegisters[1], Is.EqualTo(8)); + } + + + + [Test] + public void Execute_GlobalVariables() + { + // `GLOBAL x = 32` lives inside the test body, so `x` is scoped to + // the test — main-body code cannot see it. Referencing `x` from + // the main program is a hard parse error. + var src = @" +test foo + GLOBAL x = 32 +endtest + +print x `this should result in an error. +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.SymbolNotDeclaredYet)), + Is.True, + "expected SymbolNotDeclaredYet on the main-body `print x` reference; got: " + + string.Join("; ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/TestFromChainTests.cs b/FadeBasic/Tests/TestFromChainTests.cs new file mode 100644 index 0000000..eca42bf --- /dev/null +++ b/FadeBasic/Tests/TestFromChainTests.cs @@ -0,0 +1,615 @@ +using FadeBasic; +using FadeBasic.Sdk; + +namespace Tests; + +[TestFixture] +public class TestFromChainTests +{ + // ── End-to-end runtime tests via the SDK runner ──────────────────────── + + private FadeRuntimeContext CreateContext(string src) + { + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out var ctx, out var errors); + Assert.That(ok, Is.True, + "expected clean compile; got: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + [Test] + public void FromChain_ChildSeesParentsRuntoState() + { + // The motivating case: child references a main-body variable that + // parent brought into view via runto. Without inheritance, this + // errors at the visitor and crashes at runtime; with the chain + // launcher, parent's runto runs first and `x` is in registers + // before child's assert reads it. + var src = @" +x = 3 +_L1: +end + +test sample + runto _L1 + assert x = 3 +endtest + +test sample2 from sample + assert x = 3 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("sample2"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ChildSeesParentMainBodyAssignment() + { + // Parent's runto brings a main-body variable into view; child + // reads it. Tests static visibility (visitor) + runtime persistence + // (shared registers) end-to-end. + var src = @" +foo = 42 +_L: +end + +test parent + runto _L +endtest + +test child from parent + assert foo = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ChildSeesParentTestLocal() + { + // Parent declares a test-local and assigns to it. Child references + // it directly. The base scope checker now walks chained tests in + // topological order, copying parent's scope state (locals + funcs) + // into the child's fresh scope before validating — the same way + // a test's sub-program already inherits from the outer program. + var src = @" +end + +test parent + local foo as integer + foo = 42 +endtest + +test child from parent + assert foo = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ChildInheritsMocksInstalledByParent() + { + // Parent installs a mock that overrides `screen width` to return + // 42. Child references `screen width()` directly. The mock survives + // into the child run because it lives in the VM's mockTable, which + // is wholly shared across chain segments. + var src = @" +end + +test parent + mock screen width + exitmock 42 + endmock +endtest + +test child from parent + assert screen width() = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ChildSeesParentRuntoSideEffects() + { + // Parent's `runto` causes the main program to execute up to the + // label, including this `inc` call which writes to register `n`. + // Child should see n = 1 because parent's runto ran before child's + // body started. + var src = @" +n = 0 +inc n +_L1: +end + +test parent + runto _L1 +endtest + +test child from parent + assert n = 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ParentAssertFailure_CascadesToChild() + { + // Parent fails an assert. The trampoline halts the VM mid-chain. + // Child's body never runs; the child run is reported as failed. + var src = @" +end + +test parent + assert 0, ""parent always fails"" +endtest + +test child from parent + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.False, + "child should fail because parent's assert failed"); + Assert.That(result.failureMessage, Does.Contain("parent always fails"), + "failure should propagate parent's reason; got: " + result.failureMessage); + } + + [Test] + public void FromChain_ThreeLevelChain_RunsAllInOrder() + { + // A → B → C. Each ancestor mutates a register; the final assert + // proves all three segments ran in order, sharing state. + var src = @" +n = 0 +inc n +_L1: +end + +test a + runto _L1 +endtest + +test b from a + n = n + 10 +endtest + +test c from b + n = n + 100 + assert n = 111 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("c"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_StandaloneParent_StillRunsAlone() + { + // Running parent directly still works — its launcher is just + // [GOSUB self_body, HALT] with no ancestor links. Child's launcher + // points to a different address with the chain. + var src = @" +n = 5 +_L: +end + +test parent + runto _L + assert n = 5 +endtest + +test child from parent + assert n = 5 +endtest +"; + var ctx = CreateContext(src); + var parentResult = ctx.RunTest("parent"); + Assert.That(parentResult.passed, Is.True, parentResult.failureMessage); + var childResult = ctx.RunTest("child"); + Assert.That(childResult.passed, Is.True, childResult.failureMessage); + } + + [Test] + public void FromChain_AbstractParent_NotInRunnableList_ButInherited() + { + // Abstract tests aren't runnable directly but their body still runs + // as part of a child's chain. The manifest flags it isAbstract; + // the runner skips it for top-level execution but the launcher + // GOSUBs into its body all the same. + var src = @" +x = 100 +_L: +end + +abstract test setup + runto _L +endtest + +test concrete from setup + assert x = 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("concrete"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_TwoSiblings_IsolatedAtStatic_ButShareRuntimeRegisters() + { + // Both `childA` and `childB` inherit from `parent`. Each gets its own + // fresh Scope at validation time (no leakage between siblings — the + // scope copy is one-way, parent → child). Runtime-wise they share + // the same global register file, so a sibling's locals collide if + // they have the same name — but that's already how cross-test state + // works in the existing language and isn't specific to chains. + // + // The test demonstrates static isolation: childA declares a local + // `siblingA_only`; childB declares `siblingB_only`. Neither sibling + // sees the other's local. (If isolation were broken, the visitor + // would let `siblingA_only` slip into childB and either spuriously + // accept it or alias to a parent-declared name.) + var src = @" +end + +test parent + local shared as integer = 5 +endtest + +test childA from parent + local siblingA_only as integer = 1 + assert shared = 5 + assert siblingA_only = 1 +endtest + +test childB from parent + local siblingB_only as integer = 2 + assert shared = 5 + assert siblingB_only = 2 +endtest +"; + var ctx = CreateContext(src); + var ra = ctx.RunTest("childA"); + Assert.That(ra.passed, Is.True, ra.failureMessage); + var rb = ctx.RunTest("childB"); + Assert.That(rb.passed, Is.True, rb.failureMessage); + } + + [Test] + public void FromChain_SiblingCannotSeeOtherSiblingsLocal() + { + // Static isolation check: childA declares `priv` as a local; childB + // shouldn't see it. If sibling state were leaking (e.g. via shared + // scope mutation during validation), childB's reference to `priv` + // would spuriously succeed. + var src = @" +end + +test parent +endtest + +test childA from parent + local priv as integer = 1 +endtest + +test childB from parent + n = priv +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "childB should NOT see childA's local — they're siblings, not parent/child"); + Assert.That(errors.ParserErrors.Any(e => e.Display.Contains("priv")), + Is.True, + "expected an unknown-symbol error mentioning `priv`; got: " + errors.ToDisplay()); + } + + [Test] + public void FromChain_ParentDefer_RunsAfterChildBody() + { + // The motivating bug. Parent registers a DEFER (teardown) inside + // its body. With per-body defer drains, teardown fires at parent's + // RETURN — i.e., BEFORE the child runs — which is semantically + // wrong (child is supposed to be a continuation of parent). + // + // Expected order: child body runs first, THEN parent's defer. + // The shared deferredJumps stack accumulates parent's defer + // during the chain; the launcher's tail drains it after every + // body has run. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +counter = 0 +_L1: +do + counter = counter + 1 + _L2: +loop + +abstract test parent + defer + static print ""teardown"" + enddefer +endtest + +test sample from parent + runto _L1 + + while counter < 3 + static print ""looping"" + runto _L2 + endwhile + + static print str$(counter) +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("sample"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, + Is.EqualTo(new[] { "looping", "looping", "looping", "3", "teardown" }), + "parent's defer should fire AFTER the child's body, not before"); + } + + [Test] + public void FromChain_TwoLevelDefers_LIFOAcrossChain() + { + // Parent A and parent B both register defers. C runs in the + // middle. At chain end, defers drain LIFO — B's first, then A's. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +abstract test a + defer + static print ""a_teardown"" + enddefer +endtest + +abstract test b from a + defer + static print ""b_teardown"" + enddefer +endtest + +test c from b + static print ""body"" +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("c"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, + Is.EqualTo(new[] { "body", "b_teardown", "a_teardown" }), + "defers should drain LIFO at chain end: child body, then b's defer, then a's defer"); + } + + [Test] + public void FromChain_StandaloneDefer_StillDrainsAtTestEnd() + { + // No `from`-parent — just a normal test with a defer. The defer + // should still fire when the test body completes (after the + // body's other statements). Confirms the launcher-tail drain + // works for the simple case. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test solo + defer + static print ""teardown"" + enddefer + static print ""body"" +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("solo"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, + Is.EqualTo(new[] { "body", "teardown" }), + "standalone defer should fire after body — same as before"); + } + + [Test] + public void LabelScoping_SameLabelInTwoTests_DoesNotCollide() + { + // Bug repro: each test's `retry_done` label was sharing a global + // dictionary entry, so test alpha's `goto retry_done` resolved to + // beta's label and execution fell into beta's `assert 0`. + // Both tests should resolve their labels independently — alpha + // passes, beta fails as intended. + var src = @" +end + +test alpha +retry: + goto retry_done +retry_done: +endtest + +test beta +retry: + goto retry_done +retry_done: + assert 0, ""boooo"" +endtest +"; + var ctx = CreateContext(src); + var alpha = ctx.RunTest("alpha"); + Assert.That(alpha.passed, Is.True, + "alpha has no failing assert — should pass; got: " + alpha.failureMessage); + var beta = ctx.RunTest("beta"); + Assert.That(beta.passed, Is.False, + "beta's assert 0 must fail when its OWN label was reached"); + Assert.That(beta.failureMessage, Does.Contain("boooo"), + "beta should fail with its own message; got: " + beta.failureMessage); + } + + [Test] + public void LabelScoping_RuntoFromTest_StillFindsMainBodyLabel() + { + // Sanity check: even though labels are region-scoped, runto from + // a test resolves against the main-body region. Otherwise the + // visitor would flag it and the compiler would fail to bake the + // runto target's address. + var src = @" +n = 0 +_pause: +n = n + 5 +end + +test foo + runto _pause + assert n = 0 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("foo"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + // ── Compile-time validation: cycles and unknown parents ──────────────── + + [Test] + public void FromChain_UnknownParent_Errors() + { + var src = @" +end + +test child from nonexistent + assert 1 = 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure when fromParent doesn't name a test"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.TestFromParentUnknown)), + Is.True, + "expected TestFromParentUnknown; got: " + errors.ToDisplay()); + } + + [Test] + public void FromChain_DirectCycle_Errors() + { + // `selfref` from itself — simplest self-cycle. (Avoiding `loop` + // as a test name because it collides with the do/loop keyword.) + var src = @" +end + +test selfref from selfref + assert 1 = 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure on self-from cycle"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.TestFromParentCycle)), + Is.True, + "expected TestFromParentCycle; got: " + errors.ToDisplay()); + } + + [Test] + public void TestNames_DuplicateAcrossTopLevel_Errors() + { + // Two tests sharing a name confuse the runner's manifest lookup + // and obscure intent. Surface a clean compile error at the second + // (and any further) occurrence; the first one keeps the name. + var src = @" +end + +test N + assert 1 +endtest + +test N + assert 0 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure when two tests share a name"); + var dupes = errors.ParserErrors + .Where(e => e.errorCode.Equals(ErrorCodes.TestDuplicateName)) + .ToList(); + Assert.That(dupes, Has.Count.EqualTo(1), + "exactly one duplicate flagged (first occurrence keeps the name); got: " + + errors.ToDisplay()); + Assert.That(dupes[0].message, Does.Contain("N"), + "error detail should name the offending test; got: " + dupes[0].Display); + } + + [Test] + public void TestNames_DuplicateCaseInsensitive_Errors() + { + // Lookups (FindTestByName, runner manifest) are case-insensitive, + // so `test Foo` + `test foo` collide just as much as two `Foo`s. + var src = @" +end + +test Foo + assert 1 +endtest + +test foo + assert 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure on case-insensitive duplicate names"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.TestDuplicateName)), + Is.True, + "expected TestDuplicateName; got: " + errors.ToDisplay()); + } + + [Test] + public void FromChain_IndirectCycle_Errors() + { + // A from B, B from C, C from A — three-node cycle. + var src = @" +end + +test a from c + assert 1 = 1 +endtest + +test b from a + assert 1 = 1 +endtest + +test c from b + assert 1 = 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure on indirect cycle"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.TestFromParentCycle)), + Is.True, + "expected TestFromParentCycle; got: " + errors.ToDisplay()); + } +} diff --git a/FadeBasic/Tests/TestFunctionTests.cs b/FadeBasic/Tests/TestFunctionTests.cs new file mode 100644 index 0000000..bd41f73 --- /dev/null +++ b/FadeBasic/Tests/TestFunctionTests.cs @@ -0,0 +1,276 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestFunctionTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunTest(string src, string testName) + { + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == testName); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + return vm; + } + + [Test] + public void TestFunction_ParsesIntoTestNode() + { + var src = @" +test foo + function helper() + endfunction 5 +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + Assert.That(prog.tests[0].testProgram.functions.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].testProgram.functions[0].name, Is.EqualTo("helper")); + } + + [Test] + public void TestFunction_CalledFromTestBody_Works() + { + var src = @" +function helper() +endfunction 42 + +test foo + local result as integer + result = helper() + assert result = 42 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + + [Test] + public void MaxCycles_FailsIfNotReached() + { + var src = @" +while 1 + ` loop forever. +endwhile +L1: + +test foo + RUNTO L1 max cycles 500 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure.sourceText, Is.EqualTo("RUNTO exceeded max cycles")); + } + + + + [Test] + public void TestFunction_CalledFromTestBody_DependsOnGlobalState_Works() + { + var src = @" + +global x = 32 +function helper() +endfunction x + 10 + +test foo + local result as integer + result = helper() + assert result = 10 `by default, x is zero; so when no runto is used, this is just 0+10 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + + + // buuut, if a runto is used, and the state is set; then it can work again. + src = @" + +global x = 32 + +_def: +function helper() +endfunction x + 10 + +test foo + local result as integer + runto _def + result = helper() + assert result = 42 +endtest +"; + vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void TestFunction_DeclaredInsideTest_CallableFromBody() + { + var src = @" +test foo + local result as integer + result = twice(5) + assert result = 10 + + function twice(n) + endfunction n * 2 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null, + vm.assertionFailure?.sourceText ?? "no failure expected"); + } + + [Test] + public void TestLabel_GotoWithinTest_Works() + { + var src = @" +test foo + local count as integer = 0 +retry: + count = count + 1 + if count < 3 then goto retry + assert count = 3 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void TestFunction_NotCallableFromMainProgram_Errors() + { + // A function declared inside a test is invisible to main program code. + var src = @" +test foo + ` GLOBAL x = 3 + function helper() + ` this could rely on global state that exists lexically, but wouldn't exist from runtime. + ` print x `<-- this right here; x is not defined. + endfunction 1 +endtest + +x = helper() +end +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.GreaterThan(0), + "expected at least one error for cross-namespace function call"); + } + + [Test] + public void TestFunction_NotCallableFromOtherTest_Errors() + { + var src = @" +test alpha + function helper() + endfunction 1 +endtest + +test beta + local x as integer + x = helper() +endtest +"; + // TODO: can we share functions via abstract? + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.GreaterThan(0), + "expected error: helper is alpha-scoped, not visible in beta"); + } + + [Test] + public void TestLabel_GotoFromMainProgram_Errors() + { + // Main program code cannot goto a label declared inside a test. + // Per the "parent never reads into test" rule, the test's labels are + // invisible to main — so the goto fails as UnknownLabel rather than + // TraverseLabelBetweenScopes. Either error proves the goto is rejected. + var src = @" +goto retry +end + +test foo +retry: +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => + e.errorCode.Equals(ErrorCodes.UnknownLabel) + || e.errorCode.Equals(ErrorCodes.TraverseLabelBetweenScopes)), + Is.True, + "expected goto-into-test to be rejected; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLabel_GotoFromTestToProgramLabel_Errors() + { + var src = @" +mainLabel: +end + +test foo + goto mainLabel +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TraverseLabelBetweenScopes)), + Is.True, + "expected TraverseLabelBetweenScopes; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLabel_SameNameAcrossTests_Independent() + { + // Each test has its own label namespace, so the same label name in two + // different tests should be fine. + var src = @" +test alpha +retry: + goto retry_done +retry_done: +endtest + +test beta +retry: + goto retry_done +retry_done: +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + prog.AssertNoParseErrors(); + } +} diff --git a/FadeBasic/Tests/TestManifestPackingTests.cs b/FadeBasic/Tests/TestManifestPackingTests.cs new file mode 100644 index 0000000..e26bf50 --- /dev/null +++ b/FadeBasic/Tests/TestManifestPackingTests.cs @@ -0,0 +1,128 @@ +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestManifestPackingTests +{ + [Test] + public void PackUnpack_RoundTrips_PreservesEntries() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest + +abstract test fixture +endtest + +test gamma from fixture +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out var ctx, out var errs); + Assert.That(ok, Is.True, errs?.ToDisplay()); + + var packed = LaunchUtil.PackTestManifest(ctx.Compiler.TestManifest); + Assert.That(packed, Is.Not.Empty); + + var unpacked = LaunchUtil.UnpackTestManifest(packed); + Assert.That(unpacked.Count, Is.EqualTo(ctx.Compiler.TestManifest.Count)); + + var alphaOriginal = ctx.Compiler.TestManifest.First(t => t.name == "alpha"); + var alphaUnpacked = unpacked.First(t => t.name == "alpha"); + Assert.That(alphaUnpacked.entryPointAddress, Is.EqualTo(alphaOriginal.entryPointAddress)); + Assert.That(alphaUnpacked.isAbstract, Is.False); + + var fixtureUnpacked = unpacked.First(t => t.name == "fixture"); + Assert.That(fixtureUnpacked.isAbstract, Is.True); + + var gammaUnpacked = unpacked.First(t => t.name == "gamma"); + Assert.That(gammaUnpacked.fromParent, Is.EqualTo("fixture")); + } + + [Test] + public void PackUnpack_EmptyManifest_RoundTrips() + { + var empty = new List(); + var packed = LaunchUtil.PackTestManifest(empty); + var unpacked = LaunchUtil.UnpackTestManifest(packed); + Assert.That(unpacked.Count, Is.EqualTo(0)); + } + + [Test] + public void Unpack_NullOrEmpty_ReturnsEmptyList() + { + Assert.That(LaunchUtil.UnpackTestManifest(null).Count, Is.EqualTo(0)); + Assert.That(LaunchUtil.UnpackTestManifest("").Count, Is.EqualTo(0)); + } + + // End-to-end smoke: simulate what a generated launchable does. + // 1) Pack manifest + bytecode (compile-time analogue). + // 2) Construct a synthetic ITestLaunchable from the unpacked artifacts. + // 3) Dispatch a `--fade-test=name` via Launcher and verify it runs. + [Test] + public void GeneratedLaunchableShape_DispatchesTestArgs() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out var ctx, out _); + Assert.That(ok, Is.True); + + // Pack: same operations LaunchableGenerator performs at build time. + var packedBytecode = LaunchUtil.Pack64(ctx.Machine.program); + var packedManifest = LaunchUtil.PackTestManifest(ctx.Compiler.TestManifest); + + // Unpack: same operations the generated class performs at startup. + var bytecode = LaunchUtil.Unpack64(packedBytecode); + var manifest = LaunchUtil.UnpackTestManifest(packedManifest); + + var launchable = new SyntheticTestLaunchable + { + bytecode = bytecode, + collection = TestCommands.CommandsForTesting, + manifest = manifest + }; + + var stdout = new StringWriter(); + var savedOut = Console.Out; + try + { + Console.SetOut(stdout); + var handled = Launcher.TryDispatchTestArgs(launchable, + new[] { "--fade-test=alpha" }, out var exit); + Assert.That(handled, Is.True); + Assert.That(exit, Is.EqualTo(0), + "expected pass; stdout: " + stdout); + } + finally + { + Console.SetOut(savedOut); + } + + Assert.That(stdout.ToString(), Does.Contain("PASS")); + } + + private class SyntheticTestLaunchable : ITestLaunchable + { + public byte[] bytecode; + public CommandCollection collection; + public IReadOnlyList manifest; + + public byte[] Bytecode => bytecode; + public CommandCollection CommandCollection => collection; + public DebugData DebugData => null; + public IReadOnlyList TestManifest => manifest; + } +} diff --git a/FadeBasic/Tests/TestScopeStrictnessTests.cs b/FadeBasic/Tests/TestScopeStrictnessTests.cs new file mode 100644 index 0000000..d929356 --- /dev/null +++ b/FadeBasic/Tests/TestScopeStrictnessTests.cs @@ -0,0 +1,565 @@ +using FadeBasic; +using FadeBasic.Ast; + +namespace Tests; + +[TestFixture] +public class TestScopeStrictnessTests +{ + private ProgramNode Parse(string src, out List errors) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + errors = prog.GetAllErrors(); + return prog; + } + + [Test] + public void Strictness_PreRunto_MainBodyName_Errors() + { + // `x` is declared by main-body assignment. Pre-runto, it should not be + // visible to the test. + var src = @" +x = 5 +end + +test foo + assert x = 5 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), + Is.True, + "expected TestVariableUnreachable; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_PostRunto_NameDeclaredAfterTarget_Errors() + { + // The runto reaches `:earlyLabel`. `y` is declared AFTER that point in + // main-body — should not be visible from this runto. + var src = @" +x = 5 +earlyLabel: +y = 10 +end + +test foo + runto earlyLabel + assert y = 10 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "expected TestVariableNotYetDeclared for y; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_PostRunto_NameDeclaredBeforeTarget_Allowed() + { + // `x` is declared before the label, so it's visible after runto. + var src = @" +x = 5 +laterLabel: +end + +test foo + runto laterLabel + assert x = 5 +endtest +"; + Parse(src, out var errs); + // This should pass with no scope-strictness errors (other errors might + // exist from the main check, but our strict checks shouldn't fire). + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared) + || e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), + Is.False, + "x should be visible after runto; errors: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_GlobalDeclaration_VisibleAlways() + { + // `global X` declarations are visible from the start, even pre-runto. + var src = @" +global x as integer = 7 +end + +test foo + assert x = 7 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "global x should be visible; errors: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_TestLocal_NotConfusedWithProgramVar() + { + // A test-local declaration should be visible without errors, even if a + // program-scope variable with the same name exists. + var src = @" +end + +test foo + local x as integer = 99 + assert x = 99 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False); + } + + [Test] + public void Strictness_BranchedDeclarations_VisibleAfterMerge() + { + // Per Fade's existing branch-rule semantics, both branches of an if/else + // contribute their declared names. After the merge point, both names are + // considered declared. + var src = @" +condition = 1 +if condition + a = 5 +else + b = 10 +endif +mergeLabel: +end + +test foo + runto mergeLabel + assert a >= 0 + assert b >= 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "a and b should both be visible at mergeLabel; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_LocalAssignmentShadowsImplicit_Allowed() + { + // Implicit declaration of a name not in any other scope should make it + // a test-local and not error. + var src = @" +end + +test foo + myCount = 0 + myCount = myCount + 1 + assert myCount = 1 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "implicit test-local should be fine; errors: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_AssignmentToProgramVar_PreRunto_CreatesImplicitTestLocal() + { + // Bare `x = 99` in a test (no runto) follows BASIC's normal "assign + // to unbound name creates a local" rule, scoped to the test. The + // program-scope `x` is NOT in `visible`, so the test's write is an + // implicit-test-local declaration — no error. To actually write + // through to program-scope `x`, the test would need a runto past + // its declaration so it lands in `visible`. + var src = @" +x = 0 +end + +test foo + x = 99 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), + Is.False, + "bare assignment in a test should be an implicit test-local, not an error; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + // ============================================================ + // Function-internal label strictness (mid-function runto flow) + // ============================================================ + // + // When a test runs to a label that's declared *inside* a function body, + // the visible name set is: + // globals + function parameters + function locals declared up to that label + // + // Main-body names that aren't `global` are NOT visible — they aren't part + // of the function's lexical scope. This is the conservative rule: users + // who need shared state should declare it `global`. + + [Test] + public void Strictness_RuntoFunctionInternalLabel_FunctionParam_Visible() + { + var src = @" +do_work(7) +end + +function do_work(seed) + local total as integer + total = seed * 2 +fnInner: +endfunction total + +test foo + runto fnInner + assert seed = 7 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "function param `seed` should be visible at fnInner; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_LocalDeclaredBefore_Visible() + { + var src = @" +do_work(1) +end + +function do_work(seed) + local total as integer + total = seed + 10 +fnInner: +endfunction total + +test foo + runto fnInner + assert total = 11 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "function local `total` declared before fnInner should be visible; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_LocalDeclaredAfter_Errors() + { + var src = @" +do_work(1) +end + +function do_work(seed) +fnInner: + local afterValue as integer + afterValue = seed * 100 +endfunction afterValue + +test foo + runto fnInner + assert afterValue = 100 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "function local `afterValue` declared after fnInner should error; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_Global_Visible() + { + var src = @" +global tally as integer = 0 +do_work(3) +end + +function do_work(seed) + tally = tally + seed +fnInner: +endfunction tally + +test foo + runto fnInner + assert tally >= 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "global `tally` should be visible at fnInner; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_MainBodyNonGlobal_NotVisible() + { + // `mainCounter` is declared in main body (not `global`). Even though + // the test runs to a function-internal label, main-body non-globals + // should NOT be visible inside the function's lexical scope. + var src = @" +mainCounter = 5 +do_work(2) +end + +function do_work(seed) + local result as integer + result = seed +fnInner: +endfunction result + +test foo + runto fnInner + assert mainCounter = 5 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "main-body non-global `mainCounter` should NOT be visible at fnInner; " + + "errors: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_LocalInsideIfBranch_Visible() + { + // Both arms of if/else contribute names at the merge point — the same + // branch-merge rule that applies to top-level scope_at must apply + // inside functions too. + var src = @" +do_work(0) +end + +function do_work(flag) + if flag + a = 1 + else + b = 2 + endif +fnInner: +endfunction flag + +test foo + runto fnInner + assert a >= 0 + assert b >= 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "branch-merged names a, b should both be visible at fnInner; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_AssignmentToLaterLocal_CreatesImplicitTestLocal() + { + // After `runto fnInner`, visible = scope_at[fnInner] = { seed } + // (the param) — `late` is declared *after* fnInner so it's NOT in + // visible. The test's `late = 7` is bare-assignment-to-unbound, + // which creates an implicit test-local rather than writing through + // to the function's `late`. To actually mutate that function local, + // the runto target would need to be past the `local late` line. + var src = @" +do_work(1) +end + +function do_work(seed) +fnInner: + local late as integer + late = 99 +endfunction late + +test foo + runto fnInner + late = 7 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "bare assignment in a test should be an implicit test-local; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_MultipleRuntos_VisibleSetUpdatesEachTime() + { + // Two runtos in sequence. After the first, only `x` is visible. + // After the second, `y` is also visible. A reference to `y` between + // the runtos must error. + var src = @" +x = 1 +firstLabel: +y = 2 +secondLabel: +end + +test foo + runto firstLabel + assert x = 1 + assert y = 2 + runto secondLabel + assert y = 2 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Count(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.EqualTo(1), + "exactly one error expected for `y` between runtos; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoInsideIf_VisibilityPropagates() + { + // A runto inside an if-branch updates the test's visible set, and + // that change persists for statements after the if. (Not branch-local.) + var src = @" +x = 1 +target: +end + +test foo + local cond as integer = 1 + if cond + runto target + endif + assert x = 1 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "runto inside if should make x visible after the if; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_FunctionLocalInsideForLoop_BeforeLabel_Visible() + { + // Function-local declared inside a for loop above a function-internal + // label should still be visible at that label per branch-merge rules. + var src = @" +do_work() +end + +function do_work() + local i as integer + for i = 0 to 5 + innerSum = i + next i +fnLabel: +endfunction innerSum + +test foo + runto fnLabel + assert innerSum >= 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "innerSum declared inside for-loop above fnLabel should be visible; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_TwoFunctions_LabelsIndependent() + { + // Each function's labels get their own snapshot. Names from function A + // shouldn't leak into function B's label snapshot. + var src = @" +do_a(1) +do_b(2) +end + +function do_a(aParam) + local aLocal as integer + aLocal = aParam +labelA: +endfunction aLocal + +function do_b(bParam) +labelB: +endfunction bParam + +test usesA + runto labelA + assert aLocal = 1 +endtest + +test usesB + runto labelB + assert bParam = 2 +endtest + +test bDoesntSeeA + runto labelB + assert aLocal = 1 +endtest +"; + Parse(src, out var errs); + // usesA + usesB should NOT trip strictness errors. + // bDoesntSeeA SHOULD trip a strictness error for aLocal. + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "test `bDoesntSeeA` should error referencing aLocal at labelB; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_TestFromParent_InheritsParentRuntoScope() + { + // `test sample2 from sample` should inherit sample's runto-scope + // position so any main-body names sample had brought into view via + // runto are also visible inside sample2. Without this, the child + // test can't reference anything the parent unlocked, which defeats + // the point of `from`. + // + // STATUS: this currently fails — `fromParent` is parsed and stored + // (TestNode.fromParent, surfaced in the test manifest) but the + // strictness visitor doesn't propagate the parent's runto-visible + // set to the child. The child starts fresh with globals only and + // flags `x` as unreachable. + var src = @" +x = 3 +_L1: +end + +test sample + runto _L1 + assert x = 3 +endtest + +test sample2 from sample + print ""hahahah"" + assert x = 3 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), + Is.False, + "expected `x` to be visible in `sample2` via `from sample`'s runto inheritance; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/TestScopeTests.cs b/FadeBasic/Tests/TestScopeTests.cs new file mode 100644 index 0000000..6d1f40c --- /dev/null +++ b/FadeBasic/Tests/TestScopeTests.cs @@ -0,0 +1,783 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestScopeTests +{ + private ProgramNode Parse(string src, out List errors, bool checkScope = true) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = !checkScope }); + errors = prog.GetAllErrors(); + return prog; + } + + [Test] + public void Local_InTest_Parses() + { + var src = @" +test foo + local x as integer = 5 +endtest +"; + var prog = Parse(src, out var errs); + // For now, scope-check may flag this as something — that's OK; what we want + // to verify is parser-level: the statement is recognized. + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.GreaterThan(0)); + } + + [Test] + public void Local_InTest_Compiles() + { + // Verify a test with a local declaration compiles cleanly. + var src = @" +test foo + local x as integer = 5 +endtest +"; + var prog = Parse(src, out _, checkScope: false); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + Assert.DoesNotThrow(() => compiler.Compile(prog)); + Assert.That(compiler.TestManifest.Count, Is.EqualTo(1)); + } + + [Test] + public void Local_InTest_Executes() + { + // The test body's `local` declaration runs and assigns. We can't yet + // observe the local from C# without `assert` (Stage 5), but we can at + // least confirm execution completes without errors. + var src = @" +test foo + local x as integer = 5 +endtest +"; + var prog = Parse(src, out _, checkScope: false); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + var entry = compiler.TestManifest[0]; + var program = compiler.Program.ToArray(); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + Assert.DoesNotThrow(() => vm.Execute3()); + } + + [Test] + public void RuntoBlock_WithMaxCycles_Compiles() + { + var src = @" +mylabel: +end + +test foo + runto mylabel + max cycles 1000 + endrunto +endtest +"; + var prog = Parse(src, out _, checkScope: false); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + Assert.DoesNotThrow(() => compiler.Compile(prog)); + } + + [Test] + public void TestBody_CanContainMultipleStatements() + { + var src = @" +mylabel: +end + +test foo + local a as integer = 1 + local b as integer = 2 + runto mylabel +endtest +"; + var prog = Parse(src, out _, checkScope: false); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.EqualTo(3)); + } + + [Test] + public void Local_ScopeChecksClean() + { + // local declared and used in a test: scope check should pass. + var src = @" +test foo + local x as integer + x = 5 +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Count, Is.EqualTo(0), + "expected no errors, got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void GlobalDeclared_VisibleInTest() + { + // `global x` is declared before the test; the test reads it. + // After we have read-through-to-globals semantics, this should be clean. + var src = @" +global x as integer = 7 +end + +test foo + local y as integer + y = x +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Count, Is.EqualTo(0), + "expected no errors, got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void TwoTests_LocalsDontLeak() + { + // Each test has its own local-variable scope. A `local` in test alpha + // should not be visible to test beta. + var src = @" +test alpha + local x as integer = 5 +endtest + +test beta + local x as integer = 10 +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Count, Is.EqualTo(0), + "fresh scope per test means same local name in two tests is fine; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_CommandArg() + { + // `local x` is a top-level declaration. The test has no runto, so its + // visible-program-set is just globals (empty). Referencing `x` inside + // a command arg (`print x`) must be flagged TestVariableUnreachable — + // exposes the missing CommandStatement case in TestScopeStrictnessVisitor. + var src = @" +local x = 42 +_L1: + +test sample + print x +endtest +"; + AssertHasUnreachable(src, "x", "print x"); + } + + [Test] + public void ProgramRefCommandIntroducedVar_NotVisibleInTest() + { + // `inc x` at program top-level introduces `x` as a program variable — + // the base scope checker treats ref-command args as bindings + // (Parser.cs Scope.AddCommand -> TryAddVariable). A test that + // references `x` without a runto past this point must be flagged + // TestVariableUnreachable. + // + // Note: `inc x` *inside* the test body is NOT this scenario — there + // it acts like `x = ...`, implicitly declaring a test-local. That + // case is allowed and should not error. + // + // Exposes two compound gaps in TestScopeStrictnessVisitor: + // 1. WalkStatements has no CommandStatement case, so top-level + // ref-command bindings never enter allTopLevelNames or any + // scope_at snapshot. + // 2. VisitStatement has no CommandStatement case, so the test's + // `print x` arg is never validated. + // Both fixes are needed before this test passes. + var src = @" +inc x +_L1: + +test sample + print x +endtest +"; + AssertHasUnreachable(src, "x", "print x (x introduced by program `inc x`)"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_WhileCondition() + { + // The visitor descends into `while` body statements but never visits + // `whileStmt.condition`. A reference there slips past validation. + var src = @" +local x = 42 +_L1: + +test sample + while x > 0 + endwhile +endtest +"; + AssertHasUnreachable(src, "x", "while x > 0"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_ForBounds() + { + // ForStatement case adds the iterator to test-locals and walks the + // body, but doesn't check startValue/endValue/stepValue expressions. + var src = @" +local x = 42 +_L1: + +test sample + for i = 1 to x + next +endtest +"; + AssertHasUnreachable(src, "x", "for i = 1 to x"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_RepeatUntilCondition() + { + // `RepeatUntilStatement` is handled in WalkStatements but completely + // absent from VisitStatement — body and `until` condition both skipped. + var src = @" +local x = 42 +_L1: + +test sample + repeat + until x > 0 +endtest +"; + AssertHasUnreachable(src, "x", "until x > 0"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_SwitchExpression() + { + // `SwitchStatement` is in WalkStatements but absent from VisitStatement. + // The `select` expression and case bodies are both unchecked. + var src = @" +local x = 42 +_L1: + +test sample + select x + case 1 + endcase + endselect +endtest +"; + AssertHasUnreachable(src, "x", "select x"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_NestedExpressionInCommand() + { + // Even nested inside arithmetic + a function-call arg, the reference + // must be caught. Same root cause (no CommandStatement case) but + // exercises deeper expression walking once that case is added. + var src = @" +local x = 42 +_L1: + +test sample + print add(x + 1, 2) +endtest +"; + AssertHasUnreachable(src, "x", "print add(x + 1, 2)"); + } + + [Test] + public void ProgramStructLocal_FieldAccess_NotVisibleInTest() + { + // A struct local declared at program top-level. The test references + // a field via `p.x`. CheckExpression walks the StructFieldReference + // and finds `p` as a VariableRefNode — same visibility rule applies, + // so referencing it without a runto must flag TestVariableUnreachable. + var src = @" +type pt + x + y +endtype +local p as pt +_L1: + +test sample + print p.x +endtest +"; + AssertHasUnreachable(src, "p", "print p.x"); + } + + [Test] + public void TestLocalStruct_FieldAccess_NoError() + { + // Sanity counterpart: when the struct is declared inside the test, + // field access is fine — `p` is a test-local, so the visibility rule + // doesn't fire. + var src = @" +type pt + x + y +endtype + +test sample + local p as pt + p.x = 5 + print p.x +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestVariableUnreachable.code + || e.errorCode.code == ErrorCodes.TestVariableNotYetDeclared.code), + Is.False, + "expected no strict-test scope errors; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void StructFieldName_CollidesWithTopLevelVar_FalsePositive() + { + // BUG DEMO (currently failing): `p.foo` is a StructFieldReference + // whose `right` side is a VariableRefNode("foo"). CheckExpression + // walks every VariableRefNode in the tree and treats `foo` as if it + // were a variable lookup. When a top-level `local foo` happens to + // share the field's name, the visitor flags the field side as + // TestVariableUnreachable even though it's just a struct member. + // + // The fix is roughly: skip the `right` side of a StructFieldReference + // when walking (or only walk the left chain). Until then, this test + // documents the false positive. + var src = @" +type pt + foo +endtype + +local foo = 7 + +test sample + local p as pt + print p.foo +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestVariableUnreachable.code), + Is.False, + "field name `foo` on test-local `p` must not be treated as a variable lookup; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void ProgramStructLocal_FieldAccess_VisibleAfterRunto() + { + // With a runto past the declaration, the struct local is in scope_at + // and `p.x` should validate cleanly. Proves the runto -> scope_at + // path composes with StructFieldReference walking. + var src = @" +type pt + x + y +endtype +local p as pt +_L1: + +test sample + runto _L1 + print p.x +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestVariableUnreachable.code + || e.errorCode.code == ErrorCodes.TestVariableNotYetDeclared.code), + Is.False, + "expected no strict-test scope errors after runto past declaration; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_AcrossFunctionBoundaries_VisibilityReplacesNotUnions() + { + // Documents how `visible` shifts as a test runtos across function + // boundaries. Today the visitor REPLACES the visible set on each + // runto (no union), so each scope sees only its own snapshot plus + // globals. + // + // Stages: + // 1. runto _main1 visible = scope_at[_main1] = { top1 } + // 2. runto _inA visible = scope_at[_inA] = { a1 } + // (top1 from earlier runto is gone) + // 3. runto _inB visible = scope_at[_inB] = { b1 } + // (a1 gone) + // 4. runto _inA again visible reverts to { a1 } + // (b1 gone) + // + // Each `print X` after a runto is checked against the snapshot at + // that moment; references that aren't visible become + // TestVariableNotYetDeclared (runtoTarget != null after the first + // runto). The expected set of error names captures exactly which + // references the visitor flags. + var src = @" +local top1 = 1 +_main1: +local top2 = 2 +helper_a() +end + +function helper_a() + local a1 = 10 + _inA: + local a2 = 20 + helper_b() +endfunction + +function helper_b() + local b1 = 100 + _inB: + local b2 = 200 +endfunction + +test sample + runto _main1 + print top1 + print top2 + + runto _inA + print a1 + print top1 + print a2 + + runto _inB + print b1 + print a1 + + runto _inA + print a1 + print b1 +endtest +"; + Parse(src, out var errs); + + var visibilityErrs = errs + .Where(e => e.errorCode.code == ErrorCodes.TestVariableNotYetDeclared.code + || e.errorCode.code == ErrorCodes.TestVariableUnreachable.code) + .Select(e => e.message) + .OrderBy(s => s) + .ToList(); + + // Stage 1: top2 (declared after _main1) + // Stage 2: top1 (not in fnA scope), a2 (declared after _inA) + // Stage 3: a1 (not in fnB scope) + // Stage 4: b1 (not in fnA scope after re-entry) + var expected = new[] { "a1", "a2", "b1", "top1", "top2" }; + + Assert.That(visibilityErrs, Is.EquivalentTo(expected), + "expected exactly these visibility errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_IntoFunction_SeesImplicitAssignmentBeforeLabel() + { + // After `runto _L2`, execution is inside `tuna()` and `y = 24` has + // already run, so the test's `print y` should be clean. The base + // scope checker inserts an implicit `local y` declaration when it + // encounters the bare assignment, which means the strict visitor's + // fnState should pick up `y` before it snapshots `_L2`. + var src = @" +local x = 42 +_L1: +helper() + +function helper() + y = 24 + _L2: +endfunction + +test sample + runto _L1 + print x + runto _L2 + print y +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors of any kind; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_IntoFunction_ParamVisibleAfterRunto() + { + // ComputeFunctionInternalScopeAts adds function parameters to fnState + // before walking the body, so scope_at[_inside] includes `p`. + // After `runto _inside`, the test should see `p`. + var src = @" +helper(5) +end + +function helper(p) + _inside: + local q = p + 1 +endfunction + +test sample + runto _inside + print p +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_IntoFunction_ParamNotVisibleWithoutRunto() + { + // Mirror of the previous test: without a runto into helper, the param + // `p` is in allTopLevelNames (so the base check resolves it via our + // parent-fn copy) but not in visible -> TestVariableUnreachable. + var src = @" +helper(5) +end + +function helper(p) +endfunction + +test sample + print p +endtest +"; + AssertHasUnreachable(src, "p", "print p without runto into helper"); + } + + [Test] + public void FunctionInternalLocal_NotVisibleWithoutRunto() + { + // Negative counterpart to Runto_IntoFunction_SeesImplicitAssignmentBeforeLabel. + // The base scope checker now resolves `y` (we copy parent fn locals into + // the test scope), but the strict visitor must still flag it as + // unreachable when no runto reaches the function-internal label. + var src = @" +helper() +end + +function helper() + y = 24 + _L2: +endfunction + +test sample + print y +endtest +"; + AssertHasUnreachable(src, "y", "print y without runto into helper"); + } + + [Test] + public void TwoFunctions_SameLocalName_EachRuntoSeesItsOwn() + { + // fnA and fnB both declare `local result`. After `runto _inA`, the + // visible set is scope_at[_inA] = {result}; same after `runto _inB`. + // Strict visitor validates clean for both. The base checker resolves + // `result` to fnA's symbol (first-source-wins) for both references, + // which is fine for visibility-only purposes. + var src = @" +fnA() +fnB() +end + +function fnA() + local result = 1 + _inA: +endfunction + +function fnB() + local result = 2 + _inB: +endfunction + +test sample + runto _inA + print result + runto _inB + print result +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLocal_ShadowsParentFunctionInternal() + { + // Test body declares `local y = 99`. Parent fn `helper` also has an + // internal `y`. The test's `y` should win — VisitStatement's + // CheckExpression checks testLocals first and short-circuits before + // allTopLevelNames. Reference to `y` in the test must be clean. + var src = @" +helper() +end + +function helper() + y = 24 + _L2: +endfunction + +test sample + local y = 99 + print y +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLocal_ConflictsWithRuntoExposedName_DeclThenRunto() + { + // Declaration first, then runto brings the conflicting name into + // view -> TestRuntoShadowsLocal at the runto. + var src = @" +helper() +end + +function helper() + y = 24 + _L2: +endfunction + +test sample + local y = 99 + runto _L2 +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestRuntoShadowsLocal.code + && e.message == "y"), + Is.True, + "expected TestRuntoShadowsLocal at runto site for `y`; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLocal_ConflictsWithRuntoExposedName_RuntoThenDecl() + { + // Runto brings `y` into view first, then the test declares a local + // of the same name -> TestRuntoShadowsLocal at the declaration. + var src = @" +helper() +end + +function helper() + y = 24 + _L2: +endfunction + +test sample + runto _L2 + local y = 99 +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestRuntoShadowsLocal.code + && e.message == "y"), + Is.True, + "expected TestRuntoShadowsLocal at declaration for `y`; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void BareAssignment_InTest_ImplicitTestLocal_NoError() + { + // `x = 4` at top level introduces `x` as a program top-level local + // (via the base checker's implicit-decl). The test then does its + // own bare `x = 12` with no runto -> should be a fresh implicit + // test-local, same way `x = 12` inside a function body would be + // an implicit function-local. The strict visitor must NOT flag it + // as TestVariableUnreachable. + var src = @" +x = 4 +_L1: +test sample + x = 12 +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLocal_ShadowsGlobal_NoConflict() + { + // Globals are always-shadowable: a test-local with the same name as + // a global must not fire TestRuntoShadowsLocal. + var src = @" +global g = 5 + +test sample + local g = 10 +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestRuntoShadowsLocal.code), + Is.False, + "shadowing a global should not flag TestRuntoShadowsLocal; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_UnknownLabel_EmitsParseError() + { + // A runto whose target label doesn't exist anywhere in the program + // should be a hard parse error (RuntoUnknownLabel is already defined + // in Errors.cs but currently never emitted). Until that's wired up, + // the visitor silently sets currentRuntoTarget and produces + // confusing "not yet declared" errors for *every* subsequent + // reference in the test. + var src = @" +local x = 1 +_real: + +test sample + runto _does_not_exist +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.RuntoUnknownLabel.code), + Is.True, + "expected RuntoUnknownLabel for `_does_not_exist`; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + private void AssertHasUnreachable(string src, string varName, string context) + { + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestVariableUnreachable.code), + Is.True, + $"expected TestVariableUnreachable for `{varName}` in `{context}`; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/Tests.csproj b/FadeBasic/Tests/Tests.csproj index f28f96f..25c893e 100644 --- a/FadeBasic/Tests/Tests.csproj +++ b/FadeBasic/Tests/Tests.csproj @@ -24,7 +24,7 @@ - + @@ -36,10 +36,14 @@ + - + + + + diff --git a/FadeBasic/Tests/TokenMacroTests.cs b/FadeBasic/Tests/TokenMacroTests.cs index 139e6f2..989bd27 100644 --- a/FadeBasic/Tests/TokenMacroTests.cs +++ b/FadeBasic/Tests/TokenMacroTests.cs @@ -780,7 +780,7 @@ function decl(x) ", @" #macro - x = macro return test() + x = macro return demo() # a = 1 #endmacro ")] @@ -789,7 +789,7 @@ function decl(x) ", @" #macro - x = macroReturnTest() + x = macroReturnDemo() # a = 1 #endmacro ")] @@ -873,7 +873,7 @@ public void Macro_Commands() var input = @" #macro - macroFuncTest 6, myImage + macroFuncDemo 6, myImage #endmacro a = [myImage] "; @@ -892,7 +892,7 @@ public void Macro_Command_2_ParseIssue() var input = @" #macro - x = macro return test() + x = macro return demo() # a = 1 #endmacro "; diff --git a/FadeBasic/Tests/TokenVm.cs b/FadeBasic/Tests/TokenVm.cs index a0c620d..6a4d46d 100644 --- a/FadeBasic/Tests/TokenVm.cs +++ b/FadeBasic/Tests/TokenVm.cs @@ -439,7 +439,7 @@ public void Expression_Conditionals_Literal_Ints(string src, int expected) { Setup(src, out _, out var prog); var vm = new VirtualMachine(prog); - vm.Execute().MoveNext(); + vm.Execute3(); Assert.That(vm.dataRegisters[0], Is.EqualTo(expected)); Assert.That(vm.typeRegisters[0], Is.EqualTo(TypeCodes.INT)); } @@ -2782,7 +2782,7 @@ public void Macro_CommandCall() { var src = @" #macro -x = macro return test() +x = macro return demo() #endmacro n = [x] "; @@ -3643,17 +3643,17 @@ e AS egg albert AS chicken albert.e.color = 3 albert.n = 4 -test = albert.e.color * albert.n +result = albert.e.color * albert.n "; Setup(src, out var compiler, out var prog); var vm = new VirtualMachine(prog); vm.hostMethods = compiler.methodTable; vm.Execute2(); - - + + Assert.That(vm.heap.Cursor, Is.EqualTo(8.ToPtr())); Assert.That(vm.typeRegisters[0], Is.EqualTo(TypeCodes.STRUCT)); - + Assert.That(vm.typeRegisters[1], Is.EqualTo(TypeCodes.INT)); Assert.That(vm.dataRegisters[1], Is.EqualTo(12)); } @@ -3870,7 +3870,7 @@ public void CallHost() { var x = new TestCommands(); - var src = "callTest"; + var src = "callDemo"; Setup(src, out var compiler, out var prog); var vm = new VirtualMachine(prog); vm.hostMethods = compiler.methodTable; diff --git a/FadeBasic/Tests/TokenVm_GC.cs b/FadeBasic/Tests/TokenVm_GC.cs index c5a9763..88f8a8c 100644 --- a/FadeBasic/Tests/TokenVm_GC.cs +++ b/FadeBasic/Tests/TokenVm_GC.cs @@ -35,19 +35,19 @@ v as vec v3 = v2 ", 3)] [TestCase(@" -x$ = test() -function test() +x$ = demo() +function demo() endfunction ""igloo"" ", 1)] [TestCase(@" -x$ = test(1) -x$ = test(2) -function test(n) +x$ = demo(1) +x$ = demo(2) +function demo(n) endfunction str$(n) ", 1)] [TestCase(@" -test() -function test() +demo() +function demo() z$ = ""toast"" endfunction ", 1)] @@ -56,8 +56,8 @@ type vec x y endtype -v = test() ` 1 allocation to assign -function test() +v = demo() ` 1 allocation to assign +function demo() v2 as vec v3 as vec endfunction v2 diff --git a/FadeBasic/book/FadeBook/Language.md b/FadeBasic/book/FadeBook/Language.md index 15a88d1..7fd799c 100644 --- a/FadeBasic/book/FadeBook/Language.md +++ b/FadeBasic/book/FadeBook/Language.md @@ -934,6 +934,8 @@ IF x > 0 THEN GOTO tuna `GOTO` statements can be used to escape from looping control structures, or indeed from any control statements. However, `GOTO` statements _cannot_ be used to jump the code between [#scopes](#scopes). +---- +#### Labels _Labels_ are defined as any valid variable name, with a `:` symbol immediately following the name. Labels cannot be redeclared. ---- @@ -1361,3 +1363,750 @@ blueFish as TUNA `a second TUNA is allocated redFish = blueFish `the first TUNA is no longer being reference, and is garbage collected. ``` +## Testing + +_Fade Basic_ has a software testing framework built into the language itself. A program may define a series of test blocks using the `TEST` and `ENDTEST` keywords. + +```basic +TEST sample + `this is an example test block called `sample` +ENDTEST +``` + +The code inside a test block is never executed as part of the main program execution. +```basic +print "a" +TEST sample + print "b" +ENDTEST + +`runtime output: +` a +``` + +Instead, test blocks are run as distinct top level programs. +When `dotnet test` is run, all of the test blocks will run in sequence, in the order they are defined in the program. +```basic +TEST tuna + print "a" +ENDTEST +TEST fish + print "b" +ENDTEST + +`test output +` a +` b +``` + +Test blocks have a unique access to the scope of the main program. They can _access_ variables defined in the global [scope](#scopes) of the program, but by default, they do not have access to local scoped variables. However, the test program does not run the main program, so global values are _declared_, but they will not have their actual assigned values. In other words, the test starts executing before the main program. +```basic +GLOBAL x = 42 +TEST sample + print x +ENDTEST + +`test output +` 0 +``` + +---- +#### `RUNTO` + +Test programs are allowed to control the flow of the main program by using the `RUNTO` syntax. The `RUNTO` keyword must be followed by a [label](#labels) name. + +When the test program executes a `RUNTO` statement, the execution will switch from the test program _into_ the main program, and execution will continue until the given label is reached. When the execution reaches the given label, execution returns after the `RUNTO` statement in the test program. +```basic + +print "hello" +_L1: + +TEST sample + print "start" + RUNTO _L1 + print "end" +ENDTEST + +`test output +` start +` hello +` end +``` + +The `RUNTO` statement can take an optional `MAX CYCLES` clause followed by an integer expression. The value represents the max number of instructions that the _Fade Basic_ virtual machine will allow before causing the test to _fail_. For example, if the main program would never reach the desired label, the `MAX CYCLES` clause can be used to prevent the test from running forever. +```basic +WHILE 1 + `loop forever +ENDWHILE + +print "hello" +_L1: + +TEST sample + print "start" + RUNTO _L1 MAX CYCLES 1000 +ENDTEST + +`test output +` hello +` +``` + +A label can be `RUNTO` multiple times, if it is in a loop. +```basic +counter = 0 +DO + counter = counter + 1 + _L1: +LOOP + +TEST sample + RUNTO _L1 + PRINT counter + + PRINT "Mid Test" + + RUNTO _L1 + PRINT counter +ENDTEST + +`test output +` 1 +` Mid Test +` 2 +``` + +The `RUNTO` syntax can be combined with other _Fade Basic_ language primitives to make conditional `RUNTO` sections. +```basic +counter = 0 +_L1: +DO + counter = counter + 1 + _L2: +LOOP + +TEST sample + RUNTO _L1 + + WHILE counter < 3 + PRINT "looping" + RUNTO _L2: + ENDWHILE + + PRINT counter + +ENDTEST + +`test output +` looping +` looping +` looping +` 3 +``` + +---- +#### Test Scope + +By default, test blocks can only reference global scope from the main program. However, anytime a `RUNTO` statement resumes execution into the test program, the current available scope in the test block is equivalent to the scope from where the main program was paused. +```basic +x = 42 +_L1: + +TEST sample + RUNTO _L1 + print x +ENDTEST + +`test output +` 42 +``` + +In the example, it would be invalid to access `x` before the `RUNTO`, because the main program has not declared `x` yet. +```basic +x = 42 +_L1: + +TEST sample + print x `invalid; x is not defined yet. +ENDTEST +``` + +Variables can come in and out of scope. +```basic +LOCAL x = 42 +_L1: +tuna() + + +FUNCTION tuna() + y = 24 + _L2: +ENDFUNCTION + +TEST sample + RUNTO _L1 + print x + RUNTO _L2 + print y + ` note, x is no longer valid +ENDTEST +``` + +Sadly, it is possible to introduce a variable name collision. +```basic +x = 4 `x is declared as a variable in the main program +_L1: +TEST sample + x = 12 `x is declared as a variable in the test program + RUNTO _L1 `this RUNTO is invalid, because it introduces a conflict on x. +ENDTEST +``` + +State can be mutated from within a test. +``` +x = 1 +_L1: + +print x +_L2: + +TEST sample + RUNTO _L1 + x = 42 `mutate the program state + RUNTO _L2 +ENDTEST + +`test output +` 42 +``` + +Functions are always global in _Fade Basic_, which means a test block may invoke a function defined in the main program. +```basic +FUNCTION add(a, b) +ENDFUNCTION a + b + +TEST sample + sum = add(1,2) +ENDTEST +``` + +However, remember that if a function references variables that have not been set yet, their values will be zero'd. +```basic +GLOBAL x = 100 + +FUNCTION greaterThanX(a) +ENDFUNCTION a > x + +TEST sample + n = greaterThanX(5) `5 is less than 100 + print n +ENDTEST + +`test output +` 1 +``` + +Each test can declare variables in isolation of _other_ tests. +```basic +TEST t1 + LOCAL x = 4 + PRINT x +ENDTEST + +TEST t2 + LOCAL x = 80 + PRINT x +ENDTEST +``` + +---- +#### Asserts + +A test block can use the `ASSERT` statement to cause a test to pass or fail. An `ASSERT` statement must be followed by an expression that resolves to a boolean. When the expression resolves to a truthy value, the assert statement is valid. Otherwise, the assert statement is considered _invalid_. The test block will stop executing and be marked as a failure at the first invalid `ASSERT` statement. +```basic +TEST sample + x = 1 + ASSERT x = 1 `this assert passes. + ASSERT x = 0 `this assert fails, and causes the test to fail. +ENDTEST +``` + +The `ASSERT` statement can be used to validate the output of a function. +```basic +FUNCTION add(a, b) +ENDFUNCTION a + b + +TEST sample + ASSERT add(1,2) = 3 +ENDTEST +``` + +The `ASSERT` statement can be used to validate the state of the program in conjunction with the [`RUNTO`](#runto) statement. +```basic +x = 1 + 2 +_L1: + +TEST sample + RUNTO _L1 + ASSERT x = 3 +ENDTEST +``` + +`ASSERT` statements can optionally include a _reason_ phrase after the condition. The _reason_ phrase will be included the output if the assert ever fails. These are useful for adding documentation to failed assertions. +```basic +TEST sample + x = -12 + ASSERT x > 0, "x should be greater than zero" +ENDTEST +``` + +Similar to [`short circuits`](#short-circuiting), if an `ASSERT` does _not_ fail, then the _reason_ phrase is not evaluated. +```basic +FUNCTION message(x) + msg$ = "failed " + str$(x) + print msg$ +ENDFUNCTION msg$ + +TEST sample + ASSERT 1 = 1, message(1) `message(1) is never evaluated, because the assert is valid +ENDTEST +``` + +The `ASSERT` statement may also be used outside of a test block. + +When the assertion happens as part the execution of a test block, any failure will cause the current test block to fail. +```basic +FUNCTION ex(x) + ASSERT x > 0 `the test will fail here + print x +ENDFUNCTION + +TEST sample + ex(0) +ENDTEST +``` + +When the assertion happens as part of the main program, any failure will crash the entire program. +The `ASSERT` statement will halt the entire program if it fails. +```basic +FUNCTION ex(x) + ASSERT x > 0 `the program will crash here + print x +ENDFUNCTION + +ex(0) +``` + + +Any [`DEFERRED`](#defer-statements) statements will execute when an assertion causes a test block to fail. +```basic +DEFER print "a" +_L1: + +TEST sample + DEFER print "b" + RUNTO _L1 + + ASSERT 0 +ENDTEST + +`test output +` a +` b +``` + +However, when an assertion crashes a program without a test block, deferred statements **are not** executed. In this scenario, the `ASSERT` acts as a fatal exception, causing the program to crash. +```basic +DEFER print "a" +ASSERT 0 +` the assert throws a fatal exception, and the deferred statement is not run +``` + +---- +#### Test Macros + +Test blocks can be combined with [`#MACRO`](#compile-time-execution) statements. The following example will result in 3 unique tests. +```basic +FUNCTION evenAndPositive(n) + isEven = n mod 2 = 0 + isPositive = n > 0 + x = 1 and isEven and isPositive +ENDFUNCTION x + +#MACRO + DIM cases(3) + cases(0) = 42 + cases(1) = 888 + cases(2) = 36 + + for n = 0 to 2 + v = cases(n) + #tokenize + TEST sample_[v] + ASSERT evenAndPositive([v]) = 1 + ENDTEST + #endtokenize + next +#ENDMACRO +``` + +---- +#### Mocks + +During a test block, it is possible to change what happens when a [command](#commands) executes. This is called _mocking_. + +To create a mock, use the `MOCK` block. The name of the command must follow immediately after the opening `MOCK` keyword. The block must end with an `ENDMOCK` keyword. +```basic +WAIT MS(1000) `WAIT MS() waits for the given number of milliseconds +_L1: +TEST sample + `mock the WAIT MS(1000) so that the test does not need to wait at all. + MOCK WAIT MS + PRINT "simulating instant wait." + ENDMOCK + RUNTO _L1 +ENDTEST + +`test output +` simulating instant wait. +``` + +If the mocked command returns a value, then the `ENDMOCK` statement must be followed by a mocked return value. +```basic +x = TIMER() `TIMER() is a command that returns the number of milliseconds since the program started. +_L1: +TEST sample + MOCK TIMER + ENDMOCK 42 + + RUNTO _L1 + ASSERT x = 42 +ENDTEST +``` + +The mock block may exit early with the `EXITMOCK` keyword. If the command is supposed to return a value, the `EXITMOCK` keyword must be followed by the mocked value. +```basic +x = TIMER() `TIMER() is a command that returns the number of milliseconds since the program started. +_L1: +TEST sample + MOCK TIMER + EXITMOCK 11 + ENDMOCK 42 + + RUNTO _L1 + ASSERT x = 11 +ENDTEST +``` + +Mock blocks can access global scope from the test block. +```basic +x = TIMER() `TIMER() is a command that returns the number of milliseconds since the program started. +_L1: +TEST sample + GLOBAL t = 42 + MOCK TIMER + ENDMOCK t + + RUNTO _L1 + ASSERT x = t +ENDTEST +``` + +The mock block can access the parameters passed to the command. +```basic +WAIT MS(1000) +_L1: +TEST sample + MOCK WAIT MS(time) + PRINT "simulating wait for " + str$(time) + ENDMOCK + RUNTO _L1 +ENDTEST + +`test output +` simulating wait for 1000 +``` + +When the command has parameters that must be assigned, the mock block must assign them. +```basic +`normally, the INPUT command accepts a line of input from the terminal, and puts the value in x$ +INPUT "enter name", x$ +_L1: +TEST sample + MOCK INPUT(_, val$) + `override the terminal input so the test does not need the user to type anything + val$ = "mr tuna" + ENDMOCK + RUNTO _L1 + + ASSERT x$ = "mr tuna" +ENDTEST +``` + +Instead of returning a value and setting out parameters, a mocked command can use the `FORBID` keyword to signal that the command must not be called at all. +```basic +WAIT MS(1000) +_L1: + +TEST sample + MOCK WAIT MS + FORBID `if the WAIT MS command is called at all, the test will fail. + ENDMOCK + + RUNTO _L1 +ENDTEST + +`the test will fail. +``` + +It is not valid to put a `FORBID` statement as a sub statement in a mock block. It must be part of the top level scope. + +Once a command is mocked, the mock will be called for every call to the command. +```basic +FOR n = 1 to 3 + WAIT MS(n) +NEXT + +_L1: + +TEST sample + MOCK WAIT MS(n) + PRINT "waiting: " + str$(n) + ENDMOCK + RUNTO _L1 +ENDTEST + +`test output +` waiting: 1 +` waiting: 2 +` waiting: 3 +``` + +Any mocked command can be overridden if a new mock block targets the command. +```basic +FOR n = 1 to 3 + WAIT MS(n) + _L1: +NEXT + +TEST sample + MOCK WAIT MS(n) + PRINT "a: " + str$(n) + ENDMOCK + RUNTO _L1 + + MOCK WAIT MS(n) + PRINT "b: " + str$(n) + ENDMOCK + RUNTO _L1 + + MOCK WAIT MS(n) + PRINT "c: " + str$(n) + ENDMOCK + RUNTO _L1 +ENDTEST + +`test output +` a: 1 +` b: 2 +` c: 3 +``` + +The `CLEAR MOCK` syntax will remove any mocks for the given command. The name of the command must follow. +```basic +FOR n = 1 to 3 + WAIT MS(n) + _L1: +NEXT + +_L2: + +TEST sample + MOCK WAIT MS(n) + PRINT "a: " + str$(n) + ENDMOCK + RUNTO _L1 + + CLEAR MOCK WAIT MS + RUNTO _L2 +ENDTEST + +`test output +` a: 1 +``` + +---- +#### Child Tests + +A test block can use the `FROM` keyword to pick up where a previous test block ended. +```basic +TEST parent + x = 1 +ENDTEST + +TEST child FROM parent + assert x = 1 +ENDTEST + +`in this case, there are 2 successful tests, parent, and child. +``` + +A test block can be prefixed with the `ABSTRACT` keyword to prevent it from running or counting as a test. However, an abstract test can still be used as a parent. +```basic +ABSTRACT TEST parent + x = 1 +ENDTEST + +TEST child FROM parent + assert x = 1 +ENDTEST + +`now there is only one runnable test, child +``` + +When a test starts from the end of a previous test, it will inherit the current scope semantics. +```basic +x = 42 +_L1: + +ABSTRACT TEST parent + RUNTO _L1 +ENDTEST + +TEST child FROM parent + assert x = 42 `x is in scope, because the parent ran to _L1 +ENDTEST +``` + +A test will inherit function declarations and mocks from a parent test. +```basic +WAIT MS(1000) +x = 42 +_L1: + +ABSTRACT TEST parent + + ` configure mock for children. + MOCK WAIT MS + ENDMOCK + + FUNCTION add(a, b) + ENDFUNCTION a + b +ENDTEST + +TEST child FROM parent + RUNTO _L1 + + ` use function from parent + n = add(21, 21) + assert x = n +ENDTEST +``` + +When multiple test blocks continue from the same parent, they each get an isolated continuation. Modifications from one child test will not modify the initial parent scope for the next child test. +```basic +ABSTRACT TEST parent + x = 1 +ENDTEST + +TEST child1 FROM parent + x = x + 1 + assert x = 2 +ENDTEST + +TEST child2 FROM parent + x = x + 2 + assert x = 3 +ENDTEST + +TEST child3 FROM parent + x = x + 3 + assert x = 4 +ENDTEST +``` + +Deferred statements in a parent test run as if they were deferred from a child test. This can be used to set up re-usable teardown. +```basic +counter = 0 +_L1: +DO + counter = counter + 1 + _L2: +LOOP + +ABSTRACT TEST parent + DEFER + PRINT "teardown" + ENDDEFER +ENDTEST + +TEST sample FROM parent + RUNTO _L1 + + WHILE counter < 3 + PRINT "looping" + RUNTO _L2: + ENDWHILE + + PRINT counter + +ENDTEST + +`test output +` teardown +` looping +` looping +` looping +` 3 + +``` + + +---- +#### Call Counts +The `CALL COUNT` keyword allows a test block to get the number of times a command has been invoked, since the start of the test. +```basic +FOR n = 1 to 3 + WAIT MS(n) +NEXT + +_L1: + +TEST sample + RUNTO _L1 + x = CALL COUNT WAIT MS + ASSERT x = 3 +ENDTEST +``` + +The `CALL COUNT` is not reset between parent and child test runs. +```basic + +ABSTRACT TEST parent + WAIT MS(1) +ENDTEST + +TEST sample FROM parent + WAIT MS(1) + PRINT CALL COUNT WAIT MS +ENDTEST +`test output +` 2 +``` + +---- +#### Calling Tests + +Tests can be invoked from the command line in 2 ways, either through `dotnet test` to integrate with the dotnet ecosystem, or directly through reserved program flags using `dotnet run`. + +| Action | `dotnet test` | `dotnet run` | +| :----- | :------------ | :----------- | +| Run all tests | `dotnet test` | `dotnet run --fade-test-all` | +| List all tests | `dotnet test --list-tests` | `dotnet run --fade-list-tests` | +| Run a test | | `dotnet run --fade-test ` | + +> [!TIP] +> By default, `dotnet test` hides the standard out logging during testing. To see it, you must include the `--logger` switch. +> ```bash +> dotnet test --logger "console;verbosity=detailed" +> ``` diff --git a/FadeBasic/build.sln b/FadeBasic/build.sln index c270d50..3885aa1 100644 --- a/FadeBasic/build.sln +++ b/FadeBasic/build.sln @@ -1,5 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic", "FadeBasic\FadeBasic.csproj", "{57007F64-F4ED-4979-BC09-1F58502953A2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommandSourceGenerator", "CommandSourceGenerator\CommandSourceGenerator.csproj", "{E7702D0D-11F7-43E6-9574-C4DF0C1410A7}" @@ -14,6 +15,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBuildTasks", "FadeBuild EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.Lib.Standard", "FadeBasic.Lib.Standard\FadeBasic.Lib.Standard.csproj", "{C83538B6-7BA1-471C-B1F8-EE658166ABE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.Testing", "FadeBasic.Testing\FadeBasic.Testing.csproj", "{EFFB7814-CAB3-4AC5-9010-53845FAAE795}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.TestAdapter", "FadeBasic.TestAdapter\FadeBasic.TestAdapter.csproj", "{378913B6-568C-46A3-848F-25EFF4605358}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.Lib.Web", "FadeBasic.Lib.Web\FadeBasic.Lib.Web.csproj", "{A51EA3F2-ED0A-47FA-8DCA-41731FC0818C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,5 +56,17 @@ Global {C83538B6-7BA1-471C-B1F8-EE658166ABE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {C83538B6-7BA1-471C-B1F8-EE658166ABE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {C83538B6-7BA1-471C-B1F8-EE658166ABE0}.Release|Any CPU.Build.0 = Release|Any CPU + {EFFB7814-CAB3-4AC5-9010-53845FAAE795}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFFB7814-CAB3-4AC5-9010-53845FAAE795}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFFB7814-CAB3-4AC5-9010-53845FAAE795}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFFB7814-CAB3-4AC5-9010-53845FAAE795}.Release|Any CPU.Build.0 = Release|Any CPU + {378913B6-568C-46A3-848F-25EFF4605358}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {378913B6-568C-46A3-848F-25EFF4605358}.Debug|Any CPU.Build.0 = Debug|Any CPU + {378913B6-568C-46A3-848F-25EFF4605358}.Release|Any CPU.ActiveCfg = Release|Any CPU + {378913B6-568C-46A3-848F-25EFF4605358}.Release|Any CPU.Build.0 = Release|Any CPU + {A51EA3F2-ED0A-47FA-8DCA-41731FC0818C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A51EA3F2-ED0A-47FA-8DCA-41731FC0818C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A51EA3F2-ED0A-47FA-8DCA-41731FC0818C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A51EA3F2-ED0A-47FA-8DCA-41731FC0818C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/FadeBasic/global.json b/FadeBasic/global.json index 75a80e9..f98e01d 100644 --- a/FadeBasic/global.json +++ b/FadeBasic/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.0", + "version": "10.0.0", "rollForward": "latestMinor" } } \ No newline at end of file diff --git a/FadeBasic/install.sh b/FadeBasic/install.sh index 1053a60..cf2b782 100755 --- a/FadeBasic/install.sh +++ b/FadeBasic/install.sh @@ -2,10 +2,15 @@ # accept input parameters... VERSION=${1:-0.0.2} #0.0.2 is the development hack version -BUILD_NUMBER=${2:-1} +BUILD_NUMBER=${2:-1} PACKAGE_SOURCE=${3:-LocalFade} # in prod, should be https://nuget.org PACKAGE_SOURCE_API_KEY=${4} +SKIP_WASM=false +for arg in "$@"; do + case $arg in --skip-wasm) SKIP_WASM=true ;; esac +done + # the semantic version includes the build number as the fourth number. SEM_VER="${VERSION}.${BUILD_NUMBER}" @@ -30,29 +35,56 @@ PACK_ARGS="--output $OUTPUT_FOLDER /p:Version=$SEM_VER --include-symbols --inclu dotnet pack ./FadeBasic $PACK_ARGS dotnet pack ./FadeBasicCommands $PACK_ARGS dotnet pack ./FadeBasic.Lib.Standard $PACK_ARGS +dotnet pack ./FadeBasic.Lib.Web $PACK_ARGS dotnet pack ./ApplicationSupport $PACK_ARGS dotnet pack ./CommandSourceGenerator $PACK_ARGS dotnet pack ./Templates $PACK_ARGS dotnet pack ./FadeBuildTasks $PACK_ARGS +# FadeBasic.TestAdapter.dll is bundled inside FadeBasic.Testing.nupkg (see +# the ProjectReference + in FadeBasic.Testing.csproj). +# No separate adapter package — referencing FadeBasic.Testing alone gets both. +dotnet pack ./FadeBasic.Testing $PACK_ARGS + +if [ "$SKIP_WASM" = false ]; then + + WASM_ARTIFIACT_DIR="$PWD/bin/wasm_${SEM_VER}" + echo "publishing FadeBasic.Export.Web WASM bundle..." + # No --include-symbols/--include-source: FadeBasic.Export.Web is a content-only package. + #dotnet publish ./FadeBasic.Export.Web -c Release -o bin/wasm_t2 /p:IsPublish=true + dotnet publish ./FadeBasic.Export.Web -c Release -o $WASM_ARTIFIACT_DIR /p:IsPublish=true + dotnet pack ./FadeBasic.Export.Web --output "$OUTPUT_FOLDER" /p:Version=$SEM_VER -c Release /p:FADE_WASM_ARTIFACT_DIR=${WASM_ARTIFIACT_DIR} /p:IsPack=true +else + echo "skipping WASM build (--skip-wasm)" +fi + +# build the LSP and DAP once, then fan out the result to each editor extension +TOOLS_OUTPUT="bin/tools_${SEM_VER}" +rm -rf "$TOOLS_OUTPUT" +dotnet build ./LSP -o "$TOOLS_OUTPUT" -c Release +dotnet build ./DAP -o "$TOOLS_OUTPUT" -c Release -# build the LSP and DAP and store it in the associated vscode extension folder -dotnet build ./LSP -o ../VsCode/basicscript/out/tools -c Release -dotnet build ./DAP -o ../VsCode/basicscript/out/tools -c Release +for dest in ../VsCode/basicscript/out/tools ../Zed/fade-basic-zed/tools; do + mkdir -p "$dest" + cp -R "$TOOLS_OUTPUT"/. "$dest"/ +done if [ -z "$FADE_USE_LOCAL_SOURCE" ]; then if [ -z "$FADE_NUGET_DRYRUN" ]; then - # install nuget packages to source - echo "pushing packages, $OUTPUT_FOLDER/*.$BUILD_NUMBER.nupkg, to nuget source, ${PACKAGE_SOURCE}" - dotnet nuget push $OUTPUT_FOLDER/*.$BUILD_NUMBER.nupkg --source "$PACKAGE_SOURCE" $NUGET_KEY_STR - + echo "pushing packages to nuget source: ${PACKAGE_SOURCE}" + dotnet nuget push "$OUTPUT_FOLDER/*.$BUILD_NUMBER.nupkg" --source "$PACKAGE_SOURCE" $NUGET_KEY_STR + if [ "$SKIP_WASM" = false ]; then + dotnet nuget push "$OUTPUT_FOLDER/FadeBasic.Export.Web.$SEM_VER.nupkg" --source "$PACKAGE_SOURCE" $NUGET_KEY_STR + fi else echo "Skipping NuGet push because FADE_NUGET_DRYRUN is set." fi else - # install nuget packages to source echo "pushing fade to local!" dotnet nuget list source ./setup.sh dotnet nuget list source dotnet nuget push "$OUTPUT_FOLDER/*.$BUILD_NUMBER.nupkg" --source "LocalFade" + if [ "$SKIP_WASM" = false ]; then + dotnet nuget push "$OUTPUT_FOLDER/FadeBasic.Export.Web.$SEM_VER.nupkg" --source "LocalFade" + fi fi diff --git a/Playground/.gitignore b/Playground/.gitignore new file mode 100644 index 0000000..d49a0eb --- /dev/null +++ b/Playground/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +public/runtime +public/monogame-runtime +*.log +.vite diff --git a/Playground/README.md b/Playground/README.md new file mode 100644 index 0000000..8fd712b --- /dev/null +++ b/Playground/README.md @@ -0,0 +1,134 @@ +# Fade Playground + +Browser-based editor and runtime for FadeBasic. A static Vite SPA built +around Monaco / `@codingame/monaco-vscode-api` plus the FadeBasic +runtime compiled to WASM. + +## Local development + +```bash +cd Playground +npm install +npm run dev +``` + +`predev` builds the runtime and syncs docs into `public/`, so the first +boot takes a few seconds longer than subsequent ones. Dev server runs +on http://localhost:5311. + +## Production build + +```bash +npm run build +``` + +Outputs to `Playground/dist`. The runtime build and docs sync are +*not* run by `npm run build` — run them first if your local copies are +stale: + +```bash +npm run build:runtime +npm run sync:public-docs +npm run build +``` + +## Deploying to Cloudflare Pages + +The deployed site lives on Cloudflare Pages, project name +`fade-playground`. Two public URLs: + +- **Production** — https://fade-playground.pages.dev (deploys from + `--branch=main`) +- **Preview** — https://tests.fade-playground.pages.dev (deploys from + `--branch=tests`, used for in-progress work that isn't on `main` + yet) + +Each deploy also gets a permanent hash URL like +`https://e436d744.fade-playground.pages.dev` — useful for sharing a +specific build without overwriting the canonical one. + +### Deploy from your terminal + +`wrangler` is installed in [`../oauth-proxy/node_modules`](../oauth-proxy) +— there's no separate wrangler dependency in this package. The deploy +is a one-shot upload of `dist/` to Cloudflare Pages. + +```bash +# 1. From Playground/ — rebuild runtime, docs, and SPA. +npm run build:runtime +npm run sync:public-docs +npm run build + +# 2. From oauth-proxy/ — push dist/ to Cloudflare Pages. +cd ../oauth-proxy +npx wrangler pages deploy ../Playground/dist \ + --project-name=fade-playground \ + --commit-dirty=true \ + --branch=tests +``` + +Swap `--branch=tests` for `--branch=main` to publish to the production +URL. `--commit-dirty=true` silences the "your git tree is dirty" +warning so the upload doesn't refuse to run. + +### One-time setup + +If you've never deployed before: + +```bash +cd oauth-proxy +npx wrangler login # browser auth +npx wrangler pages project create fade-playground \ + --production-branch=main # only once +``` + +The project name and production-branch are baked into the Cloudflare +account; you don't need to re-run this on subsequent deploys. + +### CORS / OAuth proxy + +The Playground's GitHub auth uses the device-flow proxy in +[`../oauth-proxy`](../oauth-proxy). That worker enforces an origin +allow-list — `ALLOWED_ORIGINS` in +[`oauth-proxy/wrangler.toml`](../oauth-proxy/wrangler.toml). When you +add a new Pages URL (custom domain, new preview branch alias), append +it to that list and `npm run deploy` from `oauth-proxy/`, or device +flow will 403. + +Per-deploy hash URLs (`https://.fade-playground.pages.dev`) are +*not* in the allow-list. Use the branch alias URL for any flow that +hits the OAuth proxy. + +### CORS echo (`public/_worker.js`) + +The sandboxed MonoGame preview iframe ([src/monogame-host.ts](src/monogame-host.ts)) +runs without `allow-same-origin`, so its origin is the literal string +`"null"`. Blazor's `dotnet.js` then fetches `blazor.boot.json` and the +runtime `.wasm` modules with `credentials: 'include'`, and browsers +refuse credentialed responses with `Access-Control-Allow-Origin: *` +— which is what Cloudflare Pages serves static assets with by default. +[public/_worker.js](public/_worker.js) is the production-side +counterpart to the `cors-echo-origin` plugin in +[vite.config.ts](vite.config.ts): it echoes the request's `Origin` +back (including the literal `"null"`) and sets +`Access-Control-Allow-Credentials: true`, identical to the dev-server +behavior. Vite copies `public/` to `dist/` on every build, so the +worker ships automatically — no extra deploy step. + +### First-deploy SSL lag + +Cloudflare's universal `*.pages.dev` cert covers +`fade-playground.pages.dev` instantly, but the per-project wildcard +that covers `*.fade-playground.pages.dev` (branch aliases, per-deploy +hash URLs) is provisioned asynchronously after project creation. +Until that lands — typically 15 min, sometimes longer — Firefox will +report `SSL_ERROR_NO_CYPHER_OVERLAP` and curl will see an outright +handshake failure on those subdomain URLs. The root URL works the +whole time, so deploy with `--branch=main` if you need an +immediately-reachable build. + +### Size limits + +Cloudflare Pages caps individual files at 25 MiB and total files at +20,000 per deploy. The onnxruntime WASM is the largest single file +(~23.5 MiB) — keep an eye on it if upstream bumps the bundle. diff --git a/Playground/TESTING.md b/Playground/TESTING.md new file mode 100644 index 0000000..c196f3b --- /dev/null +++ b/Playground/TESTING.md @@ -0,0 +1,376 @@ +# Playground testing strategy + +This is how I keep the Playground stable while making non-trivial changes +to it. The headline: I drive a real Chromium against the live dev server +through Playwright, and assert against **observable, page-level state** — +DOM, Monaco models, the worker bridge — never against internal source +positions or stack snapshots. Each user-facing change ships with one or +two small probes in this style, and the existing suites stay green. + +## Why not unit tests? + +The Playground is dominated by integration concerns: WASM ↔ worker +↔ Monaco ↔ dockview ↔ OPFS. Most bugs live at those seams, not inside any +one module's logic. A unit test of `parseFadeProject()` catches almost +nothing real; a headless probe that types into the editor, edits +`fade.json`, and clicks the Run button catches the bug where the +project's source list isn't being honored at compile time. So the test +budget goes there. + +There's still a place for unit testing (the JSON path locator is a +candidate — pure function, lots of edge cases), but the default is +end-to-end through the live page. + +## The test runner + +Each suite is a single `.mjs` file under `scripts/`. It uses Playwright +to launch headless Chromium, points it at `http://localhost:5311/`, waits +for `window.__fadeBootstrapDone`, runs a list of `test(...)` cases +serially, and prints `OK/FAIL` per case + a final count. Exit code 0 on +success. + +``` +scripts/ + test-lsp.mjs ← Monaco-side LSP behavior (hover, completion, …) + test-dap.mjs ← Debug session (start, step, breakpoint, …) + test-tests-panel.mjs ← Tests panel UI (filter, run, failure jumps, …) + test-project.mjs ← fade.json + project source concat + badges + test-projects-overlay.mjs ← Project switcher overlay +``` + +Run a single suite: + +```sh +node scripts/test-project.mjs +``` + +Run them all (typical pre-merge sanity): + +```sh +node scripts/test-lsp.mjs && \ +node scripts/test-dap.mjs && \ +node scripts/test-tests-panel.mjs && \ +node scripts/test-project.mjs && \ +node scripts/test-projects-overlay.mjs +``` + +The dev server is expected to be already running on `:5311` (start it +with `npm run dev`). + +## The shape of a probe + +Every suite follows the same shape: + +```js +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ viewport: { width: 1500, height: 950 } }); +const page = await ctx.newPage(); + +// Optional: wipe OPFS so probes start from a known state. Project + overlay +// suites do this; LSP and DAP don't (they don't care about workspace state). +await page.goto('http://localhost:5311/', { waitUntil: 'domcontentloaded' }); +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry('workspace', { recursive: true }); } catch {} + localStorage.removeItem('fade.activeProject'); +}); + +// Reload twice. First reload kicks bootstrap; second is a "settling" pass +// that avoids Vite/HMR double-bootstrap noise. Always wait for the +// __fadeBootstrapDone flag the page sets at the end of bootstrap(). +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +await new Promise((r) => setTimeout(r, 1500)); // settling jitter + +const tests = []; +function test(name, fn) { tests.push({ name, fn }); } + +test('header shows the active project name', async () => { + const label = (await page.locator('#project-name').textContent()) || ''; + if (!label.trim()) throw new Error('project-name label is empty'); + return { label: label.trim() }; // return value is logged on OK +}); + +// … more tests … + +let passed = 0, failed = 0; +for (const t of tests) { + process.stdout.write(`• ${t.name} ... `); + try { + const r = await t.fn(); + console.log('OK', r ? JSON.stringify(r) : ''); + passed++; + } catch (e) { + console.log('FAIL\n ', e.message); + failed++; + } +} +await browser.close(); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); +``` + +A passing probe returns a small object that gets JSON-logged next to +`OK`. That's intentional — when a CI run prints `OK {"badges":[{"file": +"main.fbasic","text":"1","listed":true,"orphan":false}, …]}`, future me +can read the run log and *see* what the probe was actually asserting, +not just "it passed." Failures throw `new Error(...)` with a concrete +message including the unexpected value. + +Each test is one assertion of intent. They share a Page, so order +matters and side effects carry forward; embrace that, but don't rely on +order beyond what you can read in the file. + +## Driving the page + +There are three levers, in order of preference: + +### 1. Page-exposed test helpers (`window.__fade*`) + +Where possible, the page exposes a small typed surface for tests: + +| global | what | +|---|---| +| `window.monaco` | the full Monaco API | +| `window.__fadeBootstrapDone` | flips `true` once bootstrap completes | +| `window.__fadeDockview` | dockview API (panels, setActive, getPanel) | +| `window.__fadeRunnerHelpers` | direct worker calls — `listTests`, `runTests`, `project.getSource`, `debug.*` | +| `window.__fadeLspProbe(method, params)` | route a Monaco-bypassing LSP call to the worker | +| `window.__debugLastEvent` | last debug event the page received (poll-able) | +| `window.forceHardReset` | console-only OPFS wipe + reload | + +These let probes call the same code paths a real user would trigger, +without hand-driving DOM widgets that already work. Example: + +```js +const r = await page.evaluate(({ source }) => + window.__fadeRunnerHelpers.runTests({ source }), + { source: 'test foo\n assert 1 + 1 = 2\nendtest\n' }, +); +``` + +### 2. Monaco-level actions + +When the test cares about Monaco-side behavior (cursor placement, +hover widgets, semantic tokens, registered actions): + +```js +// seed the active model directly +await page.evaluate(({ src }) => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + ed.getModel().applyEdits([{ range: ed.getModel().getFullModelRange(), text: src }]); +}, { src }); + +// place cursor +await page.evaluate(({ line, col }) => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + ed.setPosition({ lineNumber: line, column: col }); + ed.focus(); +}, { line: 8, col: 1 }); + +// trigger a registered action (e.g. our context-menu Run Test at Cursor) +await page.evaluate(() => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + return ed.getAction('fade.runTestAtCursor').run(); +}); + +// inspect markers from the model +const markers = await page.evaluate(() => { + const uri = window.monaco.Uri.file('/workspace/fade.json'); + return window.monaco.editor.getModelMarkers({ resource: uri }).map((m) => ({ + owner: m.owner, severity: m.severity, message: m.message, line: m.startLineNumber, + })); +}); +``` + +### 3. DOM clicks for genuinely UI-level concerns + +For things the user *sees*, drive the DOM: + +```js +await page.locator('#new-file').click(); +await page.locator('#tests-search').fill('addsone'); +await page.locator('#project-list .project-row').first().click(); +``` + +Use these for click-targets, focus, visibility, and keyboard shortcuts. +Avoid them as a way to wire up business logic — the helpers above are +faster and far less flaky. + +## Handling JS dialogs (prompt / alert / confirm) + +The Playground uses native `prompt()`/`alert()` for New-File + a few +guards. Playwright intercepts those with `page.on('dialog', …)`. Two +patterns: + +```js +// Single dialog, single response: +page.once('dialog', (d) => d.accept('helper.fbasic')); +await page.locator('#new-file').click(); + +// Chain: prompt followed by an alert (e.g. fade.json refusal path) +const handler = async (d) => { + if (d.type() === 'prompt') await d.accept('fade.json'); + else { alertText = d.message(); await d.accept(); } +}; +page.on('dialog', handler); +await page.locator('#new-file').click(); +await new Promise((r) => setTimeout(r, 800)); +page.off('dialog', handler); +``` + +`page.once` fires for the *next* dialog and unbinds itself. If a second +dialog follows (as with the fade.json reject path: prompt → alert), use +`page.on` + `page.off` so you don't double-bind. + +## Waiting + +Three flavors, ordered by reliability: + +```js +// Best: poll for a specific page-level fact +await page.waitForFunction(() => + document.querySelectorAll('#tests-log .tests-log-line.fail').length > 0, + { timeout: 8000 }); + +// Good: wait for a selector to (dis)appear +await page.waitForSelector('#project-overlay:not([hidden])', { timeout: 3000 }); + +// Last resort: fixed-time settle +await new Promise((r) => setTimeout(r, 800)); +``` + +Fixed sleeps are tolerable for "let the debounced save timer flush" (we +have a 600ms one) and "let HMR settle on first boot". Anywhere else, +prefer `waitForFunction`/`waitForSelector` — they're the difference +between a flaky probe and a stable one. + +For hidden elements (which Playwright considers "not visible" by +default), use `waitForFunction` against the `hidden` attribute directly +rather than `waitForSelector('#x[hidden]')` — the latter requires +visibility. + +## Asserting + +- Read **page-observable values**: DOM text, attributes, `getModelMarkers()`, + `getModel().getValue()`, `getPosition()`, return values from worker + helpers. Never reach into module internals. +- Compare loosely on text, strictly on counts/positions: `/util\.fbasic/.test(label)` + is fine for headers, `markers.length !== 0` is fine, but cursor + position should be an exact line number. +- When asserting on Monaco markers, **filter by owner** — + `m.owner === 'fade-config'`, `m.owner === 'fade'` (LSP), etc. — so + cross-source diagnostics don't poison the assertion. +- Capture page errors during the part you care about and assert no + unwanted exceptions fired: + + ```js + const errs = []; + const handler = (e) => errs.push(e.message); + page.on('pageerror', handler); + /* do the thing */ + page.off('pageerror', handler); + if (errs.find((m) => /already exists/.test(m))) throw new Error(...); + ``` + +## OPFS hygiene + +Probes that exercise the project system should reset OPFS at the top so +prior runs don't bleed in. The shape: + +```js +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry('workspace', { recursive: true }); } catch {} + localStorage.removeItem('fade.activeProject'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +``` + +This is essentially what `window.forceHardReset()` does. Suites that +don't touch OPFS (LSP, DAP) skip this step and run faster. + +When the probe edits a file that needs to persist (e.g. `fade.json` +edits that need to land in OPFS before a reload), **open the file in a +tab first** so the page's `model.onDidChangeContent` save listener is +attached — without that, `model.applyEdits()` only changes the in-memory +model, never the OPFS file: + +```js +await page.locator('#file-list li[data-name="fade.json"]').click(); +await new Promise((r) => setTimeout(r, 200)); +await page.evaluate(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/fade.json')); + m.applyEdits([{ range: m.getFullModelRange(), text: '...' }]); +}); +await new Promise((r) => setTimeout(r, 1200)); // save-timer is 600ms; 1.2s is a safe buffer +``` + +Many of our "wait" calls are sized against the **page's 600ms +auto-save debounce**. If you bump that constant, bump the waits. + +## Snapshot screenshots + +Visual regressions don't get committed as fixtures (they rot fast and +diff badly across machines). When a feature is design-heavy (badges, +overlays, markdown preview, output panel styling), I keep a one-off +snapshot script in `scripts/_snap-*.mjs`, run it, eyeball the PNG, then +delete the script. The patterns inside are the same as a probe — set up +state, click around, `page.screenshot({ path: '/tmp/xyz.png' })`, +inspect manually. + +Leaving them in `scripts/` permanently turns into bit-rot. Treat them as +disposable. + +## What I do when something fails + +1. **Re-run alone first** — `node scripts/test-project.mjs` is cheap + and rules out cross-suite contamination. +2. **Re-run with `headless: false`** so I can watch what Playwright + actually does. Edit the suite's `chromium.launch({ headless: true })` + and re-run; the page opens and stops on the failing assertion. +3. **Run a diagnostic** — a one-off `scripts/_diag-*.mjs` that + reproduces just the failing step and dumps a structured object + (`JSON.stringify(state, null, 2)`). Delete when done. Several of + the bugs in this codebase were caught this way (e.g. the wrong-file- + opens-by-default bug surfaced via a diag script that printed the + active model URI alongside the visible `view-line` token classes). +4. **Add a probe** — once the fix lands, write a new `test(...)` case + in the relevant suite asserting the now-correct behavior. The probe + should fail against the broken code and pass against the fix; if it + passes both ways, it's not testing anything. + +## Adding a new suite + +Copy `scripts/test-project.mjs` as a template, change the URL/setup if +needed, replace the tests, run it. Don't try to share infrastructure +between suites until you have three of them — premature abstractions in +test code make the failure modes harder to read. + +## Bridge / WebRuntime changes + +Probes for behavior that depends on `WebRuntime/FadeBridge.cs` need a +fresh runtime build. `node scripts/build-runtime.mjs` runs +`dotnet publish` and copies the WASM bundle into +`public/runtime/`. If you change `FadeBridge.cs` and your tests don't +seem to pick up the change, you forgot this step. + +## Current test counts + +| Suite | Tests | +|---|---| +| `test-lsp.mjs` | 17 | +| `test-dap.mjs` | 11 | +| `test-tests-panel.mjs` | 5 | +| `test-project.mjs` | 30 | +| `test-projects-overlay.mjs` | 9 | +| `test-help.mjs` | 6 | +| **Total** | **78** | + +Keep this rough count + the suite list current when you add or move +files; it's a quick sanity check after a big change ("did I just delete +a probe by accident?"). diff --git a/Playground/docs/Playground.md b/Playground/docs/Playground.md new file mode 100644 index 0000000..a6a6bdb --- /dev/null +++ b/Playground/docs/Playground.md @@ -0,0 +1,54 @@ +# Playground + +This is the in-browser Fade development environment. It runs the Fade +compiler, LSP, and VM entirely client-side so you can write, debug, and +ship a project without installing anything. + +## Layout + +The page is organized into dockable panels. Drag a tab to rearrange, +double-click a tab to maximize, or close a panel from the **View** menu. + +- **Editor** — Monaco with the `fade` language: syntax highlighting, + diagnostics, hover docs, go-to-definition, find-references, rename. +- **Workspace** — files in your active project, plus per-project + `fade.json` manifest. Right-click for create/rename/delete. +- **Problems** — every diagnostic the LSP is currently reporting. +- **Help** — what you're reading now. Tabs at the top jump between this + Playground guide, the language reference, and the command catalog. +- **AI Chat** — chat-style assistant that can read your files, search + docs, and propose edits. +- **Game / Console** — runtime output. The Game panel is the canvas for + `monogame` projects; the Console panel collects `print` output for + any project. +- **Tests** — runs your `test` blocks via the same VM the game uses. +- **Debugger** — breakpoints, step over/in/out, evaluate expressions. + +## Projects + +Every project is a folder with a `fade.json` manifest, one or more +`.fbasic` sources, and an optional `commandDlls` list. Two templates +ship today: + +- **`web`** — pure FadeBasic with the `FadeBasic.Lib.Web` command set + (`prompt$`, `wait ms`, etc.). Renders nothing. +- **`monogame`** — FadeBasic plus the `Fade.MonoGame.Lib` commands + (`sprite`, `texture`, `sync`, …). The Game panel hosts the + MonoGame canvas. + +Switch the project type by editing `fade.json`'s `"type"` field. The +Help tab's command list and the AI's retrieved docs both follow the +active type. + +## Files persist locally + +Everything you create lives in the browser's OPFS storage. Closing the +tab keeps your work; clearing site data wipes it. Use the **Workspace** +panel's import / export actions to round-trip projects through `.zip`. + +## Sharing + +The **Share** button uploads the active project to a hosted gist-style +endpoint and copies a link. The recipient opens the link, the +Playground forks the project into their workspace, and they're editing +their own copy. diff --git a/Playground/index.html b/Playground/index.html new file mode 100644 index 0000000..f3bef3a --- /dev/null +++ b/Playground/index.html @@ -0,0 +1,5186 @@ + + + + + + Fade Land + + + + + + + +
+
+ + + +
+ View + +
+ + + + + Run (⌘R) + Stop + Debug (⌘D) + Export +
+ + + + + +
+
+ + + + + + + + + + + +
+ +
Loading FadeLand…
+
+ + + + + + diff --git a/Playground/mg.md b/Playground/mg.md new file mode 100644 index 0000000..42f3709 --- /dev/null +++ b/Playground/mg.md @@ -0,0 +1,341 @@ +# MonoGame Project Type — Plan + +Add a `'monogame'` project type to fade.json that runs `Fade.MonoGame` games +inside the web editor (and ships them as static bundles for itch.io). Goal: as +close to identical dev experience as possible between local desktop dev and +in-browser dev, so the same source survives both. + +## References + +- **Fade.MonoGame** (the desktop game framework we're porting): + `/Users/chrishanna/Documents/Github/Fade.MonoGame/Fade.MonoGame/Fade.MonoGame` + - `Fade.MonoGame.Lib/` — command library (sprites, audio, collision, render, + texture, input, tween, transform, text, math). + - `Fade.MonoGame.Game/` — `Game1` host, `GameReloader`, `DebugUISystem` + (2520 lines of ImGui — user-authored dev UI + engine inspectors). + - `Fade.MonoGame.csproj` — example user project; references the lib + game + via `` items, lists `.fbasic` files via ``. +- **XnaFiddle** — proves MonoGame-in-the-browser end-to-end; reference for + iframe lifecycle, URL-fragment sharing, GitHub Pages-style static deploy: + - + - +- **KNI** — nkast's MonoGame fork with a Blazor WebGL host. The only viable + browser-MonoGame today. XnaFiddle uses it; we will too. + +## Architecture + +Three pieces: + +1. **Playground** — adds a `'monogame'` value to `FadeProjectType`, a new "Game" + dockview panel containing a ``, and a publish action that zips the + runtime + project for itch.io. +2. **`WebRuntime.MonoGame`** — a second Blazor WASM project alongside the + existing `WebRuntime/`. Loaded inline on the page (same pattern as the + existing WebRuntime worker — `dotnet.create()` from a published `wwwroot/`, + page calls `[JSExport]` methods directly). Hosts KNI on a canvas, loads + `Fade.MonoGame.Lib` commands, runs the user's compiled bytecode in `Game1`. +3. **`Fade.MonoGame` (existing repo)** — multi-targeted via + `net10.0;net10.0-browser` so a single + source tree builds for both desktop and browser. Divergence handled by + `#if BROWSER` and TFM-conditional ``s. **No sibling + `*.Web` projects.** + +Two WASM runtimes co-exist in the same page: + +- The existing `WebRuntime/` keeps owning LSP / compilation / test discovery / + debug — runs in a Web Worker, off the main thread. +- The new `WebRuntime.MonoGame/` owns running the game — runs on the main + thread because the WebGL canvas + MonoGame's `Game.Run()` requestAnimationFrame + loop need main-thread access. + +Editor JS holds references to both. To run a monogame project: worker compiles +→ returns bytecode bytes to the page → page calls `FadeMonoGameBridge.LoadProgram(bytes)` +on the main-thread runtime. Direct function call, no postMessage at the +runtime boundary. + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ Playground (Vite + Monaco + dockview) — single document │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │ +│ │ Editor │ │ Tests/etc │ │ Game (canvas) │ │ +│ │ │ │ │ │ ┌────────────────────┐ │ │ +│ │ │ │ │ │ │ WebRuntime.MonoGame│ │ │ +│ │ │ │ │ │ │ (main-thread WASM) │ │ │ +│ │ │ │ │ │ │ KNI + Game1 │ │ │ +│ │ │ │ │ │ │ Fade.MonoGame.Lib │ │ │ +│ │ │ │ │ │ │ user bytecode │ │ │ +│ │ │ │ │ │ └────────────────────┘ │ │ +│ └──────┬──────┘ └──────┬──────┘ └────────────┬─────────────┘ │ +│ └────────┬───────┘ │ │ +│ ▼ │ JS calls │ +│ ┌────────────────┐ │ LoadProgram(), │ +│ │ WebRuntime │ worker postMessage │ Reset(), etc. │ +│ │ (worker WASM) │ compile + LSP │ │ +│ │ LSP/compile/ │ ▼ │ +│ │ tests/debug │ ┌────────────────┐ │ +│ └────────────────┘ │ Editor JS │ │ +│ ▲ │ (glue) │ │ +│ └─────────────────────┴────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +**Lifecycle:** +- Switch project within `'monogame'` type → call `Game1.ResetFade(newBytecode)`, + reuse the same KNI runtime + canvas. Already supported by `Game1`. +- Switch to a non-`'monogame'` project → hide the canvas, leave the runtime + warm. (Tearing KNI down cleanly is unverified; not worth the engineering + cost for v1.) +- Hard reset → reload the page. Cheap and rare. + +## Multi-target strategy for Fade.MonoGame + +```xml +net10.0;net10.0-browser +``` + +- TFM-conditional package references: + - `net10.0` → `MonoGame.Framework.DesktopGL`, `ImGui.NET`, content pipeline. + - `net10.0-browser` → `nkast.Xna.Framework.*` (KNI WebGL host). +- `#if BROWSER` guards: + - `DebugUISystem` (entire file) — no-op in browser for v1. + - `ImGuiRenderer` and the ImGui calls in `Game1`. + - `GameReloader.WatchFiles` (`FileSystemWatcher` doesn't exist in WASM). + - `ContentWatcher` (filesystem-based). + - `Microsoft.Xna.Framework.Content.Pipeline.Extra` references in `Game1`. +- User-facing dev-UI commands (`set ui slider`, etc.) keep their command + signatures in browser builds so user fbasic compiles — they just become + no-ops there. Documented as "in-game debug UI not yet available in web." + +**Pre-commit check:** verify KNI exposes types under +`Microsoft.Xna.Framework.*` namespaces (historically true). If KNI lives under +`nkast.Xna.Framework.*`, add a `#if BROWSER` `using` shim file; the multi-target +story still holds. + +## Phasing + +### Phase 0 — Schema + UI placeholder (hours) + +- [Playground/src/fade-config.ts:11](src/fade-config.ts#L11): add `'monogame'` + to `FadeProjectType`. Update `ALLOWED_TYPES`, `validateFadeProject`, + `defaultFadeProject`. +- Update `public/fade.schema.json` to mirror. +- Add a "Game" panel definition to dockview that renders a placeholder ("Game + runtime not yet built") when `fade.json.type === 'monogame'`. +- Project-create flow: offer "Web" vs "MonoGame" when creating a new project. + +### Phase 1 — KNI skeleton on the page (~1–2 weeks) + +Patterns borrowed from XnaFiddle's `XnaFiddle.BlazorGL`: + +- **JS-driven tick loop.** .NET does NOT own the game loop. JS owns + `requestAnimationFrame` and calls `instance.invokeMethod('TickDotNet')` + per frame. Wins: pause-when-hidden, FPS cap, 5s frame watchdog, hot-reload + without runtime teardown — all from JS. Requires decomposing `Game1` + (today: `game.Run()` is blocking) so per-tick work is a callable method. +- **KNI's nkast.Wasm.* JS shims** load as ` + + diff --git a/Playground/rag_files/monogame/FadeCommandDocs.md b/Playground/rag_files/monogame/FadeCommandDocs.md new file mode 100644 index 0000000..b2b0607 --- /dev/null +++ b/Playground/rag_files/monogame/FadeCommandDocs.md @@ -0,0 +1,6369 @@ +# FadeBasic Command Reference + +## FadeBasic.Lib.Standard.StandardCommands + +### rgb + +Creates a color with values for red, green, blue, and optionally alpha.Each value should be between 0 and 255. + +**Parameters** + +- `Byte` **r** - the red channel of the color. +- `Byte` **g** - the green channel of the color. +- `Byte` **b** - the blue channel of the color. +- `Byte` _(optional)_ **a** - the alpha channel of the color. By default, this will be 255, so it is fully opaque. + +**Returns** `Integer` - A single integer representing the color + +**Remarks** + +A few common color codes are, +- Red - (255, 0, 0) +- Salmon - (255, 128, 128) +- White - (255, 255, 255) + + + +The resulting integer is just a byte packed version of the four strings. It may be negative. + +--- + +### wait ms + +**Parameters** + +- `Integer` **arg1** + +--- + +### debug breakpoint + +This command only exists to help attach a C# debugger to the program.This command will halt execution until a C# debugger is attached to the execution host. + +**Parameters** + + +--- + +### test build + +**Returns** `Integer` + +--- + +### machine name$ + +**Parameters** + +- `String` _(ref)_ **arg1** + +--- + +### randomize + +**Parameters** + +- `Integer` **arg1** + +--- + +### rnd + +**Parameters** + +- `Integer` **arg1** + +**Returns** `Integer` + +--- + +### timer + +**Returns** `DoubleInteger` + +--- + +### inc + +**Parameters** + +- `Integer` _(ref)_ **arg1** +- `Integer` _(optional)_ **arg2** + +--- + +### dec + +**Parameters** + +- `Integer` _(ref)_ **arg1** +- `Integer` _(optional)_ **arg2** + +--- + +### upper$ + +**Parameters** + +- `String` **arg1** + +**Returns** `String` + +--- + +### lower$ + +**Parameters** + +- `String` **arg1** + +**Returns** `String` + +--- + +### right$ + +**Parameters** + +- `String` **arg1** +- `Integer` **arg2** + +**Returns** `String` + +--- + +### left$ + +**Parameters** + +- `String` **arg1** +- `Integer` **arg2** + +**Returns** `String` + +--- + +### mid$ + +**Parameters** + +- `String` **arg1** +- `Integer` **arg2** + +**Returns** `String` + +--- + +### chr$ + +**Parameters** + +- `Integer` **arg1** + +**Returns** `String` + +--- + +### str$ + +**Parameters** + +- `Integer` **arg1** + +**Returns** `String` + +--- + +### spaces$ + +**Parameters** + +- `Integer` **arg1** + +**Returns** `String` + +--- + +### val + +**Parameters** + +- `String` **arg1** + +**Returns** `Float` + +--- + +### asc + +**Parameters** + +- `String` **arg1** + +**Returns** `Integer` + +--- + +## Fade.MonoGame.Lib.FadeMonoGameCommands + +### push asset + +Pushes an asset file into the content build pipeline. + +This is a macro-time command. It runs during compilation, not at game runtime. + +**Parameters** + +- `String` **path** - The file path of the asset to add to the content build. + +**Examples** + +Push a texture asset so it is available at runtime: +``` +` push an image into the content pipeline +# push asset "Assets/Images/player-sprite-v2.png" +# rename asset "Images/Player" + ` later at runtime, load it by its renamed path +texture 1, "Images/Player" +sprite 1, 100, 100, 1 +``` + +Push a font asset for text rendering: +``` +` push a font into the content pipeline +# push asset "Assets/Fonts/MyFont.spritefont" +# rename asset "Fonts/Main" + ` later at runtime, load and use the font +font 1, "Fonts/Main" +text 1, 1, 100, 50, "Hello!" +``` + +**Remarks** + +Use this inside a macro block (lines prefixed with `#`) to tell the contentpipeline about an asset your game needs. The pipeline will process and pack it soit is available at runtime through commands like`texture`, `font`, or`load sfx clip`. After pushing, you can rename the asset with`rename asset` if the original filename is unwieldy.The push/rename pair is the most common macro pattern for setting up content. + +--- + +### rename asset + +Renames the most recently pushed asset in the content build pipeline. + +This is a macro-time command. It runs during compilation, not at game runtime.It operates on whatever `push asset` last added. + +**Parameters** + +- `String` **name** - The new content name for the asset. + +**Examples** + +Rename a pushed asset to a shorter, cleaner path: +``` +` push an audio file with a long filename and give it a short name +# push asset "Assets/Audio/bubble-pop-2-293341.mp3" +# rename asset "Audio/BubblePop" + ` at runtime, load using the short name +load sfx clip 1, "Audio/BubblePop" +``` + +Rename multiple assets in sequence: +``` +` push and rename several textures +# push asset "Assets/Images/enemy_spritesheet_final_v3.png" +# rename asset "Images/Enemy" +# push asset "Assets/Images/bg-tiles-large.png" +# rename asset "Images/Background" + ` at runtime, load them by their clean names +texture 1, "Images/Enemy" +texture 2, "Images/Background" +``` + +**Remarks** + +Call this right after `push asset` when the original filenameis too long, includes version numbers, or does not match the name you want to use inyour runtime code. The new name becomes the content path you pass to loadingcommands like `texture` or`load sfx clip`. + +--- + +### free sfx clip id + +Peeks at the next available sound effect clip ID without claiming it. + +This doesn't reserve the ID, so another call could grab it before you do. + +**Parameters** + +- `Integer` _(ref)_ **sfxClipId** - Receives the next free clip ID. + +**Returns** `Integer` - The next available clip ID (not yet reserved). + +**Examples** + +Peek at the next clip ID to see what it would be: +``` +` check what ID would be assigned next +nextClipId = free sfx clip id(nextClipId) +``` + +**Remarks** + +Most of the time you'll want `reserve sfx clip id`instead, which actually claims the slot. This is the "peek" half of the peek-vs-claimpattern. If you already know your ID, skip both and call`load sfx clip` directly. + +--- + +### reserve sfx clip id + +Claims the next available sound effect clip ID and initializes its slot. + +Use this when you need to wire up references before loading the actual audio data. + +**Parameters** + +- `Integer` _(ref)_ **sfxClipId** - Receives the reserved clip ID. + +**Returns** `Integer` - The newly reserved clip ID. + +**Examples** + +Reserve a clip ID, then load audio into it: +``` +` reserve a slot and load a sound effect clip +clipId = reserve sfx clip id(clipId) +load sfx clip clipId, "audio/laser" +``` + +**Remarks** + +The "claim" half of the peek-vs-claim pattern. After reserving, load the audio datawith `load sfx clip`. See also`free sfx clip id` if you only need to peek. + +--- + +### free sfx id + +Peeks at the next available sound effect instance ID without claiming it. + +This doesn't reserve the ID, so another call could grab it before you do. + +**Parameters** + +- `Integer` _(ref)_ **sfxId** - Receives the next free instance ID. + +**Returns** `Integer` - The next available instance ID (not yet reserved). + +**Examples** + +Peek at the next instance ID: +``` +` check what instance ID would be assigned next +nextSfxId = free sfx id(nextSfxId) +``` + +**Remarks** + +Most of the time you'll want `reserve sfx id`instead, which actually claims the slot. If you already know your ID, skip both andcall `sfx` directly. + +--- + +### reserve sfx id + +Claims the next available sound effect instance ID and initializes its slot. + +Use this when you need to wire up references before creating the actual instance. + +**Parameters** + +- `Integer` _(ref)_ **sfxId** - Receives the reserved instance ID. + +**Returns** `Integer` - The newly reserved instance ID. + +**Examples** + +Reserve an instance ID, then create the instance from a loaded clip: +``` +` reserve the instance slot first, then create it +mysfxId = reserve sfx id(mysfxId) +sfx mysfxId, clipId +``` + +**Remarks** + +The "claim" half of the peek-vs-claim pattern. After reserving, create the instancewith `sfx`. See also`free sfx id` if you only need to peek. + +--- + +### load sfx clip + +Loads a sound effect clip from the content pipeline. + +A clip is the raw audio data. Think of it as the sound file itself. Youneed to create an instance from it with `sfx`before you can actually play it. + +**Parameters** + +- `Integer` **clipId** - The clip ID to assign to the loaded sound. +- `String` **path** - Content path to the sound effect asset, relative to the Content directory. + +**Examples** + +Load a clip and create a playable instance from it: +``` +` load the explosion sound clip +clipId = 1 +load sfx clip clipId, "audio/explosion" + ` create an instance so we can play it +sfxId = 1 +sfx sfxId, clipId +play sfx sfxId +``` + +Load one clip and create multiple instances for overlapping playback: +``` +` load the gunshot clip once +gunClip = 1 +load sfx clip gunClip, "audio/gunshot" + ` create three instances so up to three can overlap +sfx 1, gunClip +sfx 2, gunClip +sfx 3, gunClip +``` + +**Remarks** + +Call this during setup. The content path is relative to the Content directory anddoesn't need a file extension. One clip can be used to create many instances, socreate one instance per concurrent playback you need. The typical audio setup is: load a clip here, create an instance with`sfx`, optionally configure pitch/pan/volume/loop,then call `play sfx` when you want to hear it. + +--- + +### sfx + +Creates a playable sound effect instance from a loaded clip. + +You need a separate instance for each concurrent playback of the same sound.If you want to play the same explosion sound three times overlapping, you need threeinstances. + +**Parameters** + +- `Integer` **sfxId** - The instance ID to assign to the new sound effect. +- `Integer` **clipId** - The clip ID of a previously loaded sound (from `load sfx clip`). + +**Examples** + +Full audio setup from clip to playback: +``` +` load the clip +clipId = 1 +load sfx clip clipId, "audio/laser" + ` create an instance and configure it +sfxId = 1 +sfx sfxId, clipId +set sfx volume sfxId, 0.8 +set sfx pitch sfxId, 0.2 + ` fire! +play sfx sfxId +``` + +Create multiple instances from one clip for overlapping sounds: +``` +` one clip, three instances +clipId = 1 +load sfx clip clipId, "audio/footstep" + sfx 10, clipId +sfx 11, clipId +sfx 12, clipId + ` randomize pitch slightly on each for variety +set sfx pitch 10, -0.1 +set sfx pitch 11, 0.0 +set sfx pitch 12, 0.1 +``` + +**Remarks** + +This is the second step in the audio setup pipeline: first you load a clip with`load sfx clip`, then you create one or moreinstances here. Each instance has its own pitch, pan, volume, and playback state. After creating an instance, configure it with`set sfx pitch`,`set sfx pan`,`set sfx volume`, and`set sfx loop`, then play it with`play sfx`. + +--- + +### pause sfx + +Pauses a playing sound effect. + +The sound stops where it is and can be resumed from that point by calling`play sfx` again. Note that `play sfx` restartsfrom the beginning, so pausing is mainly useful for stopping a sound temporarily. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect to pause. + +**Examples** + +Pause a looping ambient sound when the game pauses: +``` +` set up a looping wind sound +clipId = 1 +load sfx clip clipId, "audio/wind" +windSfx = 1 +sfx windSfx, clipId +set sfx loop windSfx, 1 +play sfx windSfx + ` later, when the game pauses +pause sfx windSfx + ` to resume, call play sfx again (restarts from beginning) +play sfx windSfx +``` + +**Remarks** + +A paused sound is different from a stopped one. `is sfx done`returns `0` for paused sounds (they're not "done", just on hold) but`1` for stopped sounds. + +--- + +### play sfx + +Plays a sound effect from the beginning. + +If the sound is already playing, it stops and restarts from the top. There is noway to layer the same instance on top of itself. Create multiple instances if youneed overlapping playback of the same sound. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect to play. + +**Examples** + +Basic playback: +``` +` load and create +clipId = 1 +load sfx clip clipId, "audio/coin" +coinSfx = 1 +sfx coinSfx, clipId + ` play the sound +play sfx coinSfx +``` + +Wait for a sound to finish before playing the next one: +``` +play sfx introSfx +DO +` wait each frame until the sound is done +LOOP UNTIL is sfx done(introSfx) = 1 +play sfx mainThemeSfx +``` + +**Remarks** + +This is the command that actually makes noise. You must have created the instancefirst with `sfx`. After calling this, you cancheck `is sfx done` to know when the sound has finished. For delayed playback, use `delay play sfx` instead. + +--- + +### delay play sfx + +Plays a sound effect after a delay in milliseconds. + +The delay is measured from the moment you call this command, using theinternal audio clock. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect to play. +- `Integer` **delayMs** - Delay in milliseconds before playback starts. + +**Examples** + +Stagger three impact sounds for a more natural collision: +``` +` play three impact sounds with slight offsets +delay play sfx impactSfx1, 0 +delay play sfx impactSfx2, 50 +delay play sfx impactSfx3, 120 +``` + +Play a warning beep one second from now: +``` +` schedule the beep for 1000 milliseconds in the future +delay play sfx warningSfx, 1000 +``` + +**Remarks** + +Use this to stagger sound effects for a more natural feel. For example, playingslightly offset impact sounds when multiple objects collide in the same frame. Thedelay runs on the audio system's own timer, not game frames, so it stays accurateregardless of frame rate. Like `play sfx`, this stops any current playback onthe instance before scheduling the delayed start. + +--- + +### set sfx pitch + +Sets the pitch of a sound effect instance. + +Values outside the `-1` to `1` range are clamped automatically, soyou will not get an error, but the value will not go beyond the limits. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect. +- `Float` **pitch** - Pitch shift, from `-1` (one octave down) to `1` (one octave up). `0` is normal. + +**Examples** + +Randomize pitch each time you play a footstep: +``` +` give each footstep a slightly different pitch +randomPitch = rnd(60) - 30 +randomPitch = randomPitch / 100.0 +set sfx pitch footstepSfx, randomPitch +play sfx footstepSfx +``` + +Pitch down an explosion for a heavy feel: +``` +set sfx pitch explosionSfx, -0.5 +play sfx explosionSfx +``` + +**Remarks** + +Pitch shifts the playback speed and frequency of the sound. A value of `0` isnormal speed, `-1` is one octave down (slower, deeper), and `1` is oneoctave up (faster, higher). Fractional values like `0.5` work fine forsubtle shifts. You can call this before or after `play sfx` and ittakes effect immediately either way. This is handy for randomizing pitch slightlyeach time you play a sound so it doesn't feel repetitive (e.g., footsteps, gunshots). Read the current value back with `sfx pitch`. + +--- + +### sfx pitch + +Returns the current pitch of a sound effect instance. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect. + +**Returns** `Float` - The current pitch value, from `-1` (one octave down) to `1` (one octave up). + +**Examples** + +Gradually raise the pitch of a rising siren each frame: +``` +` read current pitch and nudge it upward +currentPitch = sfx pitch(sirenSfx) +currentPitch = currentPitch + 0.01 +IF currentPitch > 1.0 THEN currentPitch = -1.0 +set sfx pitch sirenSfx, currentPitch +``` + +**Remarks** + +Use this to read back whatever was set with `set sfx pitch`.This is useful if you're adjusting pitch incrementally each frame. Grab the currentvalue, nudge it, and write it back. The returned value will always be in the`-1` to `1` range since `set sfx pitch` clampsits input. + +--- + +### set sfx pan + +Sets the stereo pan of a sound effect instance. + +Values outside the `-1` to `1` range are clamped automatically. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect. +- `Float` **pan** - Stereo position, from `-1` (full left) to `1` (full right). `0` is centered. + +**Examples** + +Pan a sound based on an enemy's screen position: +``` +` calculate pan from enemy X relative to screen center +screenW = screen width() +panValue = (enemyX - (screenW / 2)) / (screenW / 2) +set sfx pan enemySfx, panValue +``` + +Hard-pan a sound to the left speaker: +``` +set sfx pan leftChannelSfx, -1.0 +play sfx leftChannelSfx +``` + +**Remarks** + +Pan controls where the sound sits in the stereo field. `-1` is full left,`0` is centered, and `1` is full right. Use fractional values forsubtle positioning. For example, `-0.3` places the sound slightly leftof center. You can call this before or after `play sfx` and ittakes effect immediately. A common pattern is to update pan each frame based onwhere the sound source is relative to the player, giving a simple positionalaudio effect without a full 3D audio system. Read the current value back with `sfx pan`. + +--- + +### sfx pan + +Returns the current stereo pan of a sound effect instance. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect. + +**Returns** `Float` - The current pan value, from `-1` (full left) to `1` (full right). + +**Examples** + +Smoothly blend pan toward a target position each frame: +``` +` lerp the pan toward the target by 10% each frame +currentPan = sfx pan(engineSfx) +currentPan = currentPan + (targetPan - currentPan) * 0.1 +set sfx pan engineSfx, currentPan +``` + +**Remarks** + +Use this to read back whatever was set with `set sfx pan`.Handy if you're blending pan toward a target over time. Grab the current value,interpolate toward where you want it, and write it back with`set sfx pan`. The returned value will always be in the`-1` to `1` range. + +--- + +### set sfx volume + +Sets the volume of a sound effect instance. + +Values outside the `0` to `1` range are clamped automatically. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect. +- `Float` **volume** - Volume level, from `0` (silent) to `1` (full volume). + +**Examples** + +Fade out a sound over time each frame: +``` +` reduce volume by a small amount each frame +vol = sfx volume(mySfx) +vol = vol - 0.02 +IF vol < 0.0 THEN vol = 0.0 +set sfx volume mySfx, vol +``` + +Set a quiet background ambience at half volume: +``` +set sfx volume ambientSfx, 0.5 +set sfx loop ambientSfx, 1 +play sfx ambientSfx +``` + +**Remarks** + +Volume goes from `0` (completely silent) to `1` (full volume). There is noway to boost above `1`. If you need a sound to feel louder, you will need toadjust the source audio asset itself. You can call this before or after `play sfx` and ittakes effect immediately. This makes it easy to fade sounds in and out by adjustingvolume a little each frame. Read the current value back with `sfx volume`. + +--- + +### sfx volume + +Returns the current volume of a sound effect instance. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect. + +**Returns** `Float` - The current volume level, from `0` (silent) to `1` (full volume). + +**Examples** + +Fade in a sound from silence to full volume: +``` +` increase volume toward 1.0 each frame +vol = sfx volume(mySfx) +IF vol < 1.0 +vol = vol + 0.01 +set sfx volume mySfx, vol +ENDIF +``` + +**Remarks** + +Use this to read back whatever was set with `set sfx volume`.This is useful for fade-in and fade-out effects. Grab the current volume, adjust ittoward your target, and write it back with `set sfx volume`.The returned value will always be in the `0` to `1` range. + +--- + +### set sfx loop + +Sets whether a sound effect should loop continuously. + +When looping is enabled, the sound restarts from the beginning each time itreaches the end, and `is sfx done` will neverreturn `1` while it's playing. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect. +- `Boolean` **isLooped** - Pass `1` to loop, `0` to play once. + +**Examples** + +Set up a looping background ambience: +``` +` load and create the ambient loop +clipId = 1 +load sfx clip clipId, "audio/forest_ambience" +ambSfx = 1 +sfx ambSfx, clipId + ` enable looping and play at half volume +set sfx loop ambSfx, 1 +set sfx volume ambSfx, 0.5 +play sfx ambSfx +``` + +Stop a looping sound gracefully by letting it finish its current pass: +``` +` turn off looping so the sound plays to the end and stops +set sfx loop ambSfx, 0 +``` + +**Remarks** + +Set this before calling `play sfx` for the cleanestresults. You can also toggle it while a sound is already playing. Turning loop offmid-playback lets the sound finish its current pass and then stop naturally. Looping is great for ambient sounds, music loops, or engine hums, basically anything thatneeds to run indefinitely. When you're done with a looping sound, either call`pause sfx` to silence it or set loop back to `0`and let it finish on its own. + +--- + +### is sfx done + +Checks whether a sound effect has finished playing. + +A paused sound is not considered "done". Only a sound that has fully stopped(either it played to the end or was never started) returns `1`. + +**Parameters** + +- `Integer` **sfxId** - The instance ID of the sound effect to check. + +**Returns** `Boolean` - `1` if the sound effect has stopped, `0` if it's still playing or paused. + +**Examples** + +Wait for an intro jingle to finish, then start gameplay music: +``` +play sfx jingleSfx +DO +` keep looping until the jingle finishes +LOOP UNTIL is sfx done(jingleSfx) = 1 + ` now start the looping gameplay music +set sfx loop musicSfx, 1 +play sfx musicSfx +``` + +Trigger a visual effect when a sound finishes (called each frame): +``` +IF is sfx done(chargeSfx) = 1 +` the charge-up sound finished, fire the laser! +play sfx laserSfx +ENDIF +``` + +**Remarks** + +This is how you know when a one-shot sound has finished. Poll it each frame if youneed to trigger something when the sound ends. For example, you could play a follow-upsound or remove a visual effect that was synced to the audio. For looping sounds (set via `set sfx loop`), this willalways return `0` while they're playing, since they never reach a natural end.A sound that was paused with `pause sfx` also returns`0` because it's on hold, not done. + +--- + +### box collider + +Creates an axis-aligned box collider at the given position and size. + +The collider is static by default and will not move on its own. Attach itto a transform with `attach collider to transform`if you need it to follow a game object. + +**Parameters** + +- `Integer` **colliderId** - The ID to assign to this collider. +- `Integer` **x** - The X position of the collider's top-left corner. +- `Integer` **y** - The Y position of the collider's top-left corner. +- `Integer` **w** - The width of the collider in pixels. +- `Integer` **h** - The height of the collider in pixels. + +**Examples** + +Create a collider for a player character and attach it to a transform. +``` +` set up the player entity +playerId = 1 +transform playerId, 100, 200 +box collider playerId, 0, 0, 32, 32 +attach collider to transform playerId, playerId +``` + +Create a static wall collider that does not move. +``` +` place a wall at the bottom of the screen +wallId = 99 +box collider wallId, 0, 460, 640, 20 +``` + +**Remarks** + +Box colliders are the building blocks of Fade's collision system. You create them,optionally parent them to transforms, and then each frame you call`perform collider checks` to find out what's overlapping.After that, use `get collision` to query specific pairs. A typical setup for a game entity looks like this: create a transform with`transform`, create a sprite with[sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) and attach it via`attach sprite to transform`, then createa collider here and attach it with`attach collider to transform`. Now movingthe transform moves everything together. Collider positions are relative to their attached transform (if any). If you setx=`0`, y=`0` and attach to a transform, the collider sits at thetransform's origin. Offset x and y to shift it relative to that anchor point. There's no limit on the number of colliders you can create, but keep in mind that`perform collider checks` is an O(n^2) broad-phase, sohundreds of active colliders will start to cost you. + +--- + +### attach collider to transform + +Attaches a collider to a transform so it follows the transform's position each frame. + +Once attached, the collider's x and y become offsets relative to the transform rather than absolute screen positions. + +**Parameters** + +- `Integer` **colliderId** - The ID of the collider to attach. +- `Integer` **transformId** - The ID of the transform to follow. + +**Examples** + +Build a complete game entity with a transform, sprite, and collider. +``` +` create the entity's transform +enemyId = 5 +transform enemyId, 300, 100 + ` create and attach a sprite +sprite enemyId, 0, 0 +attach sprite to transform enemyId, enemyId + ` create and attach a collider +box collider enemyId, -16, -16, 32, 32 +attach collider to transform enemyId, enemyId + ` now moving the transform moves everything +set transform position enemyId, 400, 200 +``` + +**Remarks** + +This is how you make a collider stick to a moving game object. Without this, thecollider just sits wherever you placed it with`box collider`. The collision system reads thetransform's world position before doing its sweep each frame, so the colliderautomatically stays in sync. Pairs naturally with `attach sprite to transform`.The typical entity has a transform, a sprite attached to it, and a colliderattached to it. Move the transform and everything follows. + +--- + +### perform collider checks + +Runs the broad-phase collision sweep across all active colliders. + +You must call this once per frame before using`get collision`, or you'll be reading stalehit data from the previous frame. + +**Examples** + +A typical game loop that moves objects, sweeps collisions, then checks for hits. +``` +` set up a player and an enemy +playerId = 1 +enemyId = 2 +transform playerId, 100, 200 +transform enemyId, 300, 200 +box collider playerId, 0, 0, 32, 32 +box collider enemyId, 0, 0, 32, 32 +attach collider to transform playerId, playerId +attach collider to transform enemyId, enemyId + set sync rate 16 +DO +` move the player toward the enemy +px = get local transform x(playerId) +set transform position playerId, px + 1, 200 + ` sweep all colliders, then check for hits +perform collider checks +hit = get collision(playerId, enemyId) +IF hit = 1 THEN +print "collision detected!" +ENDIF + sync +LOOP +``` + +**Remarks** + +Collision detection in Fade works in two phases. First, you call this command tosweep all active colliders and build up the internal hit list. Then you queryspecific pairs with `get collision`. Thistwo-phase design means the expensive broad-phase only runs once per frame, nomatter how many pairs you check afterward. Call this once per frame in your `DO...LOOP`, after you've moved everythingbut before you check for hits. Calling it multiple times per frame is harmless butwasteful. Forgetting to call it means`get collision` will never see new overlaps. + +--- + +### get collision + +Checks whether two colliders are currently overlapping. + +You must call `perform collider checks` earlier inthe frame for this to return up-to-date results. Without that, you're reading stalehit data from the previous frame. + +**Parameters** + +- `Integer` **aColliderId** - The ID of the first collider. +- `Integer` **bColliderId** - The ID of the second collider. + +**Returns** `Boolean` - `1` if the two colliders are overlapping, `0` otherwise. + +**Examples** + +Check if a bullet hit any of three enemies. +``` +` assume bullet and enemy colliders are already set up +perform collider checks +FOR e = 1 TO 3 +hit = get collision(bulletId, e) +IF hit = 1 THEN +print "enemy hit!" +ENDIF +NEXT e +``` + +React to a player touching a pickup item. +``` +` inside the game loop, after perform collider checks +hit = get collision(playerId, coinId) +IF hit = 1 THEN +score = score + 10 +` move the coin off screen so it stops colliding +set transform position coinId, -100, -100 +ENDIF +``` + +**Remarks** + +This is the query side of Fade's two-phase collision system. After`perform collider checks` has done its sweep, call thisto ask about any specific pair of colliders. You can call it as many times as youwant per frame because the expensive work already happened in the sweep. The order of the two collider IDs does not matter. Checking (a, b) is the same aschecking (b, a). If either collider ID doesn't exist or hasn't been involved in any collision, thisreturns `0` rather than throwing an error. + +--- + +### print + +Prints one or more values to the console output. + +Each value is printed on its own line, so passing three values gives you three lines of output. + +**Parameters** + +- `any` **values** - One or more values of any type to print. Each value becomes its own line. + +**Examples** + +Print a simple message and a variable: +``` +` print a greeting and the player's score +score = 42 +print "hello world" +print score +``` + +Timestamp debug output with `game ms`: +``` +set sync rate 16 +DO +t = game ms() +print t +sync +LOOP +``` + +**Remarks** + +This is your go-to debug command. You can call it from macros or at runtime(it works in both contexts), which makes it handy for inspecting values duringcompilation as well as while the game is running. Since it writes to the console, you won't see anything if your game doesn't havea console window attached. It pairs naturally with`game ms` if you want to timestamp your debug output,and with `test` when you just need to dump a single int quickly. + +--- + +### game ms + +Returns the total elapsed game time in milliseconds. + +This keeps ticking regardless of what your script is doing. It reflects wall-clock time since the game started, not script time. + +**Returns** `DoubleFloat` - Total game time in milliseconds. + +**Examples** + +Use game time to move a sprite smoothly across the screen: +``` +` move a sprite based on elapsed time +set sync rate 16 +texture 1, "Images/Ship" +sprite 1, 0, 100, 1 +DO +t = game ms() +x = t / 10 +sprite 1, x, 100, 1 +sync +LOOP +``` + +Build a simple countdown timer: +``` +` count down from 5 seconds +set sync rate 16 +startTime = game ms() +DO +elapsed = game ms() - startTime +remaining = 5000 - elapsed +IF remaining < 0 THEN remaining = 0 +print remaining +sync +LOOP +``` + +**Remarks** + +Call this every frame (after `sync`) when youneed to drive animations, timers, or custom tweens by real elapsed time instead offrame counts. Because it is millisecond-resolution, you can do smooth interpolationwithout worrying about frame-rate jitter. If you only need to know how many frames have passed, use`frame number` instead. And if you are building a tween thatuses angles, the trig helpers like [sin](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sin) and[cos](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Cos) pair well with a time value converted to radians. + +--- + +### begin debug window + +**Parameters** + +- `String` **arg2** + +--- + +### end debug window + +**Parameters** + + +--- + +### debug same line + +**Parameters** + + +--- + +### debug separator + +**Parameters** + + +--- + +### begin debug tree + +**Parameters** + +- `String` **arg2** + +**Returns** `Integer` + +--- + +### end debug tree + +**Parameters** + + +--- + +### begin debug tab bar + +**Parameters** + +- `String` **arg2** + +**Returns** `Integer` + +--- + +### end debug tab bar + +**Parameters** + + +--- + +### begin debug tab + +**Parameters** + +- `String` **arg2** + +**Returns** `Integer` + +--- + +### end debug tab + +**Parameters** + + +--- + +### debug label + +**Parameters** + +- `String` **arg2** +- `String` **arg3** + +--- + +### debug text + +**Parameters** + +- `String` **arg2** + +--- + +### debug button + +**Parameters** + +- `String` **arg2** + +**Returns** `Integer` + +--- + +### debug toggle + +**Parameters** + +- `String` **arg2** +- `Integer` _(ref)_ **arg3** + +**Returns** `Integer` + +--- + +### debug textbox + +**Parameters** + +- `String` **arg2** +- `String` _(ref)_ **arg3** +- `String` _(optional)_ **arg4** +- `Integer` _(optional)_ **arg5** + +**Returns** `Integer` + +--- + +### debug int slider + +**Parameters** + +- `String` **arg2** +- `Integer` _(ref)_ **arg3** +- `Integer` _(optional)_ **arg4** +- `Integer` _(optional)_ **arg5** + +**Returns** `Integer` + +--- + +### debug float slider + +**Parameters** + +- `String` **arg2** +- `Float` _(ref)_ **arg3** +- `Float` _(optional)_ **arg4** +- `Float` _(optional)_ **arg5** + +**Returns** `Integer` + +--- + +### debug drag int + +**Parameters** + +- `String` **arg2** +- `Integer` _(ref)_ **arg3** + +**Returns** `Integer` + +--- + +### debug drag float + +**Parameters** + +- `String` **arg2** +- `Float` _(ref)_ **arg3** + +**Returns** `Integer` + +--- + +### debug color picker + +**Parameters** + +- `String` **arg2** +- `Integer` _(ref)_ **arg3** + +**Returns** `Integer` + +--- + +### enable debug inspector + +--- + +### disable debug inspector + +--- + +### debug browse sprites + +**Parameters** + + +--- + +### debug browse effects + +**Parameters** + + +--- + +### debug browse transforms + +**Parameters** + + +--- + +### debug browse tweens + +**Parameters** + + +--- + +### debug browse colliders + +**Parameters** + + +--- + +### debug browse texts + +**Parameters** + + +--- + +### debug browse sfx + +**Parameters** + + +--- + +### debug browse textures + +**Parameters** + + +--- + +### debug browse render outputs + +**Parameters** + + +--- + +### debug console + +**Parameters** + + +--- + +### debug inspector + +**Parameters** + + +--- + +### debug metadata + +**Parameters** + + +--- + +### debug sprite + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### debug effect + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### debug transform + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### debug tween + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### debug collider + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### debug text sprite + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### debug sfx + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### debug texture + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### debug render output + +**Parameters** + +- `Integer` **arg2** + +**Returns** `Integer` + +--- + +### mouse x + +Returns the mouse X position in render-buffer coordinates. + +This accounts for any offset or scaling between the OS window and the actualrender area, so you always get coordinates that match your game's internal resolution. + +**Returns** `Integer` - The mouse X position in render-space pixels. + +**Examples** + +Track the mouse and position a cursor sprite on it each frame: +``` +` load a cursor texture and create a sprite for it +texture 1, "Images/Cursor" +sprite 1, 0, 0, 1 + DO +mx = mouse x() +my = mouse y() +sprite 1, mx, my, 1 +sync +LOOP +``` + +**Remarks** + +If your window size and render size differ (e.g., a 320x240 render buffer in an800x600 window), the mouse position is automatically mapped into render space. Thismeans you can compare the result directly against sprite positions without doing anymath yourself. Read this every frame after `sync` to get freshinput. Pairs with `mouse y` to get the full cursor position. + +--- + +### mouse y + +Returns the mouse Y position in render-buffer coordinates. + +This accounts for any offset or scaling between the OS window and the actualrender area, so you always get coordinates that match your game's internal resolution. + +**Returns** `Integer` - The mouse Y position in render-space pixels. + +**Examples** + +Check if the mouse is inside a rectangular region: +``` +` define a button area +btnX = 100 +btnY = 200 +btnW = 120 +btnH = 40 + DO +mx = mouse x() +my = mouse y() + ` check if mouse is inside the button +IF mx >= btnX AND mx <= btnX + btnW +IF my >= btnY AND my <= btnY + btnH +text 10, 10, "Hovering over button!" +ENDIF +ENDIF + sync +LOOP +``` + +**Remarks** + +If your window size and render size differ, the mouse position is automaticallymapped into render space. This means you can compare the result directly againstsprite positions without doing any math yourself. Read this every frame after `sync` to get freshinput. Pairs with `mouse x` to get the full cursor position. + +--- + +### left click + +Returns `1` while the left mouse button is held down. + +This fires every frame the button is pressed, not just the first one. Use`new left click` if you only want to detect theinitial press. + +**Returns** `Boolean` - `1` while the left button is pressed, `0` otherwise. + +**Examples** + +Draw a trail of dots while the player holds the left mouse button: +``` +DO +IF left click() = 1 +mx = mouse x() +my = mouse y() +dot mx, my +ENDIF +sync +LOOP +``` + +Hold the left button to charge a power meter: +``` +power = 0 +maxPower = 100 + DO +IF left click() = 1 +IF power < maxPower +power = power + 1 +ENDIF +ELSE +power = 0 +ENDIF + text 10, 10, "Power: " + str$(power) +sync +LOOP +``` + +**Remarks** + +Good for continuous actions like dragging, holding to charge, or painting. If youneed a one-shot click (e.g., pressing a button in a menu), use`new left click` instead, because otherwise theaction will fire every frame the player holds the button. + +--- + +### new left click + +Returns `1` only on the first frame the left mouse button is pressed. + +After that first frame it returns `0`, even if the player keepsholding the button. The player must release and press again to trigger it. + +**Returns** `Boolean` - `1` on the frame the left button transitioned from released to pressed. + +**Examples** + +Click a button to start the game: +``` +btnX = 100 +btnY = 200 +btnW = 120 +btnH = 40 +started = 0 + DO +mx = mouse x() +my = mouse y() + IF started = 0 +text btnX + 10, btnY + 10, "Start Game" + ` only fires once per click, so we won't skip frames +IF new left click() = 1 +IF mx >= btnX AND mx <= btnX + btnW +IF my >= btnY AND my <= btnY + btnH +started = 1 +ENDIF +ENDIF +ENDIF +ELSE +text 10, 10, "Game is running!" +ENDIF + sync +LOOP +``` + +**Remarks** + +This is edge detection: it fires once per press, not continuously. Use this fordiscrete actions like clicking a menu button, selecting a tile, or firing a singleshot. If you need to detect a held button (e.g., dragging), use`left click` instead. + +--- + +### right click + +Returns `1` while the right mouse button is held down. + +This fires every frame the button is pressed. There is currently no`new right click` command, so use`new key down` with the right mouse scan code ifyou need edge detection for the right button. + +**Returns** `Boolean` - `1` while the right button is pressed, `0` otherwise. + +**Examples** + +Use right click to place a waypoint at the mouse position: +``` +wpX = 0 +wpY = 0 +hasWaypoint = 0 + DO +IF right click() = 1 +wpX = mouse x() +wpY = mouse y() +hasWaypoint = 1 +ENDIF + IF hasWaypoint = 1 +text wpX, wpY, "X" +ENDIF + sync +LOOP +``` + +**Remarks** + +Works the same as `left click` but for the right button.Good for secondary actions like context menus, alternate fire, or camera controls. + +--- + +### upkey + +Returns `1` if the up arrow key is currently held down, `0` otherwise. + +This is a convenience wrapper. For a more general approach, use`key down` with[scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode) to check any key. + +**Returns** `Integer` - `1` if the up arrow is pressed, `0` otherwise. + +**Examples** + +Move a sprite up and down with the arrow keys: +``` +` load a player texture and create a sprite for it +texture 1, "Images/Player" +sprite 1, 160, 120, 1 +px = 160 +py = 120 +speed = 3 + DO +` subtract upkey to move up, add downkey to move down +py = py - upkey() * speed +py = py + downkey() * speed + sprite 1, px, py, 1 +sync +LOOP +``` + +**Remarks** + +You can use the result directly in arithmetic (e.g., multiply it by a speed value).The "new" variant `new upkey` fires only on the first frame. + +--- + +### downkey + +Returns `1` if the down arrow key is currently held down, `0` otherwise. + +This is a convenience wrapper. For a more general approach, use`key down` with[scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode) to check any key. + +**Returns** `Integer` - `1` if the down arrow is pressed, `0` otherwise. + +**Examples** + +Scroll a camera offset down while the key is held: +``` +camY = 0 +scrollSpeed = 2 + DO +camY = camY + downkey() * scrollSpeed +camY = camY - upkey() * scrollSpeed + text 10, 10, "Camera Y: " + str$(camY) +sync +LOOP +``` + +**Remarks** + +Pairs with [upkey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/upKey)for vertical movement. The "new" variant `new downkey`fires only on the first frame. + +--- + +### rightKey + +Returns `1` if the right arrow key is currently held down, `0` otherwise. + +This is a convenience wrapper. For a more general approach, use`key down` with[scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode) to check any key. + +**Returns** `Integer` - `1` if the right arrow is pressed, `0` otherwise. + +**Examples** + +Move a character left and right with arrow keys: +``` +px = 160 +speed = 4 + DO +px = px + rightKey() * speed +px = px - leftKey() * speed + text px, 120, "@" +sync +LOOP +``` + +**Remarks** + +Pairs with [leftKey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/leftKey)for horizontal movement. The "new" variant `new rightKey`fires only on the first frame. + +--- + +### leftKey + +Returns `1` if the left arrow key is currently held down, `0` otherwise. + +This is a convenience wrapper. For a more general approach, use`key down` with[scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode) to check any key. + +**Returns** `Integer` - `1` if the left arrow is pressed, `0` otherwise. + +**Examples** + +Full four-direction movement using all arrow keys: +``` +` load a player texture and create a sprite for it +texture 1, "Images/Player" +sprite 1, 160, 120, 1 +px = 160 +py = 120 +speed = 3 + DO +px = px + rightKey() * speed +px = px - leftKey() * speed +py = py + downkey() * speed +py = py - upkey() * speed + sprite 1, px, py, 1 +sync +LOOP +``` + +**Remarks** + +Pairs with [rightKey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/rightKey)for horizontal movement. The "new" variant `new leftKey`fires only on the first frame. + +--- + +### spaceKey + +Returns `1` if the space bar is currently held down, `0` otherwise. + +This is a convenience wrapper. For a more general approach, use`key down` with[scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode) to check any key. + +**Returns** `Integer` - `1` if space is pressed, `0` otherwise. + +**Examples** + +Hold space to boost speed: +``` +px = 0 +baseSpeed = 2 +boostSpeed = 6 + DO +` pick speed based on whether space is held +IF spaceKey() = 1 +speed = boostSpeed +ELSE +speed = baseSpeed +ENDIF + px = px + rightKey() * speed +px = px - leftKey() * speed + text px, 120, ">" +sync +LOOP +``` + +**Remarks** + +The "new" variant`new spaceKey` fires only on the first frame. + +--- + +### new upkey + +Returns `1` only on the first frame the up arrow is pressed. + +After that first frame it returns `0`, even if the key is still held.The player must release and press again to trigger it. + +**Returns** `Boolean` - `1` on the frame the up arrow transitioned from released to pressed. + +**Examples** + +Navigate a menu with up and down arrow keys (one step per press): +``` +menuIndex = 0 +menuCount = 3 + DO +` move selection up +IF new upkey() = 1 +menuIndex = menuIndex - 1 +IF menuIndex < 0 +menuIndex = menuCount - 1 +ENDIF +ENDIF + ` move selection down +IF new downkey() = 1 +menuIndex = menuIndex + 1 +IF menuIndex >= menuCount +menuIndex = 0 +ENDIF +ENDIF + ` draw menu items +FOR i = 0 TO menuCount - 1 +IF i = menuIndex +text 20, 40 + i * 20, "> Option " + str$(i) +ELSE +text 20, 40 + i * 20, " Option " + str$(i) +ENDIF +NEXT i + sync +LOOP +``` + +**Remarks** + +Edge detection variant of [upkey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/upKey). Use this for discreteactions like menu navigation where you want one step per press, not continuousscrolling. For the general-purpose version, use`new key down` with a scan code. + +--- + +### new downkey + +Returns `1` only on the first frame the down arrow is pressed. + +After that first frame it returns `0`, even if the key is still held. + +**Returns** `Boolean` - `1` on the frame the down arrow transitioned from released to pressed. + +**Examples** + +Step through a list of items one at a time: +``` +selected = 0 +total = 5 + DO +IF new downkey() = 1 +IF selected < total - 1 +selected = selected + 1 +ENDIF +ENDIF + text 10, 10, "Selected: " + str$(selected) + " of " + str$(total) +sync +LOOP +``` + +**Remarks** + +Edge detection variant of [downkey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/downKey). Pairs with`new upkey` for menu navigation. For the general-purposeversion, use `new key down` with a scan code. + +--- + +### new rightKey + +Returns `1` only on the first frame the right arrow is pressed. + +After that first frame it returns `0`, even if the key is still held. + +**Returns** `Boolean` - `1` on the frame the right arrow transitioned from released to pressed. + +**Examples** + +Cycle through tabs with left and right arrows: +``` +tab = 0 +tabCount = 4 + DO +IF new rightKey() = 1 +tab = tab + 1 +IF tab >= tabCount +tab = 0 +ENDIF +ENDIF + IF new leftKey() = 1 +tab = tab - 1 +IF tab < 0 +tab = tabCount - 1 +ENDIF +ENDIF + text 10, 10, "Tab: " + str$(tab) +sync +LOOP +``` + +**Remarks** + +Edge detection variant of [rightKey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/rightKey). Pairs with`new leftKey` for horizontal menu navigation. For thegeneral-purpose version, use `new key down` with ascan code. + +--- + +### new leftKey + +Returns `1` only on the first frame the left arrow is pressed. + +After that first frame it returns `0`, even if the key is still held. + +**Returns** `Boolean` - `1` on the frame the left arrow transitioned from released to pressed. + +**Examples** + +Go back one page in a book viewer: +``` +page = 0 +maxPage = 10 + DO +IF new leftKey() = 1 +IF page > 0 +page = page - 1 +ENDIF +ENDIF + IF new rightKey() = 1 +IF page < maxPage +page = page + 1 +ENDIF +ENDIF + text 10, 10, "Page " + str$(page) + " of " + str$(maxPage) +sync +LOOP +``` + +**Remarks** + +Edge detection variant of [leftKey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/leftKey). Pairs with`new rightKey` for horizontal menu navigation. For thegeneral-purpose version, use `new key down` with ascan code. + +--- + +### new spaceKey + +Returns `1` only on the first frame the space bar is pressed. + +After that first frame it returns `0`, even if the key is still held. + +**Returns** `Boolean` - `1` on the frame the space bar transitioned from released to pressed. + +**Examples** + +Press space to jump (one jump per press): +``` +py = 200 +vy = 0 +gravity = 1 +ground = 200 + DO +` start a jump only on the first frame space is pressed +IF new spaceKey() = 1 +IF py >= ground +vy = -12 +ENDIF +ENDIF + ` apply gravity +vy = vy + gravity +py = py + vy + ` land on the ground +IF py > ground +py = ground +vy = 0 +ENDIF + text 160, py, "O" +sync +LOOP +``` + +**Remarks** + +Edge detection variant of [spaceKey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/spaceKey). Use this for actionslike jumping or confirming a selection where you want one action per press. For thegeneral-purpose version, use `new key down` with ascan code. + +--- + +### new key down + +Returns `1` only on the first frame a key is pressed. + +This is the general-purpose edge detection command. It works with any keyvia its scan code. The convenience wrappers like `new upkey`call this under the hood. + +**Parameters** + +- `Integer` **scanCode** - The scan code of the key. Use [scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode) to convert a name like `"Space"` to its code. + +**Returns** `Boolean` - `1` on the frame the key transitioned from released to pressed. + +**Examples** + +Press E to interact with something: +``` +` get the scan code for E once at startup +eKey = scanCode("E") + DO +IF new key down(eKey) = 1 +text 10, 10, "Interacted!" +ENDIF +sync +LOOP +``` + +Press Escape to toggle a pause menu: +``` +escKey = scanCode("Escape") +paused = 0 + DO +IF new key down(escKey) = 1 +IF paused = 0 +paused = 1 +ELSE +paused = 0 +ENDIF +ENDIF + IF paused = 1 +text 100, 100, "PAUSED" +ELSE +text 100, 100, "Playing..." +ENDIF + sync +LOOP +``` + +**Remarks** + +Use this when you need to detect a fresh press for a key that doesn't have its ownconvenience command. Get the scan code with [scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode),for example, `scanCode("A")` gives you the code for the A key. This detects the transition from released to pressed. Once the key is held, itreturns `0` on subsequent frames. The player has to release and press againto trigger it. For continuous held-key detection, use`key down` instead. + +--- + +### key down + +Returns `1` while a key is held down. + +This fires every frame the key is pressed, not just the first one. Use`new key down` if you only want the initial press. + +**Parameters** + +- `Integer` **scanCode** - The scan code of the key. Use [scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode) to convert a name to its code. + +**Returns** `Boolean` - `1` while the key is pressed, `0` otherwise. + +**Examples** + +WASD movement using scan codes: +``` +` look up scan codes once at startup +wKey = scanCode("W") +aKey = scanCode("A") +sKey = scanCode("S") +dKey = scanCode("D") + px = 160 +py = 120 +speed = 3 + DO +py = py - key down(wKey) * speed +py = py + key down(sKey) * speed +px = px - key down(aKey) * speed +px = px + key down(dKey) * speed + text px, py, "@" +sync +LOOP +``` + +Hold shift to sprint: +``` +shiftKey = scanCode("LeftShift") +px = 0 + DO +IF key down(shiftKey) = 1 +speed = 6 +ELSE +speed = 2 +ENDIF + px = px + rightKey() * speed +px = px - leftKey() * speed + text px, 120, ">" +sync +LOOP +``` + +**Remarks** + +This is the general-purpose held-key detection command. It works with any key viaits scan code. Get the code with [scanCode](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/ScanCode), for example,`scanCode("LeftShift")` for the left shift key. Good for continuous actions like movement, sprinting, or camera control where youwant the action to keep going as long as the key is held. The convenience wrapperslike [upkey](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/upKey) do the same thing but are limited to specific keys. + +--- + +### scanCode + +Converts a key name string to its integer scan code. + +Pass the result to `key down` or`new key down` to check that key's state. + +**Parameters** + +- `String` **key** - The name of the key. Must match a MonoGame `Keys` value (e.g., `"A"`, `"Space"`, `"LeftShift"`). + +**Returns** `Integer` - The integer scan code for the given key. + +**Examples** + +Store scan codes at startup and use them in the game loop: +``` +` resolve scan codes once +jumpKey = scanCode("Space") +shootKey = scanCode("Z") +pauseKey = scanCode("Escape") + DO +IF new key down(jumpKey) = 1 +text 10, 10, "Jump!" +ENDIF + IF key down(shootKey) = 1 +text 10, 30, "Shooting..." +ENDIF + IF new key down(pauseKey) = 1 +text 10, 50, "Paused" +ENDIF + sync +LOOP +``` + +Check number keys to select inventory slots: +``` +` D1 through D9 are the number row keys +FOR i = 1 TO 9 +slotKey(i) = scanCode("D" + str$(i)) +NEXT i + slot = 1 + DO +FOR i = 1 TO 9 +IF new key down(slotKey(i)) = 1 +slot = i +ENDIF +NEXT i + text 10, 10, "Active slot: " + str$(slot) +sync +LOOP +``` + +**Remarks** + +The key name must match one of the MonoGame `Keys` enum values. Commonexamples: `"A"` through `"Z"`, `"D0"` through `"D9"` fornumber keys, `"Space"`, `"Enter"`, `"LeftShift"`, `"Escape"`,`"Tab"`. You typically call this once during setup and store the result in a variable, ratherthan converting the string every frame. The scan code does not change at runtime. + +--- + +### sin + +Returns the sine of the given angle. + +The angle must be in radians. Use [rad](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Rad) to convert from degrees first if needed. + +**Parameters** + +- `Float` **x** - The angle in radians. + +**Returns** `Float` - The sine of the angle, in the range `-1.0` to `1.0`. + +**Examples** + +Move a sprite up and down in a wave pattern using [sin](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sin). +``` +` bob a sprite up and down over time +t = 0 +baseY = 200 +DO +t = t + 0.05 +y = baseY + sin(t) * 30 +draw_sprite 1, 100, y +LOOP +``` + +Move in a circle using both [sin](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sin) and [cos](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Cos). +``` +` orbit a point around a center +angle = 0 +cx = 320 +cy = 240 +radius = 80 +DO +angle = angle + 0.02 +x = cx + cos(angle) * radius +y = cy + sin(angle) * radius +draw_sprite 1, x, y +LOOP +``` + +**Remarks** + +Standard trig helper. You'll use this alongside [cos](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Cos) forcircular motion, wave effects, and oscillation. If you have an angle from[atan2](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Atan2), you can feed it straight in here since it'salready in radians. Passing values outside 0..2*pi is fine. It wraps naturally. + +--- + +### cos + +Returns the cosine of the given angle. + +The angle must be in radians. Use [rad](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Rad) to convert from degrees first if needed. + +**Parameters** + +- `Float` **x** - The angle in radians. + +**Returns** `Float` - The cosine of the angle, in the range `-1.0` to `1.0`. + +**Examples** + +Place 8 items evenly around a circle. +``` +` arrange 8 sprites in a ring +cx = 320 +cy = 240 +radius = 100 +count = 8 +FOR i = 0 TO count - 1 +angle = rad(360 / count * i) +x = cx + cos(angle) * radius +y = cy + sin(angle) * radius +draw_sprite i + 1, x, y +NEXT i +``` + +Scale movement speed by facing direction. +``` +` move forward in the direction the player is facing +facing = rad(45) +speed = 3 +px = px + cos(facing) * speed +py = py + sin(facing) * speed +``` + +**Remarks** + +Pairs with [sin](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sin) for circular motion and positioning.A common pattern is `x = cos(angle) * radius` and `y = sin(angle) * radius`to place things on a circle. Like all the trig functions here, values outside 0..2*pi wrap naturally. + +--- + +### atan2 + +Returns the angle (in radians) whose tangent is /. + +Unlike [atan](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Atan), this takes both components so it returns the correct quadrant every time. + +**Parameters** + +- `Float` **y** - The y component of the direction vector. +- `Float` **x** - The x component of the direction vector. + +**Returns** `Float` - The angle in radians, in the range `-pi` to `pi`. + +**Examples** + +Point a turret sprite toward the mouse cursor. +``` +` calculate angle from turret to mouse +dx = mouseX - turretX +dy = mouseY - turretY +angle = atan2(dy, dx) +rotate_sprite 1, deg(angle) +``` + +Move an enemy toward the player at a fixed speed. +``` +` chase the player +dx = playerX - enemyX +dy = playerY - enemyY +angle = atan2(dy, dx) +speed = 2 +enemyX = enemyX + cos(angle) * speed +enemyY = enemyY + sin(angle) * speed +``` + +**Remarks** + +This is the one you want for finding the angle between two points. Given adirection vector (dx, dy), `atan2(dy, dx)` gives you the angle you canfeed into [sin](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sin) and [cos](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Cos) to movealong that direction. The result is in radians. If you need degrees for display, pipe it through[deg](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Deg). Passing `(0, 0)` returns `0`. + +--- + +### atan + +Returns the arctangent of the given value, in radians. + +For finding angles between two points, you almost certainly want [atan2](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Atan2) instead. It handles quadrants for you. + +**Parameters** + +- `Float` **x** - The tangent value to find the angle for. + +**Returns** `Float` - The angle in radians, in the range `-pi/2` to `pi/2`. + +**Examples** + +Find the angle of a slope from rise over run. +``` +` calculate the angle of a ramp +rise = 3 +run = 4 +slope = rise / run +angle = atan(slope) +angleDeg = deg(angle) +` angleDeg is about 36.87 +``` + +**Remarks** + +Plain atan only takes one argument and can't distinguish which quadrant theangle falls in. It's here for completeness, but [atan2](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Atan2)is what you'll reach for in practice. The result is in radians; convert with[deg](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Deg) if you need degrees. + +--- + +### sqrt + +Returns the square root of the given value. + +Passing a negative value returns `NaN`. + +**Parameters** + +- `Float` **x** - A non-negative value to take the square root of. + +**Returns** `Float` - The square root of . Returns `NaN` if is negative. + +**Examples** + +Check if two sprites are within range of each other. +``` +` calculate distance between player and enemy +dx = playerX - enemyX +dy = playerY - enemyY +dist = sqrt(dx * dx + dy * dy) +IF dist < 50 +` enemy is close enough to attack +take_damage 10 +ENDIF +``` + +Normalize a direction vector to unit length. +``` +` turn a direction into a unit vector +dx = targetX - startX +dy = targetY - startY +length = sqrt(dx * dx + dy * dy) +IF length > 0 +nx = dx / length +ny = dy / length +ENDIF +``` + +**Remarks** + +Most commonly used for distance calculations. If you have dx and dy betweentwo points, `sqrt(dx*dx + dy*dy)` gives you the distance. If you onlyneed to compare distances (e.g., "is this closer than that?"), you can skip thesqrt and compare the squared values directly, which is a bit faster. Pairs well with [atan2](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Atan2) when you need both the distanceand the angle to a target. + +--- + +### deg + +Converts an angle from radians to degrees. + +All trig functions ([sin](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sin), [cos](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Cos), [atan2](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Atan2), etc.) work in radians, so use this when you need degrees for display or human-friendly output. + +**Parameters** + +- `Float` **radians** - The angle in radians to convert. + +**Returns** `Float` - The equivalent angle in degrees. + +**Examples** + +Display the angle to a target in degrees. +``` +` show the player what direction the objective is +dx = objectiveX - playerX +dy = objectiveY - playerY +angleRad = atan2(dy, dx) +angleDeg = deg(angleRad) +` angleDeg is now in 0..360 range for display +``` + +Convert an [atan2](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Atan2) result to rotate a sprite. +``` +` rotate arrow sprite toward the mouse +dx = mouseX - arrowX +dy = mouseY - arrowY +angle = deg(atan2(dy, dx)) +rotate_sprite 1, angle +``` + +**Remarks** + +The inverse of [rad](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Rad). A full circle is `360` degreesor roughly `6.283` radians. If you are doing all your math in radians(recommended), you may only need this for debug printing or UI display. + +--- + +### rad + +Converts an angle from degrees to radians. + +Use this to feed degree values into trig functions like [sin](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sin) and [cos](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Cos), which expect radians. + +**Parameters** + +- `Float` **degrees** - The angle in degrees to convert. + +**Returns** `Float` - The equivalent angle in radians. + +**Examples** + +Fire a bullet at a 45-degree angle. +``` +` launch a projectile at 45 degrees +angleDeg = 45 +angleRad = rad(angleDeg) +speed = 10 +velX = cos(angleRad) * speed +velY = sin(angleRad) * speed +``` + +Rotate something by a fixed number of degrees each frame. +``` +` spin a sprite 2 degrees per frame +angleDeg = 0 +DO +angleDeg = angleDeg + 2 +x = 320 + cos(rad(angleDeg)) * 100 +y = 240 + sin(rad(angleDeg)) * 100 +draw_sprite 1, x, y +LOOP +``` + +**Remarks** + +The inverse of [deg](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Deg). If you're working with angles thatcome from user input or config files in degrees, run them through this beforepassing to any trig function. A common pattern:`x = cos(rad(angleDeg)) * radius`. + +--- + +### screenshot + +Takes a screenshot and saves it as a PNG file. + +If the file path you pass doesn't end in `.png`, the extension getsappended automatically, so you don't need to worry about it. + +**Parameters** + +- `String` **filePath** - The path to save the screenshot to. The `.png` extension is added if missing. + +**Examples** + +Save a screenshot when the player presses a key: +``` +DO +` press S to take a screenshot +IF scancode("S") = 1 +screenshot "my_screenshot" +ENDIF +sync +LOOP +``` + +**Remarks** + +This captures whatever is currently in the main render buffer, so call itafter `sync` if you want the finalcomposited frame. Calling it mid-frame will grab a partially drawn buffer,which is usually not what you want. The file is written synchronously, so there may be a tiny hitch on theframe you call it. For most use cases (debug screenshots, photo modes) thisis fine. + +--- + +### set render size + +Sets the size of the main render buffer in pixels. + +This controls the internal resolution that everything gets drawn at, whichmay differ from the window size. The final image is scaled to fit the window. + +**Parameters** + +- `Integer` **width** - Width of the render buffer in pixels. +- `Integer` **height** - Height of the render buffer in pixels. + +**Examples** + +Set up a pixel-art resolution at startup: +``` +` configure a small render buffer for pixel art +set render size 320, 180 + ` verify the size was applied +w = render width() +h = render height() +``` + +Set up a standard HD resolution: +``` +set render size 1280, 720 +``` + +**Remarks** + +Call this once during setup to define your game's native resolution. Forexample, if you're making a pixel-art game, you might set this to somethingsmall like `320` by `180`. The engine will scale it up to thewindow size, keeping that crispy pixel look. Changing this mid-game is possible but will recreate the render buffer, soit's best done at startup or during a scene transition. You can read thecurrent size back with `render width` and`render height`. + +--- + +### render width + +Returns the width of the main render buffer in pixels. + +This reflects whatever was last set with`set render size`. + +**Returns** `Integer` - The width of the main render buffer in pixels. + +**Examples** + +Center a sprite horizontally on screen: +``` +` place a sprite in the middle of the screen +texture 1, "Images/Logo" +cx = render width() / 2 +cy = render height() / 2 +sprite 1, cx, cy, 1 +``` + +**Remarks** + +Handy when you need to position things relative to the screen edges. Forinstance, centering a sprite horizontally by placing it at`render width` / `2`. Pair with`render height` for full coverage. + +--- + +### render height + +Returns the height of the main render buffer in pixels. + +This reflects whatever was last set with`set render size`. + +**Returns** `Integer` - The height of the main render buffer in pixels. + +**Examples** + +Place a HUD bar along the bottom of the screen: +``` +` draw a health bar at the bottom +barY = render height() - 20 +barW = render width() +` use barY and barW to position your HUD sprite +sprite 1, 0, barY, hudImg +``` + +**Remarks** + +Use this alongside `render width` when youneed to know the full dimensions of the render area. For example, toplace HUD elements along the bottom edge, or to calculate aspect ratios. + +--- + +### set background color + +Sets the background clear color for the main render buffer. + +Every frame, the buffer is filled with this color before anything isdrawn on top of it. + +**Parameters** + +- `Integer` **colorCode** - A packed RGBA color value. Use [rgb](/command/FadeBasic.Lib.Standard.StandardCommands/Rgb) to build one. + +**Examples** + +Set a dark blue background at startup: +``` +` deep blue sky color +set background color rgb(20, 20, 80) +``` + +Cycle the background color over time for a day/night effect: +``` +t = 0 +DO +t = t + 0.01 +r = 40 + sin(t) * 40 +g = 40 + sin(t) * 20 +b = 80 + sin(t) * 60 +set background color rgb(r, g, b) +sync +LOOP +``` + +**Remarks** + +This is the color you see wherever nothing else is being drawn. Think ofit as the "sky" or "void" behind your game. Set it once at startup orchange it dynamically for effects like day/night cycles. If you're using render targets, each target can have its own backgroundcolor via `set render target background color`.This command only affects the main buffer. + +--- + +### free effect id + +Returns the next available effect ID without reserving it. + +Calling this multiple times in a row returns the same ID. It doesn'tadvance until something actually reserves or uses that slot. + +**Parameters** + +- `Integer` _(ref)_ **effectId** - Receives the next available effect ID. + +**Returns** `Integer` - The next available effect ID. + +**Examples** + +Peek at the next effect ID before deciding to allocate: +``` +` check what the next ID would be +nextId = free effect id() +``` + +**Remarks** + +Use this when you want to peek at which ID would be assigned next withoutcommitting to it. If you just need an ID to pass straight into`effect`, use`reserve effect id` instead, which bothgrabs the ID and sets up the internal slot in one call. The typical flow is: call `reserve effect id`,then `effect` with the returned ID. You onlyneed `free effect id` if you're doingsomething more advanced, like checking IDs before deciding whether to allocate. + +--- + +### reserve effect id + +Reserves the next available effect ID and initializes its internal slot. + +After calling this, the ID is yours. Nothing else will hand it out, andyou can safely pass it to `effect`. + +**Parameters** + +- `Integer` _(ref)_ **effectId** - Receives the reserved effect ID. + +**Returns** `Integer` - The reserved effect ID. + +**Examples** + +Reserve an effect ID and load a shader: +``` +` grab an effect ID and load a bloom shader +fxId = reserve effect id() +effect fxId, "bloom" +``` + +**Remarks** + +This is the recommended way to get a new effect ID. It calls`free effect id` internally and thenmakes sure the slot is ready to go. A typical setup sequence looks like: call`reserve effect id` to get your ID,then `effect` to load the shader, then usethe various `set effect param` commands to configure it. + +--- + +### effect + +Loads a shader effect from the content pipeline. + +The effect is also watched for file changes, so if you modify theshader on disk, it hot-reloads automatically without restarting. + +**Parameters** + +- `Integer` **effectId** - The ID to assign to this effect. Use `reserve effect id` to get one. +- `String` **effectName** - The content pipeline asset name of the shader to load. + +**Examples** + +Load a shader and apply it as a full-screen effect: +``` +` set up a post-processing shader +fxId = reserve effect id() +effect fxId, "vignette" +set effect param float fxId, "Intensity", 0.5 +set screen effect fxId +``` + +Load a shader and update parameters each frame: +``` +fxId = reserve effect id() +effect fxId, "wave_distort" +set screen effect fxId + DO +t = game ms() / 1000.0 +set effect param float fxId, "Time", t +sync +LOOP +``` + +**Remarks** + +Before calling this, you need an effect ID. Either grab one with`reserve effect id` or pick your ownnumber. The `effectName` is the content pipeline asset name (the samename you'd use in a content project, without the file extension). Once loaded, configure the effect's parameters with commands like`set effect param float`,`set effect param color`,`set effect param texture`, etc.Then apply it to the screen with `set screen effect`. The hot-reload watcher is great during development. Tweak your shaderin an external editor and see changes live without restarting the game. + +--- + +### set screen shake amount + +Sets how intense the screen shake effect is. + +Higher values produce more dramatic shaking. Set to `0` to stopthe shake entirely. + +**Parameters** + +- `Float` **mag** - The shake intensity. `0` means no shake; larger values mean more movement. + +**Examples** + +Trigger a screen shake on an explosion: +``` +` big explosion shake +set screen shake amount 15.0 +set screen shake bounce 0.8 +``` + +Stop the screen shake: +``` +set screen shake amount 0 +``` + +**Remarks** + +Screen shake is a great way to add impact to explosions, hits, ordramatic events. The magnitude controls how far the screen can move fromits normal position during a shake. Pair this with `set screen shake bounce`to control how quickly the shake settles down. A high magnitude with lowbounce gives a single sharp jolt; high magnitude with high bounce gives asustained rumble. The shake is applied to the final rendered image, so it affects everythingon screen uniformly. + +--- + +### set screen shake bounce + +Sets how bouncy the screen shake feels. + +This controls the elasticity, meaning how quickly the shake oscillates andsettles back to center. + +**Parameters** + +- `Float` **bounce** - The elasticity of the shake. Higher values produce faster, snappier oscillation. + +**Examples** + +Set up a sharp, punchy camera shake: +``` +` quick jolt that settles fast +set screen shake amount 10.0 +set screen shake bounce 0.5 +``` + +Set up a sustained earthquake rumble: +``` +` ongoing tremor with high elasticity +set screen shake amount 4.0 +set screen shake bounce 2.0 +``` + +**Remarks** + +Think of this like a spring constant. A higher bounce value makes thescreen snap back and forth more aggressively, creating a jittery feel. Alower value gives a more sluggish, heavy shake. Use this alongside `set screen shake amount`to dial in the feel you want. For a quick camera punch, try a highmagnitude with moderate bounce. For a sustained earthquake effect, keepthe magnitude lower and the bounce higher. + +--- + +### set effect param color + +Sets a color parameter on a shader effect. + +The color is passed as a packed RGBA value and sent to the namedparameter in the shader. + +**Parameters** + +- `Integer` **effectId** - The effect to modify. Must have been loaded with `effect`. +- `String` **parameterName** - The name of the shader parameter, exactly as declared in the shader. +- `Integer` **colorCode** - A packed RGBA color value. Use [rgb](/command/FadeBasic.Lib.Standard.StandardCommands/Rgb) to build one. + +**Examples** + +Pass a tint color to a shader: +``` +fxId = reserve effect id() +effect fxId, "color_tint" + ` set a warm orange tint +set effect param color fxId, "TintColor", rgb(255, 180, 80) +set screen effect fxId +``` + +**Remarks** + +Use this to feed color data into your custom shaders. For example, atint color, an outline color, or a fog color. The `parameterName`must match the parameter name declared in the shader source exactly. If the parameter doesn't exist in the shader, this call is silentlyignored. No error is thrown, which makes it safe to call even if theshader has been hot-reloaded and the parameter was temporarily removed. Load the effect first with `effect`, then setits parameters with this and the other `set effect param` commands. + +--- + +### set effect param float + +Sets a single-number parameter on a shader effect. + +The parameter name must match the shader source exactly. + +**Parameters** + +- `Integer` **effectId** - The effect to modify. Must have been loaded with `effect`. +- `String` **parameterName** - The name of the shader parameter, exactly as declared in the shader. +- `Float` **value** - The value to set. + +**Examples** + +Animate a shader parameter over time: +``` +fxId = reserve effect id() +effect fxId, "dissolve" +set screen effect fxId + DO +` pass elapsed time in seconds to the shader +t = game ms() / 1000.0 +set effect param float fxId, "Time", t +set effect param float fxId, "Threshold", 0.5 +sync +LOOP +``` + +**Remarks** + +This is the most common way to feed data into shaders. Things like time,intensity, threshold values, or any single number your shader needs. Forexample, you might pass `game ms` divided by`1000` to get a seconds-based timer for animations. If the named parameter doesn't exist in the shader, the call is silentlyignored. This is handy during development when you're iterating on shadercode with hot-reload. Load the effect first with `effect`. + +--- + +### set effect param float2 + +Sets a two-component parameter on a shader effect. + +Use this for shader parameters that expect two values, like a screenresolution or a direction vector. + +**Parameters** + +- `Integer` **effectId** - The effect to modify. Must have been loaded with `effect`. +- `String` **parameterName** - The name of the shader parameter, exactly as declared in the shader. +- `Float` **x** - The first component. +- `Float` **y** - The second component. + +**Examples** + +Pass the render resolution to a post-processing shader: +``` +fxId = reserve effect id() +effect fxId, "pixelate" + ` tell the shader the screen dimensions +w = render width() +h = render height() +set effect param float2 fxId, "ScreenSize", w, h +set screen effect fxId +``` + +**Remarks** + +Common uses include passing the render size (from`render width` and`render height`) to a post-processingshader, or sending a normalized direction for effects like directional blur. If the named parameter doesn't exist in the shader, the call is silentlyignored. Load the effect first with `effect`. + +--- + +### set effect param float3 + +Sets a three-component parameter on a shader effect. + +Use this for shader parameters that expect three values, like a positionin 3D space or an RGB color without alpha. + +**Parameters** + +- `Integer` **effectId** - The effect to modify. Must have been loaded with `effect`. +- `String` **parameterName** - The name of the shader parameter, exactly as declared in the shader. +- `Float` **x** - The first component. +- `Float` **y** - The second component. +- `Float` **z** - The third component. + +**Examples** + +Pass a light position to a shader: +``` +fxId = reserve effect id() +effect fxId, "lighting" + ` set the light at world position (100, 200, 50) +set effect param float3 fxId, "LightPos", 100.0, 200.0, 50.0 +set screen effect fxId +``` + +Pass an RGB color without alpha as three separate floats: +``` +` fog color in 0..1 range +set effect param float3 fxId, "FogColor", 0.6, 0.7, 0.9 +``` + +**Remarks** + +If your shader has a light position, a world-space coordinate, or a colorparameter that doesn't need alpha, this is the command for it. For colorsthat do include alpha, consider using`set effect param color` instead,which takes a packed RGBA value. If the named parameter doesn't exist in the shader, the call is silentlyignored. Load the effect first with `effect`. + +--- + +### set effect param float4 + +Sets a four-component parameter on a shader effect. + +Use this for shader parameters that expect four values, like arectangle, a quaternion, or a custom data pack. + +**Parameters** + +- `Integer` **effectId** - The effect to modify. Must have been loaded with `effect`. +- `String` **parameterName** - The name of the shader parameter, exactly as declared in the shader. +- `Float` **x** - The first component. +- `Float` **y** - The second component. +- `Float` **z** - The third component. +- `Float` **w** - The fourth component. + +**Examples** + +Pass a clipping rectangle to a shader: +``` +fxId = reserve effect id() +effect fxId, "clip_rect" + ` define a rectangle as (x, y, width, height) +set effect param float4 fxId, "ClipRect", 10.0, 20.0, 200.0, 150.0 +set screen effect fxId +``` + +**Remarks** + +This is the most flexible of the `set effect param` family. It canrepresent anything your shader needs as four numbers. If you're passing acolor, though, you'll probably find`set effect param color` moreconvenient since it takes a packed RGBA value directly. If the named parameter doesn't exist in the shader, the call is silentlyignored. Load the effect first with `effect`. + +--- + +### set effect param texture + +Sets a texture parameter on a shader effect. + +The texture must already be loaded via`texture` or obtained from a`render target texture`. + +**Parameters** + +- `Integer` **effectId** - The effect to modify. Must have been loaded with `effect`. +- `String` **parameterName** - The name of the texture sampler in the shader. +- `Integer` **textureId** - The texture to assign. Must have been loaded with `texture` or obtained from a render target. + +**Examples** + +Feed a noise texture into a dissolve shader: +``` +` load the noise texture +texture 1, "Images/Noise" + ` set up the dissolve shader +fxId = reserve effect id() +effect fxId, "dissolve" +set effect param texture fxId, "NoiseTex", 1 +set effect param float fxId, "Threshold", 0.3 +set screen effect fxId +``` + +Use a render target's output as input to another shader: +``` +` create a render target and grab its texture +rtId = reserve render target id() +render target rtId, 0 +rtTex = render target texture(rtId) + ` pass the render target texture into a blur shader +fxId = reserve effect id() +effect fxId, "blur" +set effect param texture fxId, "SceneTex", rtTex +set screen effect fxId +``` + +**Remarks** + +This is how you feed images into your custom shaders. For example, anoise texture for dissolve effects, a lookup table for color grading, ora render target for multi-pass rendering. The `parameterName` must match the texture sampler name declared inthe shader source exactly. If the parameter doesn't exist, the call issilently ignored. A common pattern is to create a `render target`,draw some sprites to it with `set sprite render target`,then pass that target's texture into a post-processing shader with thiscommand. Load the effect first with `effect`. + +--- + +### clear screen effect + +Removes the screen-wide post-processing effect, returning to normal rendering. + +After calling this, the main buffer is drawn directly to the screen withno shader applied. + +**Examples** + +Toggle a post-processing effect on and off with a key press: +``` +fxId = reserve effect id() +effect fxId, "grayscale" +effectOn = 0 + DO +IF scancode("G") = 1 +IF effectOn = 0 +set screen effect fxId +effectOn = 1 +ELSE +clear screen effect +effectOn = 0 +ENDIF +ENDIF +sync +LOOP +``` + +**Remarks** + +Use this to turn off an effect that was applied with`set screen effect`. This is useful fortoggling effects on and off. For example, removing a blur when a pausemenu closes, or clearing a color-grading pass during a cutscene. You can call this even if no screen effect is currently set; it's harmless. + +--- + +### set screen effect + +Applies a shader effect as a full-screen post-processing pass. + +The effect is applied to the entire main render buffer every frame untilyou call `clear screen effect`. + +**Parameters** + +- `Integer` **effectId** - The effect to apply. Must have been loaded with `effect`. + +**Examples** + +Apply a CRT scanline effect to the whole screen: +``` +` load and activate a CRT shader +fxId = reserve effect id() +effect fxId, "crt_scanlines" +set effect param float fxId, "ScanlineIntensity", 0.4 +set screen effect fxId +``` + +**Remarks** + +This is how you add screen-wide visual effects like bloom, vignette,color grading, or CRT scanlines. Load an effect with`effect`, configure its parameters with thevarious `set effect param` commands, then call this to activate it. Only one screen effect can be active at a time. Calling this again with adifferent effect ID replaces the previous one. To remove it entirely, call`clear screen effect`. The effect's shader receives the main render buffer as its input texture.Make sure your shader has a texture sampler set up to receive the screencontents. + +--- + +### set render target background color + +Sets the background clear color for a specific render target. + +Each render target can have its own clear color, independent of themain buffer's `set background color`. + +**Parameters** + +- `Integer` **outputId** - The render target ID to configure. +- `Integer` **colorCode** - A packed RGBA color value to use as the clear color. Use [rgb](/command/FadeBasic.Lib.Standard.StandardCommands/Rgb) to build one. + +**Examples** + +Set a render target to clear with a solid color each frame: +``` +rtId = reserve render target id() +render target rtId, 0 + ` clear to opaque black each frame +set render target background color rtId, rgb(0, 0, 0) +``` + +**Remarks** + +When a render target is cleared each frame (controlled by`set render target clear flags`),it fills with this color before any sprites are drawn onto it. The defaultis typically transparent black, which is usually what you want for layeredrendering. You might want an opaque color if the render target representsa self-contained scene. Create a render target first with `render target`,then configure its clear behavior with this command and`set render target clear flags`. + +--- + +### set render target clear flags + +Controls whether a render target is cleared each frame before drawing. + +Pass any value greater than `0` to enable clearing, or `0` todisable it. + +**Parameters** + +- `Integer` **outputId** - The render target ID to configure. +- `Integer` **clearTarget** - Greater than `0` to clear each frame, `0` to keep previous contents. + +**Examples** + +Disable clearing for a paint trail effect: +``` +rtId = reserve render target id() +render target rtId, 0 + ` don't clear, so previous frames accumulate +set render target clear flags rtId, 0 +``` + +Re-enable clearing after a trail sequence: +``` +set render target clear flags rtId, 1 +``` + +**Remarks** + +By default, render targets get cleared every frame. Disabling the clearmeans sprites drawn in previous frames stick around, which can be usefulfor trail effects, accumulation buffers, or painting-style visuals whereyou want things to build up over time. When clearing is enabled, the render target fills with whatever color wasset by `set render target background color`before any sprites are drawn to it. Create a render target first with `render target`. + +--- + +### render target texture + +Returns the texture ID associated with a render target. + +Use the returned ID anywhere you'd use a regular texture. For example,as a [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) image or as input to a shader via`set effect param texture`. + +**Parameters** + +- `Integer` **outputId** - The render target ID to query. + +**Returns** `Integer` - The texture ID holding this render target's contents. Use it like any other texture ID. + +**Examples** + +Display a render target's contents as a sprite: +``` +` set up a render target +rtId = reserve render target id() +render target rtId, 0 + ` grab the texture and show it as a sprite +rtTex = render target texture(rtId) +sprite 10, 0, 0, rtTex +``` + +**Remarks** + +Every render target has an associated texture that holds its contents.This command lets you grab that texture ID so you can use the rendertarget's output elsewhere in your rendering pipeline. A common pattern is multi-pass rendering: draw some sprites to a rendertarget, grab its texture with this command, then feed that texture into apost-processing shader or display it on another sprite. The render target must have been set up with`render target` first. + +--- + +### free render target id + +Returns the next available render target ID without reserving it. + +Calling this multiple times in a row returns the same ID. It doesn'tadvance until something actually reserves or uses that slot. + +**Parameters** + +- `Integer` _(ref)_ **outputId** - Receives the next available render target ID. + +**Returns** `Integer` - The next available render target ID. + +**Examples** + +Peek at the next available render target ID: +``` +nextRtId = free render target id() +``` + +**Remarks** + +Use this when you want to peek at which render target ID would be assignednext without committing to it. In most cases, you'll want`reserve render target id` instead,which both grabs the ID and initializes the slot in one step. The typical flow is: call `reserve render target id`,then `render target` to set it up.You only need this peeking command for more advanced allocation patterns. + +--- + +### reserve render target id + +Reserves the next available render target ID and initializes its internal slot. + +After calling this, the ID is yours. Pass it to`render target` to finish setting it up. + +**Parameters** + +- `Integer` _(ref)_ **outputId** - Receives the reserved render target ID. + +**Returns** `Integer` - The reserved render target ID. + +**Examples** + +Full render target setup sequence: +``` +` reserve and create a render target +rtId = reserve render target id() +render target rtId, 0 + ` configure it +set render target background color rtId, rgb(0, 0, 0) +set render target clear flags rtId, 1 + ` assign a sprite to draw on it +texture 1, "Images/Player" +sprite 1, 50, 50, 1 +set sprite render target 1, rtId +``` + +**Remarks** + +This is the recommended way to get a new render target ID. It calls`free render target id` internallyand makes sure the slot is ready to go. A typical setup sequence: call this to get the ID, then`render target` to create thebacking texture, then optionally configure it with`set render target background color` and`set render target clear flags`.Finally, assign sprites to it with`set sprite render target`. + +--- + +### render target + +Creates or configures a render target with an associated texture. + +Pass `0` for the texture ID to auto-allocate one, or `-1` totear down the render target and release its texture. + +**Parameters** + +- `Integer` **outputId** - The render target ID to create or configure. +- `Integer` _(optional)_ **textureId** - The texture ID to associate. Pass `0` to auto-allocate, or `-1` to release. + +**Examples** + +Create a render target with an auto-allocated texture: +``` +` the simplest setup: pass 0 to auto-allocate +rtId = reserve render target id() +render target rtId, 0 + ` draw a sprite onto the render target +texture 1, "Images/Enemy" +sprite 1, 100, 100, 1 +set sprite render target 1, rtId +``` + +Tear down a render target when done: +``` +` release the render target and its backing buffer +render target rtId, -1 +``` + +**Remarks** + +Render targets let you draw sprites to an off-screen buffer instead of(or in addition to) the main screen. This is the foundation of multi-passrendering, post-processing, and any technique where you need to captureintermediate results. The most common pattern is to pass `0` as the texture ID, which tellsthe system to allocate a texture for you automatically using`reserve texture id`. You can thenretrieve that texture ID with `render target texture`to use it in sprites or shaders. If you pass a specific texture ID, the render target binds to that texture.If the texture ID changes from what was previously bound, a new backingbuffer is created at the current `set render size`dimensions. Passing `-1` clears the render target. Its texture reference isremoved and the backing buffer is released. Once set up, assign sprites to draw on this target using`set sprite render target`, and configureclearing behavior with `set render target background color`and `set render target clear flags`. + +--- + +### set fullscreen + +Toggles fullscreen mode on or off. + +When going fullscreen, the back buffer resolution is automatically set to match your monitor's native resolution. + +**Parameters** + +- `Boolean` **fullScreen** - `1` to go fullscreen, `0` for windowed. + +**Examples** + +Enter fullscreen mode at startup: +``` +` configure screen size then go fullscreen +set screen size 1920, 1080 +set fullscreen 1 +``` + +Toggle fullscreen on and off with the space key: +``` +isFullscreen = 0 +set sync rate 16 +DO +IF new spaceKey() = 1 +IF isFullscreen = 0 +set fullscreen 1 +isFullscreen = 1 +ELSE +set fullscreen 0 +isFullscreen = 0 +ENDIF +ENDIF +sync +LOOP +``` + +**Remarks** + +Call this during setup after you have configured your desired resolution with`set screen size`. Internally, this applies thechanges and resets render positioning, so you do not need to do that yourself. You cangrab the monitor dimensions ahead of time with `display width`and `display height` if you need to do any math before switching. + +--- + +### set window title + +Sets the text that appears in your game window's title bar. + +**Parameters** + +- `String` **title** - The title string to display in the window bar. + +**Examples** + +Set the window title at startup: +``` +` give the game window a title +set window title "My Awesome Game" +set screen size 1280, 720 +``` + +**Remarks** + +Usually you just call this once at startup and forget about it. Nothing stops you fromchanging it later if you want to show dynamic info in the title bar, though. + +--- + +### is os windows + +Checks if the game is running on Windows. + +**Returns** `Integer` - `1` if running on Windows, `0` otherwise. + +**Examples** + +Choose a resolution based on the operating system: +``` +` set resolution based on platform +IF is os windows() = 1 +set screen size 1920, 1080 +ELSE +set screen size 1280, 720 +ENDIF +``` + +**Remarks** + +Use this alongside `is os mac` when you need to branch onplatform-specific behavior. For example, you might pick different default resolutionson Windows vs Mac. + +--- + +### is os mac + +Checks if the game is running on macOS. + +**Returns** `Integer` - `1` if running on macOS, `0` otherwise. + +**Examples** + +Adjust settings on macOS: +``` +` check if running on Mac and adjust accordingly +IF is os mac() = 1 +set screen size 1280, 800 +print "Running on macOS" +ENDIF +``` + +**Remarks** + +Use this alongside `is os windows` when you need to branch onplatform-specific behavior. For example, you might pick different default resolutions orinput handling on Mac vs Windows. + +--- + +### display width + +Returns the full width of your physical monitor in pixels. + +This is the monitor resolution, not your game window size. + +**Returns** `Integer` - The monitor width in pixels. + +**Examples** + +Print the monitor resolution: +``` +` check the monitor's native resolution +w = display width() +h = display height() +print w +print h +``` + +Set the game window to half the monitor width: +``` +` size the window to half the display +dw = display width() +dh = display height() +set screen size dw / 2, dh / 2 +``` + +**Remarks** + +Do not confuse this with `screen width`, which gives you thegame's back buffer width (that is, what you set with `set screen size`).This is handy when setting up fullscreen. You can read the display dimensions first todecide how to configure your game resolution. Pairs with `display height`. + +--- + +### display height + +Returns the full height of your physical monitor in pixels. + +This is the monitor resolution, not your game window size. + +**Returns** `Integer` - The monitor height in pixels. + +**Examples** + +Use the display height to decide on a resolution: +``` +` pick a game height based on the monitor +dh = display height() +IF dh >= 1080 +set screen size 1920, 1080 +ELSE +set screen size 1280, 720 +ENDIF +``` + +**Remarks** + +Do not confuse this with `screen height`, which gives you thegame's back buffer height (that is, what you set with `set screen size`).Useful when planning your fullscreen setup. Pairs with `display width`. + +--- + +### screen width + +Returns your game's current back buffer width in pixels. + +This is the game window size, not the physical monitor resolution. + +**Returns** `Integer` - The game's back buffer width in pixels. + +**Examples** + +Center a sprite horizontally on screen: +``` +` place a sprite in the center of the screen +texture 1, "Images/Logo" +sprite 1, 0, 0, 1 +sw = screen width() +w = texture width(1) +xPos = (sw - w) / 2 +position sprite 1, xPos, 100 +``` + +**Remarks** + +This returns whatever you last set with `set screen size`.If you need the physical monitor width instead, use `display width`.Pairs with `screen height`. + +--- + +### screen height + +Returns your game's current back buffer height in pixels. + +This is the game window size, not the physical monitor resolution. + +**Returns** `Integer` - The game's back buffer height in pixels. + +**Examples** + +Keep a sprite at the bottom of the screen: +``` +` position a ground sprite at the bottom edge +texture 1, "Images/Ground" +sprite 1, 0, 0, 1 +sh = screen height() +h = texture height(1) +position sprite 1, 0, sh - h +``` + +**Remarks** + +This returns whatever you last set with `set screen size`.If you need the physical monitor height instead, use `display height`.Pairs with `screen width`. + +--- + +### set screen size + +Sets the game window resolution by updating the back buffer dimensions. + +This applies immediately. There is no need to call a separate apply or refresh command. + +**Parameters** + +- `Integer` **width** - Desired window width in pixels. Typical values are `640`, `1280`, or `1920`. +- `Integer` **height** - Desired window height in pixels. Typical values are `480`, `720`, or `1080`. + +**Examples** + +Set up a standard 720p window: +``` +` configure a 720p game window +set window title "My Game" +set screen size 1280, 720 +set sync rate 16 +DO +sync +LOOP +``` + +Match the screen size to the monitor for borderless windowed: +``` +` fill the whole display without going fullscreen +dw = display width() +dh = display height() +set screen size dw, dh +``` + +**Remarks** + +Call this during setup to establish your game's window size. This controls the actual pixeldimensions of the game window (the back buffer), which is different from the internal renderresolution you can set with `set render size`.Think of screen size as "how big is the window on the desktop" and render size as "how manypixels does the game actually draw at internally." After calling this, you can read the values back with `screen width`and `screen height`. If you want to go fullscreen instead, use`set fullscreen`, which will override the back buffer to matchyour monitor's native resolution. + +--- + +### free sprite id + +Peeks at the next available sprite ID without claiming it. + +This doesn't reserve the ID, so another call could grab it before you do. + +**Parameters** + +- `Integer` _(ref)_ **spriteId** - Receives the next free sprite ID. + +**Returns** `Integer` - The next available sprite ID (not yet reserved). + +**Examples** + +Peek at the next sprite ID to pre-size an array. +``` +` find out what the next sprite ID will be +free sprite id nextId +dim spriteIds(nextId + 10) +``` + +**Remarks** + +Most of the time you'll want `reserve sprite id` instead,which actually claims the slot. This one is handy if you just need to know what the next IDwould be, for example, to pre-allocate an array. If you already know your ID, skip both ofthese and call [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) directly. + +--- + +### reserve sprite id + +Claims the next available sprite ID and initializes its slot. + +The slot is created but the sprite won't be visible until you call [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite). + +**Parameters** + +- `Integer` _(ref)_ **spriteId** - Receives the reserved sprite ID. + +**Returns** `Integer` - The newly reserved sprite ID. + +**Examples** + +Reserve a sprite ID, configure it, then make it visible. +``` +` reserve a slot and set it up before showing +reserve sprite id spr +set sprite texture spr, texId +scale sprite spr, 2.0, 2.0 +sprite spr, 100, 200, texId +``` + +**Remarks** + +Use this when you need to configure a sprite (set its texture, position, etc.) before itofficially exists. The typical pattern is: reserve an ID, set properties on it, then call[sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) to make it live. If you don't need that setup step, justcall [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) directly with a known ID. See also`free sprite id` if you only need to peek without claiming. + +--- + +### sprite + +Creates a sprite, or updates an existing one's position and texture. + +If the ID already exists, this overwrites its position and texture rather than creating a duplicate. + +**Parameters** + +- `Integer` **spriteId** - The unique ID for this sprite. Reusing an existing ID updates it. +- `Float` **x** - The X position in screen coordinates. +- `Float` **y** - The Y position in screen coordinates. +- `Integer` **textureId** - The ID of a previously loaded texture. + +**Examples** + +Load a texture and create a sprite at the center of the screen. +``` +` load an image and show it on screen +texture 1, "hero.png" +sprite 1, 320, 240, 1 +sync +``` + +Create multiple sprites from the same texture. +``` +` place three copies of the same image in a row +texture 1, "coin.png" +FOR i = 1 TO 3 +sprite i, i * 80, 100, 1 +NEXT i +DO +sync +LOOP +``` + +**Remarks** + +This is the main way you put images on screen. You'll need to load a texture first with`texture`. The sprite references the texture by ID and won'tactually show up until the next [sync](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sync) call. For moving a sprite aftercreation, `position sprite` is slightly more direct since itskips the texture assignment. + +--- + +### position sprite + +Moves a sprite to the given screen position. + +**Parameters** + +- `Integer` **spriteId** - The ID of the sprite to move. +- `Float` **x** - The new X position in screen coordinates. +- `Float` **y** - The new Y position in screen coordinates. + +**Examples** + +Move a sprite with the arrow keys. +``` +` simple movement loop +texture 1, "player.png" +sprite 1, 320, 240, 1 +px = 320 +py = 240 +DO +IF up key(1) THEN py = py - 2 +IF down key(1) THEN py = py + 2 +IF left key(1) THEN px = px - 2 +IF right key(1) THEN px = px + 2 +position sprite 1, px, py +sync +LOOP +``` + +**Remarks** + +Call this every frame for sprites that move, or once for static ones. If you just createdthe sprite with [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite), the position is already set. Use thisfor updates after creation. The position is where the sprite's origin point lands on screen(see `set sprite offset` to control the origin). + +--- + +### color sprite + +Sets the tint color of a sprite using a packed RGBA integer. + +This color multiplies with the texture's own colors. A white tint (`0xFFFFFFFF`) shows the texture as-is, while other values shift the hue or darken it. + +**Parameters** + +- `Integer` **spriteId** - The sprite to tint. +- `Integer` **packedColor** - A packed RGBA color value (e.g. `0xFF0000FF` for opaque red). + +**Examples** + +Tint a sprite red. +``` +` make a sprite appear red-tinted +texture 1, "enemy.png" +sprite 1, 100, 100, 1 +color sprite 1, 0xFF0000FF +``` + +Darken a sprite to 50% brightness. +``` +` half-grey tint dims the image +color sprite 1, 0x808080FF +``` + +**Remarks** + +Call this any time after creating the sprite with [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite). The tint isa multiply blend, so `0xFF0000FF` (red, full alpha) makes the whole sprite red-tinted, and`0x808080FF` (half-grey, full alpha) darkens it to 50%. If you only need to change the RGBchannels without touching alpha, use `set sprite diffuse`.To change just the transparency, use `set sprite alpha`. + +--- + +### order sprite + +Sets the draw order (z-order) of a sprite. + +Higher values draw on top of lower values, so a sprite with order `10` covers one with order `5`. + +**Parameters** + +- `Integer` **spriteId** - The sprite to reorder. +- `Integer` **order** - The z-order value. Higher values draw on top. + +**Examples** + +Layer a background behind a player sprite. +``` +` set up two sprites with explicit draw order +texture 1, "background.png" +texture 2, "player.png" +sprite 1, 0, 0, 1 +sprite 2, 160, 120, 2 +` background draws first, player on top +order sprite 1, 0 +order sprite 2, 10 +``` + +**Remarks** + +Ordering is per-render-target. A sprite's z-order only matters relative to other sprites on thesame target. If two sprites share the same order value, their draw sequence is undefined, so alwaysassign distinct orders when layering matters. You can call this once at setup or change it dynamically(e.g. to bring a sprite to the front during an animation). See`set sprite render target` for controlling which target a sprite draws to. + +--- + +### hide sprite + +Hides a sprite so it is not drawn. + +The sprite still exists in memory with all its properties intact. It just skips rendering until you call `show sprite`. + +**Parameters** + +- `Integer` **spriteId** - The sprite to hide. + +**Examples** + +Blink a sprite on and off every 30 frames. +``` +` simple blink effect +texture 1, "powerup.png" +sprite 1, 200, 150, 1 +timer = 0 +visible = 1 +DO +timer = timer + 1 +IF timer > 30 +timer = 0 +IF visible = 1 +hide sprite 1 +visible = 0 +ELSE +show sprite 1 +visible = 1 +ENDIF +ENDIF +sync +LOOP +``` + +**Remarks** + +This is cheaper than destroying and recreating a sprite when you need to toggle visibility(e.g. blinking effects, UI panels that open and close). The sprite keeps its position, texture,scale, and everything else. Use `show sprite` to make it visible again. + +--- + +### show sprite + +Makes a previously hidden sprite visible again. + +Only needed after calling `hide sprite`. Sprites are visible by default when created. + +**Parameters** + +- `Integer` **spriteId** - The sprite to show. + +**Examples** + +Show a hidden UI panel when the player presses a key. +``` +` toggle an inventory panel with the tab key +texture 10, "inventory.png" +sprite 10, 50, 50, 10 +hide sprite 10 +panelOpen = 0 +DO +IF key hit(scancode("Tab")) = 1 +IF panelOpen = 0 +show sprite 10 +panelOpen = 1 +ELSE +hide sprite 10 +panelOpen = 0 +ENDIF +ENDIF +sync +LOOP +``` + +**Remarks** + +This is the counterpart to `hide sprite`. Calling it on a spritethat is already visible has no effect. The sprite resumes drawing at its current position, scale,and z-order. Nothing else changes. + +--- + +### set sprite texture + +Swaps the texture on a sprite without changing anything else. + +Position, scale, rotation, color, and all other properties stay the same. Only the image changes. + +**Parameters** + +- `Integer` **spriteId** - The sprite to update. +- `Integer` **textureId** - The ID of a previously loaded texture. + +**Examples** + +Swap a character's texture when they take damage. +``` +` load both normal and hurt textures +texture 1, "hero.png" +texture 2, "hero_hurt.png" +sprite 1, 200, 200, 1 +` later, when the player gets hit +set sprite texture 1, 2 +``` + +**Remarks** + +Use this for things like swapping character costumes or cycling through icon states. The newtexture must already be loaded via `texture`. If the new texture hasdifferent dimensions, the sprite's visual size will change (unless you've set an explicit scalewith `scale sprite` or `size sprite`).If the sprite had a frame set via `set sprite frame`, the frameindex carries over. Make sure the new texture has enough frames or reset the frame to `0`. + +--- + +### set sprite render target + +Redirects a sprite to draw on a specific render target instead of the default output. + +This replaces any previous target assignment. The sprite will only draw to the new target. + +**Parameters** + +- `Integer` **spriteId** - The sprite to redirect. +- `Integer` **outputId** - The render target ID to draw to. + +**Examples** + +Draw a sprite to an off-screen render target for a minimap. +``` +` create a render target and draw the map icon to it +render target 5, 128, 128 +texture 1, "map_icon.png" +sprite 1, 64, 64, 1 +set sprite render target 1, 5 +``` + +**Remarks** + +By default, sprites draw to the main screen output. Use this to redirect a sprite to an off-screenbuffer created with `render target`. This is how you buildmulti-pass effects, minimaps, or UI layers. The sprite's z-order only competes with other spriteson the same target. To draw a sprite on multiple targets at once, use`add sprite render target` instead. To go back to the defaultoutput, call `reset sprite render target`. + +--- + +### reset sprite render target + +Resets a sprite to draw on the default render target. + +This undoes any previous `set sprite render target` or `add sprite render target` calls. + +**Parameters** + +- `Integer` **spriteId** - The sprite to reset to the default output. + +**Examples** + +Move a sprite back to the main screen after rendering to a buffer. +``` +` redirect sprite to a render target, then reset it +set sprite render target 1, 5 +` ... do some off-screen rendering ... +reset sprite render target 1 +``` + +**Remarks** + +Convenience shortcut, equivalent to calling `set sprite render target`with the default output ID. Use this when you're done drawing a sprite to an off-screen buffer andwant it back on the main screen. + +--- + +### add sprite render target + +Adds an additional render target for a sprite, so it draws to multiple targets at once. + +Unlike `set sprite render target`, this does not remove existing targets. It stacks. + +**Parameters** + +- `Integer` **spriteId** - The sprite to add a target to. +- `Integer` **outputId** - The render target ID to add. + +**Examples** + +Draw a sprite to both the main screen and a minimap buffer. +``` +` show the player icon on the main screen and the minimap +render target 5, 128, 128 +texture 1, "player_icon.png" +sprite 1, 320, 240, 1 +` add the minimap target without removing the main screen +add sprite render target 1, 5 +``` + +**Remarks** + +This is how you get a single sprite to appear on both the main screen and an off-screen buffer(or multiple buffers). Each call adds one more target to the sprite's output set. The sprite'sz-order is evaluated independently on each target. To start fresh with a single target, use`set sprite render target` (which replaces rather than adds).To return to defaults, call `reset sprite render target`. + +--- + +### scale sprite + +Sets the X and Y scale factors of a sprite directly. + +A scale of `1.0` is the original texture size, `2.0` doubles it, `0.5` halves it. + +**Parameters** + +- `Integer` **spriteId** - The sprite to scale. +- `Float` **x** - Horizontal scale factor. `1.0` = original width. +- `Float` **y** - Vertical scale factor. `1.0` = original height. + +**Examples** + +Double the size of a sprite uniformly. +``` +` make a sprite twice as big +texture 1, "gem.png" +sprite 1, 100, 100, 1 +scale sprite 1, 2.0, 2.0 +``` + +Stretch a sprite horizontally for a squash-and-stretch effect. +``` +` squash on landing: wide and short +scale sprite 1, 1.4, 0.7 +` then spring back to normal +scale sprite 1, 1.0, 1.0 +``` + +**Remarks** + +Use this when you want precise control over the scale multiplier. If you'd rather specify atarget pixel size and let Fade figure out the scale, use `size sprite`,`size sprite x`, or `size sprite y`instead. You can set X and Y independently to stretch or squash the sprite. Negative values willmirror the sprite (though `set sprite flip` is cleaner for simple flips). + +--- + +### attach sprite to transform + +Attaches a sprite to a transform so it follows the transform's position, rotation, and scale. + +The sprite becomes a child of the transform. Move the transform and the sprite moves with it. + +**Parameters** + +- `Integer` **spriteId** - The sprite to attach. +- `Integer` **transformId** - The transform to follow. Must be created via `transform`. + +**Examples** + +Attach a sprite and collider to a shared transform. +``` +` create a transform and attach both a sprite and a collider +transform 1 +texture 1, "hero.png" +sprite 1, 0, 0, 1 +attach sprite to transform 1, 1 +box collider 1, 0, 0, 32, 32 +attach collider to transform 1, 1 +` now moving the transform moves everything +position transform 1, 200, 150 +``` + +**Remarks** + +This is how you build hierarchical movement. For example, attaching a weapon sprite to a charactertransform so they move together. Create the transform first with `transform`,then attach the sprite here. The sprite's own position becomes a local offset relative to thetransform. You can also attach a collider to the same transform with`attach collider to transform` to keep physics in sync.Call this once during setup; the attachment persists until you change it. + +--- + +### size sprite + +Resizes a sprite to exact pixel dimensions by calculating the right scale internally. + +This sets X and Y scale independently, so the aspect ratio may change if the target dimensions don't match the texture's ratio. + +**Parameters** + +- `Integer` **spriteId** - The sprite to resize. +- `Float` **xPixels** - Desired width in pixels. +- `Float` **yPixels** - Desired height in pixels. + +**Examples** + +Force a sprite to be exactly 64x64 pixels on screen. +``` +` resize a sprite to a fixed pixel size regardless of texture dimensions +texture 1, "icon.png" +sprite 1, 10, 10, 1 +size sprite 1, 64, 64 +``` + +**Remarks** + +This is the easiest way to make a sprite a specific pixel size on screen. It reads the texture'sframe dimensions and computes scale factors to hit the target size. If you want to preserve theaspect ratio, use `size sprite x` (lock width, auto height) or`size sprite y` (lock height, auto width) instead. For directcontrol over the scale multiplier itself, use `scale sprite`. + +--- + +### size sprite x + +Resizes a sprite to a target width in pixels while maintaining aspect ratio. + +The height scales uniformly with the width, so the image never stretches or squashes. + +**Parameters** + +- `Integer` **spriteId** - The sprite to resize. +- `Float` **xPixels** - Desired width in pixels. Height adjusts automatically. + +**Examples** + +Make a sprite 200 pixels wide while keeping its proportions. +``` +` set width to 200, height scales automatically +texture 1, "banner.png" +sprite 1, 50, 50, 1 +size sprite x 1, 200 +``` + +**Remarks** + +This is the go-to for "make this sprite X pixels wide" without distortion. It computes thescale from the texture's frame width and applies it to both axes. If you need to lock the heightinstead, use `size sprite y`. If you want to set both widthand height independently (potentially changing the aspect ratio), use`size sprite`. + +--- + +### size sprite y + +Resizes a sprite to a target height in pixels while maintaining aspect ratio. + +The width scales uniformly with the height, so the image never stretches or squashes. + +**Parameters** + +- `Integer` **spriteId** - The sprite to resize. +- `Float` **yPixels** - Desired height in pixels. Width adjusts automatically. + +**Examples** + +Fit a sprite to a 48-pixel tall slot. +``` +` set height to 48, width scales to match +texture 1, "portrait.png" +sprite 1, 10, 10, 1 +size sprite y 1, 48 +``` + +**Remarks** + +This is the counterpart to `size sprite x`. Use it when youwant to lock the height and let the width follow. It computes the scale from the texture's frameheight and applies it to both axes. For setting exact pixel dimensions on both axes independently,use `size sprite`. + +--- + +### rotate sprite + +Rotates a sprite to the given angle in radians. + +The sprite rotates around its offset (origin) point. By default that is the top-left corner. + +**Parameters** + +- `Integer` **spriteId** - The sprite to rotate. +- `Float` **angle** - Rotation angle in radians. `0` is no rotation. + +**Examples** + +Spin a sprite around its center continuously. +``` +` rotate a sprite around its center each frame +texture 1, "star.png" +sprite 1, 320, 240, 1 +set sprite offset 1, 0.5, 0.5 +angle = 0.0 +DO +angle = angle + 0.02 +rotate sprite 1, angle +sync +LOOP +``` + +Rotate a sprite by 45 degrees using the [rad](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Rad) helper. +``` +` tilt a sprite 45 degrees +set sprite offset 1, 0.5, 0.5 +rotate sprite 1, rad(45) +``` + +**Remarks** + +This sets an absolute angle, not a delta. Calling it with the same value every frame holds therotation steady. If you want the sprite to rotate around its center, set the offset to `(0.5, 0.5)`first with `set sprite offset`. The angle is in radians; use[rad](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Rad) to convert from degrees if needed. If the sprite is attached to atransform via `attach sprite to transform`, thisrotation is applied on top of the transform's rotation. + +--- + +### set sprite offset + +Sets the origin point of a sprite as a ratio of its size. + +`(0, 0)` is the top-left corner, `(0.5, 0.5)` is the center, `(1, 1)` is the bottom-right. This affects both the rotation pivot and where the position anchors. + +**Parameters** + +- `Integer` **spriteId** - The sprite to adjust. +- `Float` **xRatio** - Horizontal origin as a 0-to-1 ratio of the sprite's width. +- `Float` **yRatio** - Vertical origin as a 0-to-1 ratio of the sprite's height. + +**Examples** + +Center a sprite's origin for rotation. +``` +` set origin to the center so rotation looks natural +set sprite offset 1, 0.5, 0.5 +rotate sprite 1, rad(90) +``` + +Anchor a sprite from its bottom-center (useful for characters standing on a surface). +``` +` anchor at the bottom-center so the feet stay on the ground +set sprite offset 1, 0.5, 1.0 +position sprite 1, 320, 400 +``` + +**Remarks** + +By default the origin is `(0, 0)` (top-left), which means`position sprite` places the top-left corner at the givencoordinates. Set it to `(0.5, 0.5)` if you want the sprite's center at that position.This is especially important for `rotate sprite`, which pivotsaround the origin. Values outside `0` to `1` are valid and shift the anchor beyond the sprite's bounds. + +--- + +### set sprite all texcoord1 + +Sets the secondary texture coordinate (texcoord1) for all four vertices of a sprite at once. + +This is an advanced feature for passing custom per-sprite data to shaders. You won't need it unless you're writing custom effects. + +**Parameters** + +- `Integer` **spriteId** - The sprite to update. +- `Float` **x** - The X component of the texcoord1 vector. +- `Float` **y** - The Y component of the texcoord1 vector. +- `Float` **z** - The Z component of the texcoord1 vector. +- `Float` **w** - The W component of the texcoord1 vector. + +**Examples** + +Pass a dissolve threshold to a custom shader. +``` +` set up a dissolve effect and pass the threshold via texcoord1 +effect 1, "dissolve.fx" +set sprite effect 1, 1 +` x = dissolve threshold (0.0 to 1.0), y/z/w unused +set sprite all texcoord1 1, 0.5, 0.0, 0.0, 0.0 +``` + +**Remarks** + +Each sprite quad has four vertices, and each vertex has a second texture coordinate slot (texcoord1)that is not used by the default rendering pipeline. When you assign a custom shader via`set sprite effect`, your shader can read these values to driveeffects like dissolve thresholds, color-cycling parameters, or distortion strength. This overloadsets the same value on all four corners. If you need per-corner values (e.g. for gradient effects),use `set sprite index texcoord1`. + +--- + +### set sprite index texcoord1 + +Sets the secondary texture coordinate (texcoord1) for a single corner vertex of a sprite. + +This is an advanced feature for passing per-vertex data to custom shaders. Most use cases only need `set sprite all texcoord1`. + +**Parameters** + +- `Integer` **spriteId** - The sprite to update. +- `Integer` **cornerIndex** - Which corner: `0` = top-left, `1` = top-right, `2` = bottom-left, `3` = bottom-right. +- `Float` **x** - The X component of the texcoord1 vector. +- `Float` **y** - The Y component of the texcoord1 vector. +- `Float` **z** - The Z component of the texcoord1 vector. +- `Float` **w** - The W component of the texcoord1 vector. + +**Examples** + +Set up a vertical gradient by giving top corners one value and bottom corners another. +``` +` top corners get 1.0, bottom corners get 0.0 in the x channel +set sprite index texcoord1 1, 0, 1.0, 0.0, 0.0, 0.0 +set sprite index texcoord1 1, 1, 1.0, 0.0, 0.0, 0.0 +set sprite index texcoord1 1, 2, 0.0, 0.0, 0.0, 0.0 +set sprite index texcoord1 1, 3, 0.0, 0.0, 0.0, 0.0 +``` + +**Remarks** + +Each sprite is a quad with four corners. This overload lets you set a different texcoord1 value oneach corner, which the GPU interpolates across the sprite's surface. This is useful for gradient-styleshader effects where each corner needs a distinct value. Assign a custom shader first with`set sprite effect`, then set corner data here. Corner indices:`0` = top-left, `1` = top-right, `2` = bottom-left, `3` = bottom-right. + +--- + +### set sprite effect + +Assigns a custom shader effect to a sprite. + +The sprite will be drawn using this effect instead of the default pipeline. All sprites sharing an effect are batched together. + +**Parameters** + +- `Integer` **spriteId** - The sprite to apply the effect to. +- `Integer` **effectId** - The ID of a previously loaded effect. + +**Examples** + +Apply a custom glow shader to a sprite. +``` +` load a shader and assign it to a sprite +effect 1, "glow.fx" +texture 1, "orb.png" +sprite 1, 200, 200, 1 +set sprite effect 1, 1 +``` + +**Remarks** + +Load the effect first with `effect`, then pass its ID here. Onceassigned, the sprite uses that shader every frame until you change it. You can pass per-spritedata to the shader via `set sprite all texcoord1`or `set sprite index texcoord1`.Sprites with the same effect are drawn together in the same batch, so grouping sprites by effectis good for performance. + +--- + +### set sprite diffuse + +Sets the RGB color channels of a sprite, leaving alpha unchanged. + +Use this when you want to tint or recolor a sprite without affecting its transparency. + +**Parameters** + +- `Integer` **spriteId** - The sprite to tint. +- `Byte` **red** - Red channel, `0` to `255`. +- `Byte` **green** - Green channel, `0` to `255`. +- `Byte` **blue** - Blue channel, `0` to `255`. + +**Examples** + +Give a sprite a green tint. +``` +` tint the sprite green while keeping alpha as-is +set sprite diffuse 1, 100, 255, 100 +``` + +**Remarks** + +This modifies only the red, green, and blue channels. The alpha channel stays at whatever itwas before. Like `color sprite`, these values multiply with thetexture's colors. Setting all three to `255` shows the texture at full brightness. Tochange alpha independently, use `set sprite alpha`.To set all four channels at once with a packed integer, use `color sprite`. + +--- + +### set sprite alpha + +Sets the transparency of a sprite. + +`0` is fully transparent (invisible), `255` is fully opaque. RGB channels are not affected. + +**Parameters** + +- `Integer` **spriteId** - The sprite to adjust. +- `Byte` **alpha** - Alpha value, `0` to `255`. `0` = transparent, `255` = opaque. + +**Examples** + +Fade a sprite in from fully transparent to fully opaque. +``` +` gradually fade in a sprite over many frames +texture 1, "title.png" +sprite 1, 200, 100, 1 +set sprite alpha 1, 0 +alpha = 0 +DO +IF alpha < 255 +alpha = alpha + 3 +IF alpha > 255 THEN alpha = 255 +set sprite alpha 1, alpha +ENDIF +sync +LOOP +``` + +Make a sprite semi-transparent for a ghost effect. +``` +` 50% transparency +set sprite alpha 1, 128 +``` + +**Remarks** + +This is the quickest way to fade a sprite in or out without touching its color tint. The alphavalue multiplies with the texture's own alpha, so a texture pixel at 50% alpha with a sprite alphaof `128` ends up at roughly 25% opacity. To set RGB channels without touching alpha, use`set sprite diffuse`. To set all fourchannels at once, use `color sprite`. + +--- + +### set sprite frame + +Selects which frame of a spritesheet to display on a sprite. + +The texture must have its frame grid set up first via `set texture frame grid`, or this won't do anything useful. + +**Parameters** + +- `Integer` **spriteId** - The sprite to update. +- `Integer` **frameId** - Zero-based frame index into the texture's frame grid. + +**Examples** + +Animate a sprite by cycling through frames. +``` +` set up a 4x4 spritesheet and animate it +texture 1, "walk.png" +set texture frame grid 1, 4, 4 +sprite 1, 200, 200, 1 +frame = 0 +totalFrames = texture frames(1) +timer = 0 +DO +timer = timer + 1 +IF timer > 5 +timer = 0 +frame = frame + 1 +IF frame >= totalFrames THEN frame = 0 +set sprite frame 1, frame +ENDIF +sync +LOOP +``` + +**Remarks** + +Frame indices are zero-based and count left-to-right, top-to-bottom across the grid. You canquery how many frames a texture has with `texture frames`.Call this every frame (or whenever the animation advances) to animate a sprite through itsspritesheet. If the sprite's texture is a single image with no frame grid, frame `0` showsthe whole texture. + +--- + +### set sprite flip + +Flips a sprite horizontally, vertically, or both. + +Pass `1` to flip an axis, `0` for normal. This is a visual flip only. Position and offset are not affected. + +**Parameters** + +- `Integer` **spriteId** - The sprite to flip. +- `Integer` **flipHorizontal** - `1` to flip horizontally, `0` for normal. +- `Integer` **flipVertical** - `1` to flip vertically, `0` for normal. + +**Examples** + +Flip a character sprite to face left when moving left. +``` +` flip based on movement direction +IF left key(1) +set sprite flip 1, 1, 0 +px = px - 2 +ENDIF +IF right key(1) +set sprite flip 1, 0, 0 +px = px + 2 +ENDIF +``` + +**Remarks** + +This is the cleanest way to mirror a sprite (e.g. flipping a character to face left vs. right).It's cheaper and simpler than using negative scale values via `scale sprite`.Both axes can be flipped simultaneously by passing `1` for both parameters. The flip isapplied after rotation, so a rotated + flipped sprite may look different than a flipped + rotated one. + +--- + +### sprite width + +Returns the width of the sprite's current texture frame in pixels, before any scaling is applied. + +If the texture uses a frame grid, this returns the width of a single frame, not the whole texture. + +**Parameters** + +- `Integer` **spriteId** - The sprite to measure. + +**Returns** `Float` - Width of the current frame in pixels (before scaling). + +**Examples** + +Center a sprite based on its width. +``` +` place a sprite so its center is at screen X = 320 +texture 1, "logo.png" +sprite 1, 0, 100, 1 +w = sprite width(1) +position sprite 1, 320 - w / 2, 100 +``` + +**Remarks** + +Use this to get the raw pixel dimensions of what the sprite is displaying. This is the basemeasurement that `scale sprite` multiplies against. If you need theon-screen size, multiply this by the sprite's current X scale. Pair with`sprite height` for both dimensions. + +--- + +### sprite height + +Returns the height of the sprite's current texture frame in pixels, before any scaling is applied. + +If the texture uses a frame grid, this returns the height of a single frame, not the whole texture. + +**Parameters** + +- `Integer` **spriteId** - The sprite to measure. + +**Returns** `Float` - Height of the current frame in pixels (before scaling). + +**Examples** + +Stack two sprites vertically using their heights. +``` +` place sprite 2 directly below sprite 1 +h = sprite height(1) +y1 = sprite y(1) +position sprite 2, sprite x(1), y1 + h +``` + +**Remarks** + +Use this to get the raw pixel dimensions of what the sprite is displaying. This is the basemeasurement that `scale sprite` multiplies against. If you need theon-screen size, multiply this by the sprite's current Y scale. Pair with`sprite width` for both dimensions. + +--- + +### sprite x + +Returns the current X position of a sprite. + +This is the position last set by [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) or `position sprite`. It does not include transform offsets. + +**Parameters** + +- `Integer` **spriteId** - The sprite to query. + +**Returns** `Float` - The X position in screen coordinates (or local coordinates if attached to a transform). + +**Examples** + +Read a sprite's position and print it. +``` +` check where a sprite is +px = sprite x(1) +py = sprite y(1) +``` + +**Remarks** + +If the sprite is attached to a transform via `attach sprite to transform`,this returns the sprite's local position, not its final on-screen position. Pair with`sprite y` for the full coordinate. + +--- + +### sprite y + +Returns the current Y position of a sprite. + +This is the position last set by [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) or `position sprite`. It does not include transform offsets. + +**Parameters** + +- `Integer` **spriteId** - The sprite to query. + +**Returns** `Float` - The Y position in screen coordinates (or local coordinates if attached to a transform). + +**Examples** + +Clamp a sprite so it cannot move off the bottom of the screen. +``` +` keep the sprite above the screen floor +py = sprite y(1) +IF py > 440 THEN position sprite 1, sprite x(1), 440 +``` + +**Remarks** + +If the sprite is attached to a transform via `attach sprite to transform`,this returns the sprite's local position, not its final on-screen position. Pair with`sprite x` for the full coordinate. + +--- + +### set sync rate + +Sets the target frame time in milliseconds. + +This controls how long the engine waits between frames: `16` ms gives you roughly 60 fps, `33` ms gives you roughly 30 fps. + +**Parameters** + +- `Integer` **rate** - Target elapsed time per frame, in milliseconds. Common values: `16` (~60 fps), `33` (~30 fps). + +**Examples** + +Standard 60 fps game loop setup: +``` +` set up a 60 fps game loop +set sync rate 16 +DO +` game logic goes here +sync +LOOP +``` + +Switch to a slower frame rate for a cutscene: +``` +` run at 30 fps during a cutscene, then switch back +set sync rate 33 +` ... play cutscene ... +set sync rate 16 +``` + +**Remarks** + +Call this once during setup, before your main `DO...LOOP`. You generallydon't need to change it at runtime, though nothing stops you from doing so(for example, dropping to 30 fps during a heavy scene). This works hand-in-hand with `sync`.The sync call is what actually yields to let the frame happen, and the rate youset here determines how long that frame takes. If you never call`sync`, this setting has no visible effect. + +--- + +### sync + +Suspends script execution and lets a render frame happen. + +Without this call, nothing you draw, move, or change will ever appear on screen. + +**Parameters** + + +**Examples** + +Minimal game loop that moves a sprite each frame: +``` +` move a sprite to the right, one pixel per frame +set sync rate 16 +texture 1, "Images/Ball" +sprite 1, 0, 100, 1 +x = 0 +DO +x = x + 1 +sprite 1, x, 100, 1 +sync +LOOP +``` + +**Remarks** + +This is THE core game loop command. You'll typically call it once per iterationinside a `DO...LOOP`. Every sprite move, text change, or effect you set upbetween syncs becomes visible only after this call fires. Pair it with `set sync rate` to control how fastframes tick. You can read `game ms` right after a syncto get the current time for animations, or check`frame number` if you prefer frame-based timing. Calling sync twice in a row is harmless; you just get an extra frame with nochanges. Forgetting to call it at all means your script runs to completion andthe window closes (or hangs) without ever rendering. + +--- + +### frame number + +Returns the current frame number. + +The counter increments by one each time `sync` is called, starting from zero. + +**Returns** `DoubleInteger` - The current frame number. Starts at `0` and increments by one per sync. + +**Examples** + +Cycle a sprite image every 10 frames: +``` +` swap between two images every 10 frames +set sync rate 16 +texture 1, "Images/Frame1" +texture 2, "Images/Frame2" +sprite 1, 100, 100, 1 +DO +f = frame number() +` switch image every 10 frames +img = (f / 10) mod 2 + 1 +sprite 1, 100, 100, img +sync +LOOP +``` + +Trigger an event after 120 frames: +``` +set sync rate 16 +DO +f = frame number() +IF f = 120 THEN print "two seconds have passed!" +sync +LOOP +``` + +**Remarks** + +Useful for frame-based timing and animations. For example, you can cycle a spritesheet every N frames, or trigger an event after a fixed number of updates. If you need real wall-clock time instead of frame counts, use`game ms`. + +--- + +### free text id + +Peeks at the next available text sprite ID without claiming it. + +The returned ID is not reserved, so another call could grab it before you do. + +**Parameters** + +- `Integer` _(ref)_ **textId** - Receives the next available text ID. + +**Returns** `Integer` - The next available text ID. + +**Examples** + +Check what the next text ID will be before creating it. +``` +` peek at the next available text ID +nextId = free text id() +print "Next text ID will be: " + str(nextId) +``` + +**Remarks** + +Same pattern as the sprite ID management commands. Call this when you need to know what IDwill be assigned next but aren't ready to create the text sprite yet. If you actually wantto lock in the ID, use `reserve text id` instead. + +--- + +### reserve text id + +Claims the next available text sprite ID and initializes its slot. + +Unlike `free text id`, this actually reserves the ID so nothing else can take it. + +**Parameters** + +- `Integer` _(ref)_ **textId** - Receives the reserved text ID. + +**Returns** `Integer` - The reserved text ID. + +**Examples** + +Reserve a text ID ahead of time, then create the text later. +``` +` reserve the ID so nothing else grabs it +myTextId = reserve text id() + ` later, use the reserved ID to create the text +font 1, "Fonts/Arial" +text myTextId, 100, 50, 1, "Hello!" +``` + +**Remarks** + +Same pattern as the sprite ID reservation. Use this when you want to set up an ID ahead of timebefore calling [text](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Text) to fill in the details. Handy if you need to wire upreferences between text sprites before they're fully configured. + +--- + +### text + +Creates a text sprite with a position, font, and string content. + +If the ID already exists, it updates the existing text sprite instead of creating a new one. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. If it already exists, the sprite is updated. +- `Integer` **x** - X position in pixels. +- `Integer` **y** - Y position in pixels. +- `Integer` **spriteFontId** - The sprite font ID returned by `font`. +- `String` **text** - The string to display. + +**Examples** + +Create a simple text sprite and display it. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Hello World!" +DO +sync +LOOP +``` + +Update an existing text sprite by reusing the same ID. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "First message" +sync +wait 1000 +` reusing ID 1 updates the text in place +text 1, 100, 50, 1, "Updated message" +sync +``` + +**Remarks** + +This is the main entry point for getting text on screen. You need a font loaded via`font` first, or you'll get nothing. The text sprite won'tactually appear until the next [sync](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sync). Text sprites work almostidentically to regular sprites. They share the same rendering pipeline for z-ordering,render targets, transforms, etc. + +--- + +### set text + +Updates the displayed string of an existing text sprite. + +This changes only the text content. Position, color, scale, and everything else stay the same. + +**Parameters** + +- `Integer` **textId** - The text sprite ID to update. +- `String` **text** - The new string to display. + +**Examples** + +Update a score display every frame. +``` +font 1, "Fonts/Arial" +text 1, 10, 10, 1, "Score: 0" +score = 0 +DO +score = score + 1 +set text 1, "Score: " + str(score) +sync +LOOP +``` + +**Remarks** + +Use this when you need to change what a text sprite says without tearing it down and recreating it.For example, updating a score counter or a status label every frame. If you haven't created thetext sprite yet, call [text](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Text) first. If you also need to resize the spriteto fit the new string, follow up with `size text` or`size text x` since the scale won'tautomatically adjust to the new content. + +--- + +### set text position + +Moves a text sprite to a new screen position. + +This is the text equivalent of `position sprite`. + +**Parameters** + +- `Integer` **textId** - The text sprite ID to move. +- `Integer` **x** - New X position in pixels. +- `Integer` **y** - New Y position in pixels. + +**Examples** + +Animate a text sprite moving across the screen. +``` +font 1, "Fonts/Arial" +text 1, 0, 100, 1, "Moving text!" +xPos = 0 +DO +xPos = xPos + 2 +set text position 1, xPos, 100 +sync +LOOP +``` + +**Remarks** + +Call this whenever you need to reposition a text sprite. Use it every frame for animation, or oncefor static placement. The position is in screen pixels and represents the top-left corner bydefault, but that changes if you've set a custom origin with`set text offset`. If the text sprite is attached to atransform via `attach text to transform`,this position becomes relative to that transform. + +--- + +### color text + +Sets the color of a text sprite using a packed RGBA color value. + +This replaces the current color entirely, alpha included. Use`set text alpha` if you only want to change transparency. + +**Parameters** + +- `Integer` **textId** - The text sprite ID to color. +- `Integer` **colorCode** - Packed RGBA color value. + +**Examples** + +Color text red and display it. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Warning!" +` red with full opacity +color text 1, 0xFF0000FF +DO +sync +LOOP +``` + +**Remarks** + +The color value is a packed integer in RGBA format. This works just like`color sprite` but for text. The color tints the renderedglyphs, so white (`0xFFFFFFFF`) shows the font's original appearance. If the textsprite has a drop shadow enabled, use `color text drop shadow`to color the shadow independently. + +--- + +### color text drop shadow + +Sets the color of a text sprite's drop shadow independently from the main text color. + +The drop shadow must already be enabled via `enable text drop shadow`for this to have any visible effect. + +**Parameters** + +- `Integer` **textId** - The text sprite ID whose shadow color to change. +- `Integer` **colorCode** - Packed RGBA color value for the shadow. + +**Examples** + +Change a drop shadow to a subtle blue after enabling it. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Shadow text" +enable text drop shadow 1, 2, 2, 0x000000FF +` change the shadow color to dark blue with half opacity +color text drop shadow 1, 0x000088AA +DO +sync +LOOP +``` + +**Remarks** + +Use this when you want to change just the shadow color without touching the offset or togglingthe shadow on/off. A common pattern is a dark, semi-transparent shadow. Pack your RGBA witha low alpha for a subtle effect. The shadow is drawn as a second copy of the text at the offsetyou specified when enabling it, so this color applies to that entire second copy. + +--- + +### enable text drop shadow + +Enables a drop shadow on a text sprite and configures its offset and color in one call. + +The shadow is drawn as a second copy of the text rendered behind the original at the given pixel offset. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Integer` **x** - Shadow X offset in pixels from the text position. +- `Integer` **y** - Shadow Y offset in pixels from the text position. +- `Integer` **colorCode** - Packed RGBA color value for the shadow. + +**Examples** + +Add a black drop shadow offset by 2 pixels in each direction. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Readable text" +` black shadow, 2 pixels down and right +enable text drop shadow 1, 2, 2, 0x000000FF +DO +sync +LOOP +``` + +Use a soft, semi-transparent shadow for a subtler effect. +``` +font 1, "Fonts/Arial" +text 1, 200, 100, 1, "Soft shadow" +` dark gray shadow with half opacity, offset 1 pixel +enable text drop shadow 1, 1, 1, 0x33333388 +DO +sync +LOOP +``` + +**Remarks** + +Drop shadows make text more readable over busy backgrounds. The shadow is literally the samestring drawn again at `(x, y)` pixels from the original position, using the color youprovide here. Common values are small offsets like `(2, 2)` with a dark or black color.Once enabled, you can tweak just the color later with`color text drop shadow`, or turn it off entirelywith `disable text drop shadow`. The shadow respectsthe text sprite's scale, rotation, and render target assignment. + +--- + +### disable text drop shadow + +Disables the drop shadow on a text sprite. + +The shadow settings (offset, color) are preserved, so re-enabling later restores the previous look. + +**Parameters** + +- `Integer` **textId** - The text sprite ID whose shadow to disable. + +**Examples** + +Toggle a drop shadow on and off. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Toggle shadow" +enable text drop shadow 1, 2, 2, 0x000000FF +sync +wait 2000 +` turn off the shadow; settings are preserved +disable text drop shadow 1 +sync +wait 2000 +` re-enable with the same offset and color +enable text drop shadow 1, 2, 2, 0x000000FF +sync +``` + +**Remarks** + +Use this to turn off a shadow you previously enabled with`enable text drop shadow`. This is a visibility toggleonly. It doesn't clear the offset or color, so calling`enable text drop shadow` again will bring back thesame shadow without needing to reconfigure it. + +--- + +### set text alpha + +Sets the transparency of a text sprite. + +`0` is fully transparent (invisible) and `255` is fully opaque. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Byte` **alpha** - Alpha value from `0` (transparent) to `255` (opaque). + +**Examples** + +Fade text in from transparent to fully opaque. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Fading in..." +a = 0 +DO +set text alpha 1, a +IF a < 255 THEN a = a + 5 ENDIF +IF a > 255 THEN a = 255 ENDIF +sync +LOOP +``` + +**Remarks** + +This modifies only the alpha channel, leaving the RGB color untouched. If you need tochange both color and alpha at once, use `color text` insteadsince that takes a packed RGBA value. Useful for fade-in/fade-out effects; just tween thealpha value each frame. The drop shadow (if enabled) is not affected by this; it usesthe alpha from its own color set via `color text drop shadow`or `enable text drop shadow`. + +--- + +### scale text + +Sets the X and Y scale factors of a text sprite directly. + +A scale of `1.0` is the font's native size; values below shrink, above enlarge. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Float` **x** - Scale factor on the X axis. `1.0` = native size. +- `Float` **y** - Scale factor on the Y axis. `1.0` = native size. + +**Examples** + +Double the size of a text sprite. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Big text" +` scale to twice the native font size +scale text 1, 2.0, 2.0 +DO +sync +LOOP +``` + +**Remarks** + +This gives you direct control over the scale, unlike `size text`which calculates the scale from a target pixel size. You can set different X and Y valuesto stretch the text non-uniformly, but that usually looks bad for readable text. If youwant uniform scaling to a target pixel width or height, use`size text x` or`size text y` instead. + +--- + +### order text + +Sets the draw order (z-order) for a text sprite. + +Higher values draw on top of lower values, just like regular sprites. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Integer` **order** - The z-order value. Higher = drawn on top. + +**Examples** + +Layer text on top of a sprite using z-order. +``` +font 1, "Fonts/Arial" +` create a sprite and a text label +sprite 1, 100, 100, loadImage("background.png") +order sprite 1, 5 +text 1, 110, 110, 1, "On top!" +order text 1, 10 +DO +sync +LOOP +``` + +**Remarks** + +Text sprites and regular sprites share the same z-order space within a render target,so you can interleave them. For example, a text sprite with order `10` draws on top ofa regular [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) with order `5`. Setting the order marksthe render target's sprite list as dirty, so it will be re-sorted before the next draw. + +--- + +### hide text + +Hides a text sprite so it is not drawn. + +The text sprite still exists and keeps all its properties. It just becomes invisible. + +**Parameters** + +- `Integer` **textId** - The text sprite ID to hide. + +**Examples** + +Hide a text sprite and show it again after a delay. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Now you see me" +sync +wait 2000 +hide text 1 +sync +wait 2000 +show text 1 +sync +``` + +**Remarks** + +Use this instead of destroying and recreating text sprites when you need to toggle visibility.The sprite stays in memory with its position, color, scale, and everything else intact.Call `show text` to make it visible again. This is the textequivalent of hiding a regular [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite). + +--- + +### show text + +Makes a previously hidden text sprite visible again. + +Has no effect if the text sprite is already visible. + +**Parameters** + +- `Integer` **textId** - The text sprite ID to show. + +**Examples** + +Show a hidden text sprite. +``` +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Hidden at first" +hide text 1 +sync +wait 1000 +` make it visible again +show text 1 +DO +sync +LOOP +``` + +**Remarks** + +This is the counterpart to `hide text`. The text spritereappears exactly as it was before hiding, with the same position, color, scale, render target,and everything else. You don't need to reconfigure anything after showing it. + +--- + +### set text render target + +Assigns a text sprite to draw on a specific render target. + +This replaces any previous render target assignment. The text sprite will only draw to the new target. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Integer` **outputId** - The render target ID to draw to. + +**Examples** + +Draw text onto a custom render target. +``` +font 1, "Fonts/Arial" +` create a 256x256 render target +rtId = render target(256, 256) +text 1, 10, 10, 1, "On render target" +` redirect text to the custom target +set text render target 1, rtId +DO +sync +LOOP +``` + +**Remarks** + +By default, text sprites draw to the main screen (render target `1`). Use this toredirect a text sprite to a different render target created with`render target`. This works the same way asrender target assignment for regular sprites. If you want the text sprite to appear onmultiple render targets simultaneously, use`add text render target` instead. To go backto the default, call `reset text render target`. + +--- + +### reset text render target + +Resets a text sprite to draw on the default render target (the main screen). + +This removes any custom render target assignment. + +**Parameters** + +- `Integer` **textId** - The text sprite ID to reset. + +**Examples** + +Move text back to the main screen after drawing to a custom render target. +``` +font 1, "Fonts/Arial" +rtId = render target(256, 256) +text 1, 10, 10, 1, "Temporary" +set text render target 1, rtId +sync +` move it back to the main screen +reset text render target 1 +DO +sync +LOOP +``` + +**Remarks** + +Equivalent to calling `set text render target`with output ID `1`. Use this when you're done drawing a text sprite to an off-screenrender target and want it back on the main screen. + +--- + +### add text render target + +Adds an additional render target for a text sprite without removing existing ones. + +The text sprite will draw to all assigned render targets each frame. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Integer` **outputId** - The additional render target ID to add. + +**Examples** + +Draw the same text on both the main screen and a custom render target. +``` +font 1, "Fonts/Arial" +rtId = render target(256, 256) +text 1, 10, 10, 1, "Everywhere!" +` text already draws to the main screen by default; +` add it to the custom target as well +add text render target 1, rtId +DO +sync +LOOP +``` + +**Remarks** + +Unlike `set text render target` which replacesthe assignment, this stacks on top of whatever targets the text sprite already draws to.Useful when you want the same text to appear on the main screen and also on an off-screenrender target (e.g., a minimap or a UI overlay). Works the same way as adding rendertargets to regular sprites. + +--- + +### size text + +Scales a text sprite to fit exact pixel dimensions for both width and height. + +This calculates independent X and Y scale factors, so the text may stretch non-uniformly. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Float` **xPixels** - Target width in pixels. +- `Float` **yPixels** - Target height in pixels. + +**Examples** + +Scale text to fill a 200x50 pixel box. +``` +font 1, "Fonts/Arial" +text 1, 50, 50, 1, "Stretched to fit" +` scale to exactly 200 wide by 50 tall (may stretch) +size text 1, 200, 50 +DO +sync +LOOP +``` + +**Remarks** + +The command measures the text string using the assigned font and then computes the scaleneeded to fill the target rectangle. Because X and Y are calculated independently, thetext will distort if the aspect ratio doesn't match. If you want to scale uniformly(preserving the font's aspect ratio), use`size text x` or`size text y` instead. If you change the textcontent with `set text`, you'll need to call this again sincethe measured size will be different. + +--- + +### size text x + +Scales a text sprite to a target width in pixels, scaling uniformly to maintain aspect ratio. + +Both X and Y scale are set to the same value, so the text won't stretch or squish. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Float` **xPixels** - Target width in pixels. + +**Examples** + +Scale text uniformly to fit a 300-pixel width. +``` +font 1, "Fonts/Arial" +text 1, 50, 50, 1, "Uniform scale" +` scale so the width is exactly 300 pixels; height adjusts proportionally +size text x 1, 300 +DO +sync +LOOP +``` + +**Remarks** + +This measures the text string's natural width and calculates a uniform scale factor sothe rendered width matches . The height scales proportionally.If the font hasn't been assigned yet, this logs a warning and does nothing. For theheight-based equivalent, see `size text y`. Ifyou need to clamp the resulting scale to a range (e.g., to prevent text from gettingabsurdly large or tiny), use the overload`size text x` thattakes min and max parameters. + +--- + +### size text x + +Scales a text sprite to a target width in pixels with clamped scale bounds, maintaining aspect ratio. + +The computed scale is clamped between and ,preventing the text from becoming too small or too large. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Float` **xPixels** - Target width in pixels. +- `Float` **min** - Minimum allowed scale factor. +- `Float` **max** - Maximum allowed scale factor. + +**Examples** + +Size text to 200 pixels wide, but clamp the scale between 0.5 and 2.0. +``` +font 1, "Fonts/Arial" +text 1, 50, 50, 1, "Clamped scale" +` target 200px wide, but never shrink below 0.5 or grow above 2.0 +size text x 1, 200, 0.5, 2.0 +DO +sync +LOOP +``` + +**Remarks** + +Works like the unclamped `size text x`,but after computing the scale factor it clamps the result to the`[min, max]` range. This is useful when you have dynamic text (like player names orscores) that varies wildly in length. You can target a fixed width but guarantee thetext never scales below a readable minimum or above a maximum that breaks your layout.If the font hasn't been assigned yet, this logs a warning and does nothing. + +--- + +### size text y + +Scales a text sprite to a target height in pixels, scaling uniformly to maintain aspect ratio. + +Both X and Y scale are set to the same value, so the text won't stretch or squish. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Float` **yPixels** - Target height in pixels. + +**Examples** + +Scale text to fit a 40-pixel tall row. +``` +font 1, "Fonts/Arial" +text 1, 50, 50, 1, "Fit the row" +` scale so the height is exactly 40 pixels; width adjusts proportionally +size text y 1, 40 +DO +sync +LOOP +``` + +**Remarks** + +This is the height-based counterpart to`size text x`. It measures the textstring's natural height and calculates a uniform scale factor so the rendered heightmatches . The width scales proportionally. If the fonthasn't been assigned yet, this logs a warning and does nothing. Handy when you wanttext to fit a fixed vertical space (like a UI row) regardless of the string length. + +--- + +### attach text to transform + +Attaches a text sprite to a transform for hierarchical positioning. + +The text sprite's position, rotation, and scale become relative to the transform. + +**Parameters** + +- `Integer` **textId** - The text sprite ID to attach. +- `Integer` **transformId** - The transform ID to attach to, created via `transform`. + +**Examples** + +Make a health label follow a character transform. +``` +font 1, "Fonts/Arial" +` create a transform for the character +tId = transform() +position transform tId, 200, 150 + ` create the label and attach it to the transform +text 1, 0, -20, 1, "100 HP" +attach text to transform 1, tId + ` now moving the transform moves the text too +DO +position transform tId, 200 + rnd(4), 150 +sync +LOOP +``` + +**Remarks** + +Once attached, the text sprite follows the transform as it moves, rotates, and scales.This is how you make text follow a game object. Create a transform with`transform`, attach it to your entity, then attach thetext sprite to that same transform. The text sprite's own position (set via`set text position`) becomes an offset relative to thetransform rather than an absolute screen position. Works identically to how regularsprites attach to transforms. + +--- + +### rotate text + +Sets the rotation of a text sprite to a specific angle in radians. + +The text rotates around its origin point, which defaults to the top-left corner. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Float` **angle** - Rotation angle in radians. `0` = no rotation. + +**Examples** + +Spin text around its center. +``` +font 1, "Fonts/Arial" +text 1, 200, 150, 1, "Spinning!" +` set the origin to center so it rotates in place +set text offset 1, 0.5, 0.5 +angle# = 0.0 +DO +angle# = angle# + 0.02 +rotate text 1, angle# +sync +LOOP +``` + +**Remarks** + +The angle is in radians, not degrees. Use [rad](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Rad) to convert fromdegrees if that's easier to think about. The rotation pivot is the text sprite's origin,which you can change with `set text offset`. Forrotation around the center of the text, set the offset to `(0.5, 0.5)` first.This sets an absolute angle. It doesn't accumulate, so calling it with the same valuetwice has no additional effect. + +--- + +### set text offset + +Sets the origin (pivot point) of a text sprite as a ratio of its measured size. + +`(0, 0)` is the top-left corner, `(0.5, 0.5)` is the center, and `(1, 1)` is the bottom-right. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. +- `Float` **xRatio** - Horizontal origin as a ratio. `0` = left edge, `0.5` = center, `1` = right edge. +- `Float` **yRatio** - Vertical origin as a ratio. `0` = top edge, `0.5` = center, `1` = bottom edge. + +**Examples** + +Center the text origin so it draws centered on its position. +``` +font 1, "Fonts/Arial" +text 1, 400, 300, 1, "Centered!" +` set origin to the center of the text +set text offset 1, 0.5, 0.5 +DO +sync +LOOP +``` + +**Remarks** + +The origin affects where the text sprite "anchors" to its position. By default it's`(0, 0)` (top-left), which means the position you set with`set text position` corresponds to the top-left cornerof the text. Setting it to `(0.5, 0.5)` centers the text on that position, whichis usually what you want for rotation (via `rotate text`)or for centering text in a UI element. The origin also serves as the pivot for scaling. + +--- + +### text x + +Returns the current X position of a text sprite. + +This is the raw position value, not accounting for transform attachment or origin offset. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. + +**Returns** `Float` - The X position in pixels. + +**Examples** + +Read back the X position of a text sprite. +``` +font 1, "Fonts/Arial" +text 1, 150, 80, 1, "Hello" +xPos = text x(1) +print "Text X is: " + str(xPos) +``` + +**Remarks** + +Returns the X component of the position last set by [text](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Text) or`set text position`. If the text sprite is attached toa transform, this still returns the local position, not the final on-screen position.Use this together with `text y` to read back both coordinates. + +--- + +### text y + +Returns the current Y position of a text sprite. + +This is the raw position value, not accounting for transform attachment or origin offset. + +**Parameters** + +- `Integer` **textId** - The text sprite ID. + +**Returns** `Float` - The Y position in pixels. + +**Examples** + +Read back the Y position of a text sprite. +``` +font 1, "Fonts/Arial" +text 1, 150, 80, 1, "Hello" +yPos = text y(1) +print "Text Y is: " + str(yPos) +``` + +**Remarks** + +Returns the Y component of the position last set by [text](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Text) or`set text position`. If the text sprite is attached toa transform, this still returns the local position, not the final on-screen position.Use this together with `text x` to read back both coordinates. + +--- + +### font + +Loads a font from the content pipeline and assigns it to the given ID. + +Call this during setup before you try to render any text. You cannot createa [text](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Text) sprite without a loaded font. + +**Parameters** + +- `Integer` **fontId** - The ID to assign to this font. +- `String` **filePath** - Content path to the font asset, relative to the Content directory (no extension needed). + +**Examples** + +Load a font and create a text sprite with it: +``` +` load a font and display a greeting +font 1, "Fonts/Arial" +text 1, 100, 50, 1, "Hello World!" +``` + +Load multiple fonts for different UI elements: +``` +` load a heading font and a body font +font 1, "Fonts/TitleFont" +font 2, "Fonts/BodyFont" + ` use the title font for the game name +text 1, 200, 50, 1, "My Game" +scale text 1, 2.0, 2.0 + ` use the body font for instructions +text 2, 200, 120, 2, "Press space to start" +``` + +**Remarks** + +Fonts are the first thing you need if you want to draw any text on screen. Loadone here, then pass its ID to [text](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Text) when you create a textsprite. You only need to load a font once; after that, any number of text spritescan share the same font ID. The content path is relative to the Content directory and doesn't need a fileextension. So if your font lives at `Content/Fonts/Arial`, just pass`"Fonts/Arial"`. + +--- + +### free texture id + +Gets the next available texture ID without reserving it. + +The returned ID is not claimed, so another call could grab it before youuse it. If you need a guaranteed slot, use`reserve texture id` instead. + +**Parameters** + +- `Integer` _(ref)_ **textureId** - Receives the next free texture ID. + +**Returns** `Integer` - The next available texture ID. Not yet reserved, just a peek at what is next. + +**Examples** + +Peek at the next available texture ID: +``` +` check what texture ID would be assigned next +nextId = free texture id(nextId) +print nextId +``` + +**Remarks** + +This is handy when you want to peek at what ID is available next without actuallycommitting to it. A common use is to check the next ID for bookkeeping or loggingbefore deciding whether to load a texture. If you plan to actually load something into that slot, prefer`reserve texture id`. It calls thisinternally and then initializes the slot so nothing else can steal the ID outfrom under you. + +--- + +### reserve texture id + +Reserves the next available texture ID and initializes its slot. + +Unlike `free texture id`, thisactually claims the ID so it will not be handed out again. + +**Parameters** + +- `Integer` _(ref)_ **textureId** - Receives the reserved texture ID. + +**Returns** `Integer` - The newly reserved texture ID, ready to be used. + +**Examples** + +Reserve a texture ID for later use with a render target: +``` +` reserve a texture slot before setting up a render target +texId = reserve texture id(texId) +render target 1, 256, 256 +render target texture 1, texId +``` + +**Remarks** + +Use this when you need a texture slot ready before you fill it. For example,when you are about to set up a `render target texture`that writes into a texture, or any other workflow where you need the ID allocatedahead of time. Under the hood, this calls `free texture id`to find the next open slot and then immediately initializes it. After this call,the ID is yours and will not be reused by other texture commands. + +--- + +### texture + +Loads a texture from the content pipeline and assigns it to the given ID. + +This is the main way to get images into Fade. Once loaded, you can assignthe texture to a [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite), split it into frames, or queryits dimensions. + +**Parameters** + +- `Integer` **textureId** - The ID to assign to this texture. Must be unique; loading over an existing ID replaces it. +- `String` **filePath** - Content path to the texture asset, relative to the Content directory (no extension needed). + +**Examples** + +Load a texture and display it as a sprite: +``` +` load a player texture and create a sprite with it +texture 1, "Images/Player" +sprite 1, 100, 100, 1 +``` + +Load a spritesheet texture and set up animation frames: +``` +` load a character spritesheet and split it into a 4x2 grid +texture 1, "Images/CharacterSheet" +set texture frame grid 1, 2, 4 + ` create a sprite and show frame 0 +sprite 1, 100, 100, 1 +set sprite frame 1, 0 +``` + +**Remarks** + +Textures are the raw image data that sprites display. You load one here, thenreference it by ID when creating a [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite). Multiplesprites can share the same texture, which is great for things like particle effectsor tiled backgrounds. The content path is relative to the Content directory and doesn't need a fileextension. If you want to use the texture as a spritesheet, load it first and thencall `set texture frame grid` to carveit into frames. You can also query the loaded texture's size with`texture width` and`texture height`, which is useful for thingslike scaling sprites with `size sprite`. + +--- + +### set texture frame grid + +Splits a texture into a grid of frames for spritesheet animation. + +Each cell in the grid becomes a separate frame you can select with`set sprite frame`. Frames are numbered left-to-right,top-to-bottom, starting at `0`. + +**Parameters** + +- `Integer` **textureId** - The ID of the texture to split. Must already be loaded with `texture`. +- `Integer` **rows** - Number of rows in the grid. Must be at least `1`. +- `Integer` **columns** - Number of columns in the grid. Must be at least `1`. + +**Examples** + +Set up a 4x2 spritesheet and animate it in a loop: +``` +` load a spritesheet and split it into frames +texture 1, "Images/RunCycle" +set texture frame grid 1, 2, 4 + ` create the sprite +sprite 1, 100, 100, 1 + ` animate through frames in the game loop +frame = 0 +totalFrames = texture frames(1) +set sync rate 16 +DO +set sprite frame 1, frame +frame = frame + 1 +IF frame >= totalFrames THEN frame = 0 +sync +LOOP +``` + +**Remarks** + +This is how you turn a single spritesheet image into an animation-ready texture.Say you have a character sheet that is 4 columns wide and 2 rows tall. Call thiswith rows `2` and columns `4`, and you will get 8 frames numbered `0`through `7`. The texture must already be loaded with `texture` beforeyou call this. The command divides the texture evenly, so make sure your spritesheethas uniform cell sizes. If the texture dimensions do not divide evenly by the rowand column count, you will get frames that clip into neighboring cells. After setting up frames, use `set sprite frame` onany sprite using this texture to pick which frame to display. You can check how manyframes a texture has with `texture frames`. + +--- + +### texture frames + +Returns the total number of frames in a texture's frame grid. + +Only meaningful after you have called`set texture frame grid` on the texture. + +**Parameters** + +- `Integer` **textureId** - The ID of the texture to check. Must already be loaded with `texture`. + +**Returns** `Integer` - The number of frames in the texture's frame grid. + +**Examples** + +Use the frame count to loop an animation: +``` +` load a spritesheet and get the total frame count +texture 1, "Images/Explosion" +set texture frame grid 1, 4, 4 +totalFrames = texture frames(1) + ` cycle through all frames +frame = 0 +set sync rate 16 +DO +set sprite frame 1, frame +frame = frame + 1 +IF frame >= totalFrames THEN frame = 0 +sync +LOOP +``` + +**Remarks** + +This tells you how many frames are available for animation on a given texture.It is useful when you are cycling through frames and need to know when to wrapback to `0`. For example, you might set the sprite frame to`currentFrame mod textureFrames` each tick. If you have not called `set texture frame grid`on this texture yet, the frame count will not reflect a grid layout. + +--- + +### texture width + +Returns the width of a texture in pixels. + +**Parameters** + +- `Integer` **textureId** - The ID of the texture to measure. Must already be loaded with `texture`. + +**Returns** `Integer` - The width of the texture in pixels. + +**Examples** + +Size a sprite to match its texture dimensions: +``` +` load a texture and size the sprite to match +texture 1, "Images/Logo" +sprite 1, 100, 100, 1 +w = texture width(1) +h = texture height(1) +size sprite 1, w, h +``` + +**Remarks** + +Handy when you need to know a texture's dimensions for layout or scaling. Forexample, you might use this alongside `texture height`to size a [sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) to match its texture exactly, or tocalculate a custom aspect ratio. You can also grab the pre-calculated ratio directly with`texture aspect` if that is all you need. + +--- + +### texture height + +Returns the height of a texture in pixels. + +**Parameters** + +- `Integer` **textureId** - The ID of the texture to measure. Must already be loaded with `texture`. + +**Returns** `Integer` - The height of the texture in pixels. + +**Examples** + +Use texture height to center a sprite vertically on screen: +``` +` load a texture and center the sprite vertically +texture 1, "Images/Banner" +sprite 1, 0, 0, 1 +h = texture height(1) +screenH = screen height() +yPos = (screenH - h) / 2 +position sprite 1, 0, yPos +``` + +**Remarks** + +Use this when you need to know a texture's vertical size for layout or scaling.Pair it with `texture width` to get the fulldimensions, or use `texture aspect` if youjust need the ratio. This is particularly useful when you want to scale a sprite proportionally.For instance, use `size sprite x` to setthe width and let it calculate the height from the aspect ratio. + +--- + +### texture aspect + +Returns the aspect ratio of a texture, calculated as height divided by width. + +A value greater than `1.0` means the texture is taller than it is wide.Less than `1.0` means it is wider than it is tall. + +**Parameters** + +- `Integer` **textureId** - The ID of the texture to measure. Must already be loaded with `texture`. + +**Returns** `Float` - The height-to-width ratio as a decimal. For example, a 200x100 texture returns `2.0` and a 100x200 texture returns `0.5`. + +**Examples** + +Scale a sprite to a target width while preserving proportions: +``` +` load a texture and scale the sprite proportionally +texture 1, "Images/Portrait" +sprite 1, 50, 50, 1 + ` set a target width and compute the matching height +targetW = 200 +aspect = texture aspect(1) +targetH = targetW * aspect +size sprite 1, targetW, targetH +``` + +**Remarks** + +This saves you from doing the division yourself when you need to scale thingsproportionally. A common pattern is to set a sprite's width to some target sizeand then multiply by the aspect ratio to get the matching height, keeping theimage from looking stretched. If you need the raw pixel dimensions instead, use`texture width` and`texture height`. + +--- + +### free transform id + +Peeks at the next available transform ID without claiming it. + +This doesn't reserve the ID, so another call could grab it before you do. + +**Parameters** + +- `Integer` _(ref)_ **transformId** - Receives the next free transform ID. + +**Returns** `Integer` - The next available transform ID (not yet reserved). + +**Examples** + +Peek at the next ID to size an array, then reserve and create transforms. +``` +` find out what the next ID will be +nextId = free transform id() +print nextId +``` + +**Remarks** + +Most of the time you'll want `reserve transform id`instead, which actually claims the slot. This one is handy if you just need to know whatthe next ID would be, for example to pre-allocate an array. If you already know yourID, skip both of these and call `transform` directly. + +--- + +### reserve transform id + +Claims the next available transform ID and initializes its slot. + +The slot is created but the transform won't affect anything until you set itsposition with `transform` or`set transform position`. + +**Parameters** + +- `Integer` _(ref)_ **transformId** - Receives the reserved transform ID. + +**Returns** `Integer` - The newly reserved transform ID. + +**Examples** + +Reserve IDs for a batch of enemies, then create their transforms. +``` +` reserve five enemy transform IDs +FOR i = 1 TO 5 +id = reserve transform id() +transform id, i * 64, 100 +NEXT i +``` + +**Remarks** + +Use this when you need to wire up references to a transform before it's fullyconfigured. The typical pattern is: reserve an ID, then call`transform` to place it. If you don't need thatsetup step, just call `transform` directly with aknown ID. See also `free transform id` ifyou only need to peek without claiming. + +--- + +### transform + +Creates a transform at the given position. + +Transforms are the backbone of Fade's scene hierarchy. They let you groupsprites, text, and colliders so they all move, rotate, and scale together. + +**Parameters** + +- `Integer` **transformId** - The ID to assign to this transform. +- `Float` **x** - The starting X position. +- `Float` **y** - The starting Y position. + +**Examples** + +Create a full game entity with a transform, sprite, and collider. +``` +` build a player entity at the center of the screen +playerId = 1 +transform playerId, 320, 240 + ` attach a sprite and a collider +sprite playerId, 0, 0 +attach sprite to transform playerId, playerId +box collider playerId, -16, -16, 32, 32 +attach collider to transform playerId, playerId +``` + +Create a parent transform and a child that follows it. +``` +` create a ship and an orbiting shield +shipId = 1 +shieldId = 2 +transform shipId, 320, 240 +transform shieldId, 30, 0 +set transform parent shieldId, shipId + ` moving the ship moves the shield too +set transform position shipId, 400, 240 +``` + +**Remarks** + +This is usually one of the first things you create for a game entity. The typicalpattern looks like this: create a transform here, create a sprite with[sprite](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Sprite) and attach it via`attach sprite to transform`, create acollider with `box collider` and attach it via`attach collider to transform`. Now movingthe transform with `set transform position`moves everything together. Transforms can also be parented to other transforms with`set transform parent`, forming a hierarchy wherechildren inherit their parent's position, rotation, and scale. + +--- + +### set transform position + +Sets the position of a transform. + +If this transform has children (sprites, colliders, or other transforms parentedto it), they all move with it. + +**Parameters** + +- `Integer` **transformId** - The ID of the transform. +- `Float` **x** - The new X position. +- `Float` **y** - The new Y position. + +**Examples** + +Move a player to the right each frame. +``` +` set up the player +playerId = 1 +transform playerId, 0, 240 +px = 0 + set sync rate 16 +DO +px = px + 2 +set transform position playerId, px, 240 +sync +LOOP +``` + +**Remarks** + +Call this every frame for transforms that move, or once for static ones. This isthe main way you drive game object movement. Move the transform, and everythingattached to it follows. The position is local to the transform's parent (if it has one via`set transform parent`). If there's no parent,the position is in screen coordinates. You can read the position back with`get local transform x` and`get local transform y`. + +--- + +### get local transform x + +Returns the local X position of a transform. + +This is the position relative to the transform's parent, not its final worldposition. If the transform has no parent, local and world are the same thing. + +**Parameters** + +- `Integer` **transformId** - The ID of the transform. + +**Returns** `Float` - The local X position. + +**Examples** + +Read the player's X position and print it each frame. +``` +` track the player's horizontal position +playerId = 1 +transform playerId, 100, 200 + set sync rate 16 +DO +px = get local transform x(playerId) +print px +sync +LOOP +``` + +**Remarks** + +Use this to read back whatever you set with`set transform position`. If the transform isparented via `set transform parent`, this returnsthe offset from the parent, not the on-screen position. Pairs with`get local transform y`. + +--- + +### get local transform y + +Returns the local Y position of a transform. + +This is the position relative to the transform's parent, not its final worldposition. If the transform has no parent, local and world are the same thing. + +**Parameters** + +- `Integer` **transformId** - The ID of the transform. + +**Returns** `Float` - The local Y position. + +**Examples** + +Read both X and Y to compute distance from origin. +``` +` check how far the player is from the top-left corner +playerId = 1 +px = get local transform x(playerId) +py = get local transform y(playerId) +dist = sqrt(px * px + py * py) +print dist +``` + +**Remarks** + +Use this to read back whatever you set with`set transform position`. If the transform isparented via `set transform parent`, this returnsthe offset from the parent, not the on-screen position. Pairs with`get local transform x`. + +--- + +### get local transform scale x + +Returns the local X scale of a transform. + +A value of `1.0` is the default (no scaling). This does not account forparent scaling; it is just what you set on this transform. + +**Parameters** + +- `Integer` **transformId** - The ID of the transform. + +**Returns** `Float` - The X scale factor. `1.0` is the default. + +**Examples** + +Check if a transform has been flipped horizontally. +``` +` read the X scale to see if the entity is facing left +sx = get local transform scale x(playerId) +IF sx < 0 THEN +print "facing left" +ENDIF +``` + +**Remarks** + +Reads back the X component of whatever you set with`set transform scale`. Pairs with`get local transform scale y`. + +--- + +### get local transform scale y + +Returns the local Y scale of a transform. + +A value of `1.0` is the default (no scaling). This does not account forparent scaling; it is just what you set on this transform. + +**Parameters** + +- `Integer` **transformId** - The ID of the transform. + +**Returns** `Float` - The Y scale factor. `1.0` is the default. + +**Examples** + +Read both scale axes and print them. +``` +` inspect the current scale of an entity +sx = get local transform scale x(entityId) +sy = get local transform scale y(entityId) +print sx +print sy +``` + +**Remarks** + +Reads back the Y component of whatever you set with`set transform scale`. Pairs with`get local transform scale x`. + +--- + +### set transform scale + +Sets the scale of a transform on the X and Y axes. + +A scale of `1.0` is the default. Children attached to this transform(sprites, text, colliders, and child transforms) inherit the scaling. + +**Parameters** + +- `Integer` **transformId** - The ID of the transform. +- `Float` **x** - The X scale factor. `1.0` is no change, `2.0` is double size. +- `Float` **y** - The Y scale factor. `1.0` is no change, `2.0` is double size. + +**Examples** + +Double the size of an entity uniformly. +``` +` make the boss twice as big +bossId = 10 +transform bossId, 320, 240 +set transform scale bossId, 2.0, 2.0 +``` + +Flip a character horizontally when they change direction. +``` +` flip the sprite to face left by using negative X scale +set transform scale playerId, -1.0, 1.0 +``` + +**Remarks** + +Use this to grow or shrink everything attached to a transform at once. Pass thesame value for both axes for uniform scaling, or different values to stretch.Negative values will flip the attached sprites. You can read the scale back with`get local transform scale x` and`get local transform scale y`. + +--- + +### set transform rotation + +Sets the rotation of a transform in radians. + +Children attached to this transform inherit the rotation, so rotating a parentspins everything attached to it. + +**Parameters** + +- `Integer` **transformId** - The ID of the transform. +- `Float` **angle** - The rotation angle in radians. Use [rad](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Rad) to convert from degrees. + +**Examples** + +Spin an entity continuously each frame. +``` +` rotate a spinning coin +coinId = 3 +transform coinId, 320, 240 +angle = 0.0 + set sync rate 16 +DO +angle = angle + 0.05 +set transform rotation coinId, angle +sync +LOOP +``` + +Set a fixed rotation using degrees. +``` +` tilt an entity 45 degrees +set transform rotation entityId, rad(45) +``` + +**Remarks** + +If you're working in degrees, convert with [rad](/command/Fade.MonoGame.Lib.FadeMonoGameCommands/Rad) first. A fullrotation is roughly `6.283` radians (2*pi). The rotation applies around thetransform's position, which acts as the pivot point. This is the transform-level rotation. Individual sprites can also have their ownrotation via `rotate sprite`, which stacks on top ofwhatever the transform is doing. + +--- + +### set transform parent + +Parents a transform to another transform. + +The child inherits the parent's position, rotation, and scale. The child's ownvalues become relative to the parent rather than the screen. + +**Parameters** + +- `Integer` **transformId** - The ID of the child transform. +- `Integer` **parentTransformId** - The ID of the parent transform to attach to. + +**Examples** + +Create a character with a weapon that follows it. +``` +` set up a character and a weapon +charId = 1 +weaponId = 2 +transform charId, 200, 300 +transform weaponId, 20, -10 + ` parent the weapon to the character +set transform parent weaponId, charId + ` now moving the character moves the weapon too +set sync rate 16 +cx = 200 +DO +cx = cx + 1 +set transform position charId, cx, 300 +sync +LOOP +``` + +Build a three-level hierarchy: ship, turret, and barrel. +``` +` the barrel is offset from the turret, which is offset from the ship +shipId = 1 +turretId = 2 +barrelId = 3 +transform shipId, 320, 400 +transform turretId, 0, -20 +transform barrelId, 10, -15 + set transform parent turretId, shipId +set transform parent barrelId, turretId + ` rotating the ship rotates everything +set transform rotation shipId, rad(30) +``` + +**Remarks** + +This is how you build a scene hierarchy. For example, you might parent a weapontransform to a character transform. Moving the character automatically moves theweapon, and the weapon's position becomes an offset from the character. Re-parenting is supported: calling this on a transform that already has a parentdetaches it from the old parent and attaches to the new one. The system managesreference counts internally. The local getters (`get local transform x`,`get local transform y`) return the positionrelative to the parent, not the final on-screen position. + +--- + +### free tween id + +Peeks at the next available tween ID without claiming it. + +This doesn't reserve the ID, so another call could grab it before you do. + +**Parameters** + +- `Integer` _(ref)_ **tweenId** - Receives the next free tween ID. + +**Returns** `Integer` - The next available tween ID (not yet reserved). + +**Examples** + +Peek at the next tween ID before deciding whether to create one. +``` +` check what the next tween ID would be +nextId = free tween id() +print nextId +``` + +**Remarks** + +Most of the time you'll want `reserve tween id`instead, which actually claims the slot. This one is handy if you just need to knowwhat the next ID would be. If you already know your ID, skip both of these and call`create basic tween` directly. + +--- + +### reserve tween id + +Claims the next available tween ID and initializes its slot. + +The slot is created but the tween won't start until you call`create basic tween` to configure it. + +**Parameters** + +- `Integer` _(ref)_ **tweenId** - Receives the reserved tween ID. + +**Returns** `Integer` - The newly reserved tween ID. + +**Examples** + +Reserve tween IDs for a staggered animation sequence. +``` +` reserve three tween IDs for a multi-part intro +t1 = reserve tween id() +t2 = reserve tween id() +t3 = reserve tween id() + ` now configure them with staggered delays +create basic tween t1, 0, 255, 500, 0 +create basic tween t2, 0, 255, 500, 200 +create basic tween t3, 0, 255, 500, 400 +``` + +**Remarks** + +Use this when you need to set up a tween ID ahead of time, for example to storeit in an array before configuring the actual tween. If you don't need that setupstep, just call `create basic tween` directly with aknown ID. See also `free tween id` if you onlyneed to peek without claiming. + +--- + +### create basic tween + +Creates a tween that smoothly interpolates a value from start to end over a duration. + +Defaults to cubic ease-in-out. Change the curve with`set tween easing` after creation. + +**Parameters** + +- `Integer` **tweenId** - The ID to assign to this tween. +- `Float` **start** - The starting value. +- `Float` **end** - The ending value. +- `Float` **duration** - How long the tween takes, in milliseconds. +- `Float` **delay** - How long to wait before starting, in milliseconds. Pass `0` to start immediately. + +**Examples** + +Slide a sprite from left to right over one second. +``` +` tween the X position from 0 to 640 in 1000ms +tweenId = 1 +spriteId = 1 +create basic tween tweenId, 0, 640, 1000, 0 + set sync rate 16 +DO +x = tweenVal(tweenId) +set transform position spriteId, x, 240 +sync +LOOP +``` + +Fade in a sprite's alpha after a half-second delay. +``` +` fade alpha from 0 to 255 over 800ms, starting after 500ms +tweenId = 2 +create basic tween tweenId, 0, 255, 800, 500 + set sync rate 16 +DO +a = tweenVal(tweenId) +set sprite alpha spriteId, a +sync +LOOP +``` + +**Remarks** + +This is the main entry point for Fade's tween system. Tweens run on real time(milliseconds), not frame counts, so they're smooth regardless of frame rate. Thesystem updates them automatically each frame. The typical pattern is: create a tween, then each frame read its current value with`tweenVal` and use that to drive a position, alpha,scale, or anything else you want to animate. Check`is tween done` to know when it's finished. By default a tween plays once and stops. Use`set tween type` to make it loop or ping-pong. + +--- + +### set tween easing + +Sets the easing function for a tween. + +Call this right after `create basic tween` tooverride the default cubic ease-in-out. + +**Parameters** + +- `Integer` **tweenId** - The ID of the tween. +- `Integer` **easingType** - The easing curve. Common values include linear, ease-in, ease-out, and cubic variants. + +**Examples** + +Create a tween with a linear easing so it moves at constant speed. +``` +` slide a sprite at constant speed +tweenId = 1 +create basic tween tweenId, 0, 640, 2000, 0 +set tween easing tweenId, 0 +``` + +**Remarks** + +The easing type controls the shape of the interpolation curve, whether the tweenstarts slow and speeds up (ease-in), starts fast and slows down (ease-out), orsomething else entirely. If you don't call this, the tween uses cubic ease-in-out, which is a safe defaultfor most UI and game animations. + +--- + +### set tween type + +Sets the execution behavior of a tween (play once, loop, ping-pong, etc.). + +By default tweens play once and stop. Call this right after`create basic tween` to change that. + +**Parameters** + +- `Integer` **tweenId** - The ID of the tween. +- `Integer` **type** - The execution type. Common values: once, loop, ping-pong. + +**Examples** + +Make a sprite bob up and down forever with a ping-pong tween. +``` +` bob between y=200 and y=240 over 1 second, repeating forever +tweenId = 1 +spriteId = 1 +create basic tween tweenId, 200, 240, 1000, 0 +set tween type tweenId, 2 + set sync rate 16 +DO +y = tweenVal(tweenId) +set transform position spriteId, 320, y +sync +LOOP +``` + +**Remarks** + +A looping tween repeats from start to end indefinitely. A ping-pong tween bouncesback and forth between start and end. These are useful for ambient animations likebobbing, pulsing, or breathing effects. Note that `is tween done` will never return`1` for a looping or ping-pong tween, since they never finish. + +--- + +### tweenVal + +Returns the current interpolated value of a tween. + +This is the main output of the tween system, the number that smoothly movesfrom start to end according to the easing curve. + +**Parameters** + +- `Integer` **tweenId** - The ID of the tween. + +**Returns** `Float` - The current tweened value, between start and end. + +**Examples** + +Use a tween to animate a transform's X position. +``` +` smoothly slide an entity from x=50 to x=500 +tweenId = 1 +entityId = 1 +transform entityId, 50, 300 +create basic tween tweenId, 50, 500, 1500, 0 + set sync rate 16 +DO +x = tweenVal(tweenId) +set transform position entityId, x, 300 +sync +LOOP +``` + +Animate scale using two tweens at once. +``` +` grow an entity from half-size to full-size +tweenX = 1 +tweenY = 2 +create basic tween tweenX, 0.5, 1.0, 600, 0 +create basic tween tweenY, 0.5, 1.0, 600, 0 + set sync rate 16 +DO +sx = tweenVal(tweenX) +sy = tweenVal(tweenY) +set transform scale entityId, sx, sy +sync +LOOP +``` + +**Remarks** + +Read this every frame to drive your animation. If you created a tween from `0`to `100`, this will smoothly return values between 0 and 100 as the tweenprogresses. Feed this into `set transform position`,`set sprite alpha`, or anything else youwant to animate. If you need the raw 0-to-1 progress instead of the interpolated value, use`tweenRatio`. + +--- + +### tweenRatio + +Returns the raw progress ratio of a tween, from `0` (just started) to `1` (finished). + +Unlike `tweenVal`, this gives you theun-interpolated progress, useful when you want to drive your own math. + +**Parameters** + +- `Integer` **tweenId** - The ID of the tween. + +**Returns** `Float` - The progress ratio, from `0.0` (just started) to `1.0` (finished). + +**Examples** + +Use the ratio to blend between two colors manually. +``` +` blend from red to blue using the raw ratio +tweenId = 1 +create basic tween tweenId, 0, 1, 2000, 0 + set sync rate 16 +DO +r = tweenRatio(tweenId) +red = 255 * (1.0 - r) +blue = 255 * r +sync +LOOP +``` + +**Remarks** + +Most of the time you'll want `tweenVal` instead, whichgives you the actual number between start and end. This is for cases where you needthe raw 0-to-1 ratio to feed into your own interpolation logic, for exampleblending between two colors or computing a custom curve. + +--- + +### is tween done + +Returns `1` if a tween has finished playing. + +A tween is "done" when its progress ratio reaches `1` or beyond. Loopingand ping-pong tweens never finish. + +**Parameters** + +- `Integer` **tweenId** - The ID of the tween. + +**Returns** `Boolean` - `1` if the tween's progress ratio has reached `1` or beyond. + +**Examples** + +Wait for a slide-in to finish, then print a message. +``` +` slide a title in from the left +tweenId = 1 +create basic tween tweenId, -200, 320, 1000, 0 + set sync rate 16 +DO +x = tweenVal(tweenId) +set transform position titleId, x, 100 + done = is tween done(tweenId) +IF done = 1 THEN +print "title is in place!" +ENDIF + sync +LOOP +``` + +**Remarks** + +Use this to sequence actions after a tween completes, for example destroying anentity after its fade-out finishes, or starting the next animation in a chain. If you need to wait for several tweens at once, use`any tweens running` instead of checking eachone individually. + +--- + +### any tweens running + +Checks if any of the given tweens are still running. + +Returns `1` if at least one tween in the list hasn't finished yet.Returns `0` only when every tween is done. + +**Parameters** + +- `Integer` **tweenIds** - One or more tween IDs to check. + +**Returns** `Boolean` - `1` if at least one tween is still running, `0` if all are done. + +**Examples** + +Wait for all UI tweens to finish before showing a menu. +``` +` kick off three staggered fade-in tweens +t1 = 1 +t2 = 2 +t3 = 3 +create basic tween t1, 0, 255, 400, 0 +create basic tween t2, 0, 255, 400, 150 +create basic tween t3, 0, 255, 400, 300 + ` wait until all three are done +set sync rate 16 +DO +running = any tweens running(t1, t2, t3) +IF running = 0 THEN +print "all animations finished!" +ENDIF +sync +LOOP +``` + +**Remarks** + +This is the batch version of `is tween done`.Instead of checking each tween individually, pass them all in and get a singleanswer. Common use case: you've kicked off several tweens to animate a UI transition,and you want to wait until they're all finished before proceeding. Since this returns `1` while tweens are still going, you'd typically use itin a loop condition: keep calling `sync` while`any tweens running` is true. + +--- + diff --git a/Playground/scripts/build-docs-index.mjs b/Playground/scripts/build-docs-index.mjs new file mode 100644 index 0000000..b3ddd8c --- /dev/null +++ b/Playground/scripts/build-docs-index.mjs @@ -0,0 +1,166 @@ +// Build script: walks the configured doc sources (see docs-sources.mjs), +// chunks each markdown file, embeds every chunk with bge-small-en-v1.5, +// and writes public/docs-index.json for the runtime to serve. +// +// Usage: +// npm run build:docs-index +// +// The model downloads to ~/.cache/huggingface/ on first run (~30 MB) and +// is reused thereafter. The output JSON is committed-or-not at your +// discretion — it's small (typically <1 MB) and deterministic given the +// same docs + same model, but rebuilding from source is cheap. + +import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve, dirname, relative, posix } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { DOC_SOURCES } from './docs-sources.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const playgroundDir = resolve(__dirname, '..'); +const outPath = resolve(playgroundDir, 'public', 'docs-index.json'); + +// We import the TS modules directly via Vite-compatible source paths. +// Node + TypeScript-from-source requires tsx, ts-node, or pre-built JS. +// Simplest: use `node --experimental-strip-types`. Available since Node 22.6. +// +// If the user is on older Node, they can do `npx tsx scripts/build-docs-index.mjs`. +// We don't pre-build here to keep the toolchain minimal. + +const srcRoot = resolve(playgroundDir, 'src', 'ai', 'rag'); +const { Embedder } = await import(pathToFileURL(resolve(srcRoot, 'embedder.ts')).href); +const { chunkMarkdown } = await import(pathToFileURL(resolve(srcRoot, 'chunker.ts')).href); +const { EMBEDDING_DIM, EMBEDDING_MODEL, INDEX_VERSION } = await import(pathToFileURL(resolve(srcRoot, 'types.ts')).href); + +// ─── Walk + chunk ─────────────────────────────────────────────────────────── + +console.log('[build:docs-index] walking doc sources'); +const allChunks = []; +let sourceCount = 0; + +for (const src of DOC_SOURCES) { + if (!existsSync(src.root)) { + console.warn(`[build:docs-index] skipping ${src.label}: ${src.root} does not exist`); + continue; + } + const files = await walk(src.root, src.glob); + const scopeNote = src.projectTypes?.length + ? ` [scoped to projectTypes=${JSON.stringify(src.projectTypes)}]` + : ''; + console.log(`[build:docs-index] ${src.label}: ${files.length} file(s)${scopeNote}`); + sourceCount += files.length; + + for (const file of files) { + const text = await readFile(file, 'utf-8'); + const relPath = posix.join(src.label, relative(src.root, file).split(/[\\/]/).join('/')); + const chunks = chunkMarkdown({ source: relPath, text }); + console.log(` - ${relPath}: ${chunks.length} chunk(s)`); + for (const c of chunks) { + // Tag the chunk with the source's projectTypes (when set) so the + // runtime Retriever can gate retrieval by active project type. + // Stored as a plain field; absent / empty arrays are treated as + // "always include". + if (src.projectTypes?.length) c.projectTypes = [...src.projectTypes]; + allChunks.push(c); + } + } +} + +console.log(`[build:docs-index] total chunks: ${allChunks.length}`); + +if (allChunks.length === 0) { + console.warn('[build:docs-index] no chunks produced — writing empty index'); + await writeIndex([]); + process.exit(0); +} + +// ─── Embed ────────────────────────────────────────────────────────────────── + +console.log(`[build:docs-index] loading embedder (${EMBEDDING_MODEL})…`); +const embedder = new Embedder({ + device: 'cpu', + dtype: 'fp32', + onProgress: (info) => { + if (info?.status === 'progress' && typeof info.progress === 'number') { + // Throttle: only log at 25/50/75/100% milestones per file. + const pct = Math.round(info.progress); + if (pct % 25 === 0) console.log(` [${info.file ?? '?'}] ${pct}%`); + } else if (info?.status === 'ready') { + console.log(` loaded ${info.file ?? ''}`); + } + }, +}); +await embedder.ensureReady(); + +console.log('[build:docs-index] embedding chunks…'); +const BATCH = 16; +const t0 = Date.now(); +const enriched = []; + +for (let i = 0; i < allChunks.length; i += BATCH) { + const batch = allChunks.slice(i, i + BATCH); + const vectors = await embedder.embedPassage(batch.map(c => c.text)); + for (let j = 0; j < batch.length; j++) { + const v = vectors[j]; + if (v.length !== EMBEDDING_DIM) { + throw new Error(`Embedding dim mismatch: got ${v.length}, expected ${EMBEDDING_DIM}`); + } + const row = { + id: batch[j].id, + source: batch[j].source, + heading: batch[j].heading, + text: batch[j].text, + chars: batch[j].text.length, + // Plain number[] so JSON.parse hydrates directly; runtime + // converts to Float32Array on demand. + vector: Array.from(v), + }; + if (batch[j].projectTypes?.length) row.projectTypes = batch[j].projectTypes; + enriched.push(row); + } + process.stdout.write(`\r embedded ${Math.min(i + BATCH, allChunks.length)}/${allChunks.length}`); +} +console.log(`\n[build:docs-index] embedded in ${((Date.now() - t0) / 1000).toFixed(1)}s`); + +await writeIndex(enriched); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function writeIndex(chunks) { + const index = { + version: INDEX_VERSION, + model: EMBEDDING_MODEL, + dim: EMBEDDING_DIM, + builtAt: new Date().toISOString(), + sourceCount, + chunks, + }; + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, JSON.stringify(index)); + const bytes = (await stat(outPath)).size; + console.log(`[build:docs-index] wrote ${outPath} (${(bytes / 1024).toFixed(1)} KB, ${chunks.length} chunks)`); +} + +async function walk(root, glob) { + // We support only `**/*.`-style globs for simplicity. If we need + // more, swap in fast-glob — but a few-line manual walk avoids adding + // a build dep we don't otherwise need. + const m = glob.match(/\.([a-z0-9]+)$/i); + const wantExt = m ? `.${m[1]}` : '.md'; + const out = []; + async function rec(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const full = resolve(dir, e.name); + if (e.isDirectory()) { + await rec(full); + } else if (e.isFile() && e.name.toLowerCase().endsWith(wantExt)) { + out.push(full); + } + } + } + await rec(root); + out.sort(); + return out; +} diff --git a/Playground/scripts/build-monogame-runtime.mjs b/Playground/scripts/build-monogame-runtime.mjs new file mode 100644 index 0000000..e731ef6 --- /dev/null +++ b/Playground/scripts/build-monogame-runtime.mjs @@ -0,0 +1,116 @@ +// Mirrors build-runtime.mjs but publishes WebRuntime.MonoGame (KNI BlazorGL) +// into Playground/public/runtime/monogame/ so Vite can serve it from the same +// origin as the Playground page when a 'monogame' fade.json project is open. +// +// Layout under public/runtime/ (mg-export-3.md phase 3): +// public/runtime/web/ ← Export.Web template (build-runtime.mjs) +// public/runtime/monogame/ ← this script's output (WebRuntime.MonoGame template) +// public/runtime/fade-libs/ ← shared command DLLs (build-runtime.mjs) +// +// The two templates' boot styles differ on purpose; see Playground/mg.md. + +import { execSync } from 'node:child_process'; +import { rm, mkdir, cp, copyFile, readdir, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, relative } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const playgroundDir = resolve(__dirname, '..'); +const runtimeProject = resolve(playgroundDir, '..', '..', 'Fade.MonoGame', 'Fade.MonoGame', 'WebRuntime.MonoGame', 'WebRuntime.MonoGame.csproj'); +const publishOut = resolve( + playgroundDir, '..', '..', 'Fade.MonoGame', 'Fade.MonoGame', 'WebRuntime.MonoGame', + 'bin', 'Release', 'net8.0', 'publish', 'wwwroot', +); +const targetDir = resolve(playgroundDir, 'public', 'runtime', 'monogame'); +// Old (pre-restructure) location. We wipe it once on next build so +// stale leftover assets don't keep getting served. Safe to remove this +// guard a few weeks after the rename has shipped. +const legacyTargetDir = resolve(playgroundDir, 'public', 'monogame-runtime'); + +console.log('[build:monogame-runtime] dotnet publish', runtimeProject); +execSync(`dotnet publish "${runtimeProject}" -c Release /p:FadeMonoGamePlatform=Web`, { + stdio: 'inherit', +}); + +if (!existsSync(publishOut)) { + console.error(`[build:monogame-runtime] expected publish output at ${publishOut} but it does not exist.`); + process.exit(1); +} + +// One-time cleanup of pre-restructure layout. Safe to remove this block +// once everyone's rebuilt past the rename. +if (existsSync(legacyTargetDir)) { + console.log('[build:monogame-runtime] removing legacy', legacyTargetDir); + await rm(legacyTargetDir, { recursive: true, force: true }); +} + +console.log('[build:monogame-runtime] clearing', targetDir); +await rm(targetDir, { recursive: true, force: true }); +await mkdir(targetDir, { recursive: true }); + +console.log('[build:monogame-runtime] copying', publishOut, '→', targetDir); +await cp(publishOut, targetDir, { recursive: true }); + +// ── Command libs for the LSP ──────────────────────────────────────── +// The LSP worker (FadeBasic.Export.Web in /runtime/web/) needs to know +// the MonoGame command surface for hover, completion, and parse — even +// though it never *executes* monogame commands (the iframe's Game1 owns +// execution). Stage Lib + its required project-deps as real .dll files +// (not the renamed-to-.wasm Blazor variants — those are real WASM modules +// in .NET 8, not loadable via Assembly.Load) so main.ts's LSP-sync can +// fetch + load them when fade.json declares type='monogame'. +// +// Why Game + Contracts: Fade.MonoGame.Lib's csproj has +// ProjectReference → Fade.MonoGame.Game, which transitively pulls in +// Contracts. Activator.CreateInstance(FadeMonoGameCommands) shouldn't +// touch their types eagerly (class-level only references IMethodSource +// from FadeBasic), but pre-loading is cheap insurance. KNI BlazorGL + +// MonoGame.Framework are NOT staged — they're huge and the LSP never +// needs them: method bodies aren't JITed during metadata enumeration. +const monoLibsSrc = resolve( + playgroundDir, '..', '..', 'Fade.MonoGame', 'Fade.MonoGame', 'WebRuntime.MonoGame', + 'bin', 'Release', 'net8.0', +); +const fadeLibsDir = resolve(playgroundDir, 'public', 'runtime', 'fade-libs'); +await mkdir(fadeLibsDir, { recursive: true }); +const monoCommandLibs = [ + 'Fade.MonoGame.Contracts.dll', + 'Fade.MonoGame.Game.dll', + 'Fade.MonoGame.Lib.dll', +]; +for (const name of monoCommandLibs) { + const src = resolve(monoLibsSrc, name); + if (!existsSync(src)) { + console.error(`[build:monogame-runtime] expected ${src} but it does not exist.`); + process.exit(1); + } + await copyFile(src, resolve(fadeLibsDir, name)); + console.log(`[build:monogame-runtime] staged ${name} → public/runtime/fade-libs/`); +} + +// ── Runtime manifest ────────────────────────────────────────────────────────── +// Enumerate every file under public/runtime/monogame/ so the Playground's +// export bundler knows what to include in the static-host zip. Same shape as +// build-runtime.mjs's web manifest — the Playground reads either at zip time +// based on the active project's type. Paths are POSIX-style relative to the +// monogame/ subtree. +async function walk(dir) { + const out = []; + for (const ent of await readdir(dir, { withFileTypes: true })) { + const full = resolve(dir, ent.name); + if (ent.isDirectory()) out.push(...await walk(full)); + else if (ent.isFile()) out.push(full); + } + return out; +} +const allFiles = await walk(targetDir); +const relPaths = allFiles + .map((f) => relative(targetDir, f).split('\\').join('/')) + .filter((p) => p !== 'runtime-manifest.json') + .sort(); +const manifestPath = resolve(targetDir, 'runtime-manifest.json'); +await writeFile(manifestPath, JSON.stringify({ files: relPaths }, null, 2)); +console.log(`[build:monogame-runtime] wrote runtime-manifest.json (${relPaths.length} entries)`); + +console.log('[build:monogame-runtime] done.'); diff --git a/Playground/scripts/build-runtime.mjs b/Playground/scripts/build-runtime.mjs new file mode 100644 index 0000000..de921b3 --- /dev/null +++ b/Playground/scripts/build-runtime.mjs @@ -0,0 +1,123 @@ +// Publishes FadeBasic.Export.Web in Release and copies the resulting wwwroot/* +// into Playground/public/runtime/web/ so Vite can serve the runner from the same +// origin as the Playground page (workers require same-origin). +// +// Layout under public/runtime/ (mg-export-3.md phase 3): +// public/runtime/web/ ← this script's output (Export.Web template) +// public/runtime/monogame/ ← build-monogame-runtime.mjs's output +// public/runtime/fade-libs/ ← shared command DLLs (this script writes these) + +import { execSync } from 'node:child_process'; +import { rm, mkdir, cp, copyFile, writeFile, readdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, relative, posix } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const playgroundDir = resolve(__dirname, '..'); +const runtimeProject = resolve(playgroundDir, '..', 'FadeBasic', 'FadeBasic.Export.Web', 'FadeBasic.Export.Web.csproj'); +const publishOut = resolve(playgroundDir, '..', 'FadeBasic', 'FadeBasic.Export.Web', 'bin', 'Release', 'net8.0', 'publish', 'wwwroot'); +const runtimeRoot = resolve(playgroundDir, 'public', 'runtime'); +const targetDir = resolve(runtimeRoot, 'web'); + +console.log('[build:runtime] dotnet publish', runtimeProject); +execSync(`dotnet publish "${runtimeProject}" -c Release`, { + stdio: 'inherit', +}); + +if (!existsSync(publishOut)) { + console.error(`[build:runtime] expected publish output at ${publishOut} but it does not exist.`); + process.exit(1); +} + +// One-time cleanup of pre-restructure layout: the old flat layout dropped +// every Export.Web file directly into public/runtime/. Now everything goes +// under public/runtime/web/. Wipe any leftover files at the top level so +// stale `_framework/` / `index.html` / etc. don't shadow the new web/ tree. +// Preserves the sibling subdirs (web/, monogame/, fade-libs/) which are +// managed by this script and build-monogame-runtime.mjs. +const keepAtRoot = new Set(['web', 'monogame', 'fade-libs']); +if (existsSync(runtimeRoot)) { + for (const ent of await readdir(runtimeRoot, { withFileTypes: true })) { + if (keepAtRoot.has(ent.name)) continue; + const full = resolve(runtimeRoot, ent.name); + console.log('[build:runtime] cleaning stale', full); + await rm(full, { recursive: true, force: true }); + } +} + +console.log('[build:runtime] clearing', targetDir); +await rm(targetDir, { recursive: true, force: true }); +await mkdir(targetDir, { recursive: true }); + +console.log('[build:runtime] copying', publishOut, '→', targetDir); +await cp(publishOut, targetDir, { recursive: true }); + +console.log('[build:runtime] done.'); + +// ── Command libs ────────────────────────────────────────────────────────────── +// Build each preloaded command library and stage its DLL under +// public/runtime/fade-libs/ so the Playground can fetch and dynamically load +// it at runtime without FadeBasic.Export.Web needing a compile-time reference. +// fade-libs lives at the runtime root (not under web/) because both +// templates may need to load DLLs from it. +// Don't wipe the whole fade-libs dir — build-monogame-runtime.mjs stages +// its own DLLs there (Fade.MonoGame.{Contracts,Game,Lib}.dll). Wiping the +// directory means running `npm run dev` (predev → build-runtime) after a +// prior `build:monogame-runtime` deletes the monogame DLLs, which breaks +// the LSP's command highlighting for monogame projects. Just ensure the +// dir exists and overwrite our own DLLs below. +const fadeLibsDir = resolve(runtimeRoot, 'fade-libs'); +await mkdir(fadeLibsDir, { recursive: true }); + +const commandLibs = [ + { + project: resolve(playgroundDir, '..', 'FadeBasic', 'FadeBasic.Lib.Web', 'FadeBasic.Lib.Web.csproj'), + dll: resolve(playgroundDir, '..', 'FadeBasic', 'FadeBasic.Lib.Web', 'bin', 'Release', 'net8.0', 'FadeBasic.Lib.Web.dll'), + name: 'FadeBasic.Lib.Web.dll', + }, +]; + +for (const lib of commandLibs) { + console.log(`[build:runtime] dotnet build ${lib.name}`); + execSync(`dotnet build "${lib.project}" -c Release`, { stdio: 'inherit' }); + if (!existsSync(lib.dll)) { + console.error(`[build:runtime] expected DLL at ${lib.dll} but it does not exist.`); + process.exit(1); + } + await copyFile(lib.dll, resolve(fadeLibsDir, lib.name)); + console.log(`[build:runtime] staged ${lib.name} → public/runtime/fade-libs/`); +} + +// ── Runtime manifest ────────────────────────────────────────────────────────── +// Enumerate every file under public/runtime/web/ so the Playground's export +// download knows what to bundle. We can't list files via fetch on a static +// host, so emit a JSON index at build time. Paths are POSIX-style relative +// to the web/ subtree (e.g. "_framework/dotnet.js", "index.html"). The +// manifest is consumed by main.ts's web-export bundler; monogame export +// has its own (future) manifest under public/runtime/monogame/. +async function walk(dir) { + const out = []; + for (const ent of await readdir(dir, { withFileTypes: true })) { + const full = resolve(dir, ent.name); + if (ent.isDirectory()) { + out.push(...await walk(full)); + } else if (ent.isFile()) { + out.push(full); + } + } + return out; +} +const allFiles = await walk(targetDir); +// Skip: +// - The manifest itself (avoid self-reference). +// - index.html: the export bundles its own copy from /runtime/, so it's +// fine to include — keeping it in the manifest is simpler than the +// per-export carve-out. +const manifestPath = resolve(targetDir, 'runtime-manifest.json'); +const relPaths = allFiles + .map((f) => relative(targetDir, f).split('\\').join('/')) + .filter((p) => p !== 'runtime-manifest.json') + .sort(); +await writeFile(manifestPath, JSON.stringify({ files: relPaths }, null, 2)); +console.log(`[build:runtime] wrote runtime-manifest.json (${relPaths.length} entries)`); diff --git a/Playground/scripts/check-page.mjs b/Playground/scripts/check-page.mjs new file mode 100644 index 0000000..b4e303e --- /dev/null +++ b/Playground/scripts/check-page.mjs @@ -0,0 +1,379 @@ +// Headless check of the Playground page. Captures console messages, errors, +// and key DOM signals so we can iterate without bouncing through the human. +// +// Usage: node scripts/check-page.mjs [--run] [url] [timeoutMs] + +import { chromium } from 'playwright'; + +const positional = process.argv.slice(2).filter((a) => !a.startsWith('--')); +const url = positional[0] || 'http://localhost:5311/'; +const timeoutMs = Number(positional[1] || 45000); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext(); +const page = await context.newPage(); + +const messages = []; +const pageErrors = []; + +page.on('console', (msg) => { + messages.push({ type: msg.type(), text: msg.text() }); +}); +page.on('pageerror', (err) => { + pageErrors.push({ message: err.message, stack: err.stack }); +}); +page.on('console', (msg) => { + // Already captured above; surface deep stacks via printing args too +}); +page.on('requestfailed', (req) => { + messages.push({ + type: 'requestfailed', + text: `${req.method()} ${req.url()} ${req.failure()?.errorText ?? ''}`, + }); +}); + +try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); +} catch (e) { + console.error('navigation failed:', e.message); + await browser.close(); + process.exit(2); +} + +// Wait for bootstrap to finish (success or failure). +const settled = await page + .waitForFunction( + () => { + const w = window; + const status = document.getElementById('status')?.textContent ?? ''; + if (status.startsWith('Bootstrap failed:')) { + return { kind: 'bootstrap-failed', text: status }; + } + if (w.__fadeBootstrapDone) { + return { kind: 'done', status }; + } + return false; + }, + { timeout: timeoutMs }, + ) + .then((h) => h.jsonValue()) + .catch(() => null); + +const workbenchPresent = await page.evaluate( + () => document.querySelector('.monaco-workbench') != null, +); +const editorPresent = await page.evaluate( + () => document.querySelector('.monaco-editor') != null, +); +const activityBarPresent = await page.evaluate( + () => document.querySelector('.activitybar') != null, +); +const statusBarPresent = await page.evaluate( + () => document.querySelector('.statusbar') != null, +); + +const partsPresent = await page.evaluate(() => { + const parts = { + editorPart: !!document.querySelector('.part.editor'), + sidebarPart: !!document.querySelector('.part.sidebar'), + panelPart: !!document.querySelector('.part.panel'), + statusbarPart: !!document.querySelector('.part.statusbar'), + titlebarPart: !!document.querySelector('.part.titlebar'), + welcomePage: !!document.querySelector('.editor-instance .welcome-page, .gettingStartedContainer'), + }; + return parts; +}); + +// Wait for any post-bootstrap async work to complete (setTimeouts, etc.) +// BEFORE we snapshot console messages. +if (process.argv.includes('--shot') || process.argv.includes('--run')) { + await new Promise((r) => setTimeout(r, 5000)); +} + +const editorContent = await page.evaluate(() => { + // Try to read the active editor's view-lines text content + const lines = document.querySelectorAll('.monaco-editor .view-lines .view-line'); + return Array.from(lines).map((l) => l.textContent).slice(0, 4).join('\n'); +}); + +const editorBox = await page.evaluate(() => { + const editor = document.querySelector('.monaco-editor'); + if (!editor) return null; + const r = editor.getBoundingClientRect(); + const cs = getComputedStyle(editor); + return { + width: r.width, height: r.height, + visibility: cs.visibility, display: cs.display, opacity: cs.opacity, + viewLinesCount: editor.querySelectorAll('.view-line').length, + }; +}); + +// Inspect model state via monaco from the page +const modelState = await page.evaluate(() => { + const w = window; + const m = w.monaco; + if (!m) return null; + const models = m.editor.getModels(); + return models.map((mod) => ({ + uri: mod.uri.toString(), + languageId: mod.getLanguageId(), + lineCount: mod.getLineCount(), + })); +}); +console.log('models:', JSON.stringify(modelState)); + +// Probe the workbench parts dimensions too. Each "part" is `
` +const partsSizes = await page.evaluate(() => { + const partNames = ['editor', 'sidebar', 'panel', 'auxiliarybar', 'activitybar', 'statusbar', 'titlebar', 'banner']; + return Object.fromEntries(partNames.map((name) => { + const el = document.querySelector('.part.' + name); + if (!el) return [name, null]; + const r = el.getBoundingClientRect(); + return [name, { w: Math.round(r.width), h: Math.round(r.height) }]; + })); +}); + +// And the body / workbench root +const rootSize = await page.evaluate(() => { + const wb = document.querySelector('.monaco-workbench'); + if (!wb) return null; + const r = wb.getBoundingClientRect(); + return { w: Math.round(r.width), h: Math.round(r.height) }; +}); + +const tabLabels = await page.evaluate(() => { + return Array.from(document.querySelectorAll('.tabs-container .tab .tab-label')) + .map((t) => t.textContent) + .slice(0, 8); +}); + +console.log('---'); +console.log('settled:', settled ? JSON.stringify(settled) : '(timed out)'); +console.log('workbench mounted:', workbenchPresent); +console.log('editor present:', editorPresent); +console.log('activity bar present:', activityBarPresent); +console.log('status bar present:', statusBarPresent); +console.log('workbench parts:', JSON.stringify(partsPresent)); +console.log('editor box:', JSON.stringify(editorBox)); +console.log('workbench root:', JSON.stringify(rootSize)); +console.log('parts sizes:', JSON.stringify(partsSizes)); + +// What's inside the editor part? +const editorPartHtml = await page.evaluate(() => { + const el = document.querySelector('.part.editor'); + if (!el) return null; + const r = el.getBoundingClientRect(); + const parent = el.parentElement; + const pr = parent?.getBoundingClientRect(); + return { + outerHTML: el.outerHTML.slice(0, 500), + rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + parentRect: pr ? { x: Math.round(pr.x), y: Math.round(pr.y), w: Math.round(pr.width), h: Math.round(pr.height) } : null, + parentClass: parent?.className, + }; +}); +console.log('editor part:', JSON.stringify(editorPartHtml, null, 2)); + +// Find ALL .monaco-editor instances and where they live +const monacoEditors = await page.evaluate(() => { + return Array.from(document.querySelectorAll('.monaco-editor')).map((el) => { + const r = el.getBoundingClientRect(); + const ancestors = []; + let p = el.parentElement; + while (p && ancestors.length < 6) { + const cls = p.className?.toString().slice(0, 50) || ''; + const id = p.id ? '#' + p.id : ''; + ancestors.push(p.tagName + id + (cls ? '.' + cls : '')); + p = p.parentElement; + } + return { + w: Math.round(r.width), + h: Math.round(r.height), + viewLines: el.querySelectorAll('.view-line').length, + ancestors, + }; + }); +}); +console.log('all monaco-editors:', JSON.stringify(monacoEditors, null, 2)); + +// Walk up the .part.editor chain capturing inline styles +const editorChain = await page.evaluate(() => { + const out = []; + let el = document.querySelector('.part.editor'); + while (el && out.length < 10) { + const r = el.getBoundingClientRect(); + out.push({ + tag: el.tagName, + class: el.className?.toString().slice(0, 60), + inlineStyle: el.style?.cssText?.slice(0, 200), + w: Math.round(r.width), + h: Math.round(r.height), + }); + el = el.parentElement; + } + return out; +}); +console.log('editor part chain:', JSON.stringify(editorChain, null, 2)); +console.log('open tabs:', tabLabels); +console.log('editor first lines:'); +console.log(editorContent.split('\n').map((l) => ' ' + l).join('\n')); +console.log('---'); + +const errors = messages.filter((m) => m.type === 'error' || m.type === 'requestfailed'); +if (errors.length || pageErrors.length) { + console.log('Errors (' + (errors.length + pageErrors.length) + '):'); + for (const e of errors) console.log(' [' + e.type + '] ' + e.text); + for (const e of pageErrors) { + console.log(' [pageerror] ' + e.message); + if (e.stack) { + for (const line of e.stack.split('\n').slice(0, 6)) console.log(' ' + line); + } + } +} + +const interesting = messages.filter((m) => + m.text.includes('[fade]') || + m.text.includes('[fade-lsp]') || + m.text.includes('[runtime worker]') || + m.text.includes('[lsp worker]') +); +if (interesting.length) { + console.log('Log lines (' + interesting.length + '):'); + for (const l of interesting) console.log(' [' + l.type + '] ' + l.text); +} +console.log('All log messages (last 10):'); +for (const m of messages.filter((m) => m.type === 'log' || m.type === 'info' || m.type === 'warning' || m.type === 'error').slice(-10)) { + console.log(' [' + m.type + '] ' + m.text.slice(0, 200)); +} + +const warns = messages.filter((m) => m.type === 'warning'); +if (warns.length) { + console.log('Warnings (' + warns.length + '):'); + for (const w of warns.slice(0, 8)) console.log(' ' + w.text); + if (warns.length > 8) console.log(' ... and ' + (warns.length - 8) + ' more'); +} + +// If --lsp is given, introduce a syntax error and check diagnostics flow. +// To avoid HMR-induced double-bootstrap pollution in tests, do an explicit +// page reload once we know one bootstrap completed. After reload only ONE +// bootstrap will be in play. +if (settled?.kind === 'done') { + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window).__fadeBootstrapDone, { timeout: 30000 }).catch(() => null); + await new Promise((r) => setTimeout(r, 1500)); +} + +if (process.argv.includes('--hover') && settled?.kind === 'done') { + console.log('--- triggering hover ---'); + // Wait for the file to be loaded + LSP to have processed it + await new Promise((r) => setTimeout(r, 2000)); + // Move mouse over a known position (line 1 char 5 — middle of "print") + await page.evaluate(() => { + const editor = (window).monaco.editor.getEditors()[0]; + if (editor) { + // Trigger the editor hover at a known position + editor.trigger('test', 'editor.action.showHover', { + position: { lineNumber: 1, column: 5 }, + }); + } + }); + await new Promise((r) => setTimeout(r, 1500)); + const hoverWidget = await page.evaluate(() => { + return document.querySelector('.monaco-hover')?.textContent ?? null; + }); + console.log(' hover widget text:', hoverWidget); +} + +if (process.argv.includes('--lsp') && settled?.kind === 'done') { + console.log('--- introducing a syntax error ---'); + // Use the EDITOR captured at runtime — same path our polling uses now. + const setResult = await page.evaluate(() => { + const m = window.monaco; + const editors = m.editor.getEditors(); + if (!editors.length) return { error: 'no editor' }; + // Try to find the editor with a fade model + const ed = editors.find((e) => e.getModel()?.getLanguageId() === 'fade') ?? editors[0]; + const model = ed.getModel(); + if (!model) return { error: 'no model on editor' }; + model.applyEdits([{ range: model.getFullModelRange(), text: 'this is not valid fade %@$' }]); + return { uri: model.uri.toString(), value: model.getValue().slice(0, 50), editorCount: editors.length }; + }); + console.log(' setValue result:', JSON.stringify(setResult)); + // Wait for polling LSP push + diagnostics return + await new Promise((r) => setTimeout(r, 4000)); + + const problems = await page.evaluate(() => { + const items = document.querySelectorAll('#problems-list .problem-item'); + return Array.from(items).map((el) => el.textContent); + }); + const markerCount = await page.evaluate(() => { + const m = window.monaco; + return m.editor.getModelMarkers({}).length; + }); + console.log(' marker count:', markerCount); + console.log(' problems list items:', problems.length); + for (const p of problems.slice(0, 5)) console.log(' ', p); + + // Test hover at the error location + const hover = await page.evaluate(async () => { + const m = window.monaco; + const editors = m.editor.getEditors(); + if (!editors[0]) return null; + // Just trigger a hover; we can't read the widget easily + const model = editors[0].getModel(); + if (!model) return null; + return { uri: model.uri.toString(), value: model.getValue().slice(0, 50) }; + }); + console.log(' model after edit:', JSON.stringify(hover)); +} + +// If --run is given AND bootstrap finished AND no fatal errors, click Run +// and check the output. +const shouldRun = process.argv.includes('--run'); +if (shouldRun && settled?.kind === 'done') { + console.log('--- clicking Run ---'); + await page.click('#run'); + try { + await page.waitForFunction( + () => { + const t = document.getElementById('output')?.textContent ?? ''; + return t.length > 0 && t !== '(not yet run)' && !t.startsWith('Running'); + }, + { timeout: 15000 }, + ); + } catch { + console.error(' output did not populate within 15s'); + } + const out = await page.evaluate( + () => document.getElementById('output')?.textContent ?? '', + ); + console.log(' output (first 400 chars):'); + console.log(out.slice(0, 400).split('\n').map((l) => ' ' + l).join('\n')); +} + +// How many stylesheets are adopted? +const adoptedCount = await page.evaluate(() => document.adoptedStyleSheets?.length ?? 0); +console.log('adopted stylesheets:', adoptedCount); + +// Save a screenshot for visual debugging. +if (process.argv.includes('--shot')) { + // Wait an extra moment so any late settling shows up + await new Promise((r) => setTimeout(r, 3000)); + const lateEditor = await page.evaluate(() => { + const el = document.querySelector('.monaco-editor'); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { + w: Math.round(r.width), + h: Math.round(r.height), + viewLines: el.querySelectorAll('.view-line').length, + }; + }); + console.log('editor 3s later:', JSON.stringify(lateEditor)); + await page.screenshot({ path: 'page-shot.png', fullPage: false }); + console.log('--- screenshot saved to page-shot.png ---'); +} + +await browser.close(); + +process.exit(errors.length || pageErrors.length ? 1 : 0); diff --git a/Playground/scripts/docs-sources.mjs b/Playground/scripts/docs-sources.mjs new file mode 100644 index 0000000..7637932 --- /dev/null +++ b/Playground/scripts/docs-sources.mjs @@ -0,0 +1,32 @@ +// Configurable list of doc sources to feed into build-docs-index.mjs. +// Each entry is { root, label, glob, projectTypes? }: +// - root: absolute or repo-relative directory +// - label: prefix used in chunk source paths and surfaced in citations +// - glob: forward-slash glob, evaluated relative to `root` +// - projectTypes: optional gate; when set, every chunk from this source is +// only surfaced in retrieval if the active project's `type` +// matches one of these values. Omit for always-on docs. +// +// Edit this file to add new doc sets. The indexer reads it directly — no +// separate config file format. + +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..'); +const playgroundDir = resolve(__dirname, '..'); + +export const DOC_SOURCES = [ + { + root: resolve(repoRoot, 'FadeBasic', 'book', 'FadeBook'), + label: 'FadeBook', + glob: '**/*.md', + }, + { + root: resolve(playgroundDir, 'rag_files', 'monogame'), + label: 'MonoGame', + glob: '**/*.md', + projectTypes: ['monogame'], + }, +]; diff --git a/Playground/scripts/inspect-toc.mjs b/Playground/scripts/inspect-toc.mjs new file mode 100644 index 0000000..8a50efb --- /dev/null +++ b/Playground/scripts/inspect-toc.mjs @@ -0,0 +1,49 @@ +import { chromium } from 'playwright'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +await page.goto('http://localhost:5311/', { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mginspect', { create: true }); + const write = async (n, t) => { const fh = await dir.getFileHandle(n, { create: true }); const w = await fh.createWritable(); await w.write(t); await w.close(); }; + await write('fade.json', JSON.stringify({ name: 'mginspect', type: 'monogame', commandDlls: [], sources: ['main.fbasic'] }) + '\n'); + await write('main.fbasic', 'do\nsync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mginspect'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 2500)); + +// Capture the OUTERHTML of one Commands group + one Language section. +const dump = await page.evaluate(() => { + const cmdGrp = document.querySelector('#help-toc .help-toc-group.help-toc-collapsible'); + return cmdGrp?.outerHTML?.replace(/>\n<') ?? null; +}); +console.log('--- Commands group HTML:'); +console.log(dump); + +// Now switch to language tab. +await page.click('.help-tab[data-tab="language"]'); +await new Promise(r => setTimeout(r, 800)); +const langDump = await page.evaluate(() => { + const sec = document.querySelector('#help-toc .help-toc-item.help-toc-collapsible'); + return sec?.outerHTML?.replace(/>\n<') ?? null; +}); +console.log('--- Language section HTML:'); +console.log(langDump); + +// Compare chevron computed widths and positions. +const widths = await page.evaluate(() => { + const lang = document.querySelector('#help-toc .help-toc-item.help-toc-collapsible'); + const langChev = lang?.querySelector('.help-toc-chevron'); + return { + langChevRect: langChev?.getBoundingClientRect(), + langChevComputedWidth: langChev ? getComputedStyle(langChev).width : null, + }; +}); +console.log('--- Language chevron details:', JSON.stringify(widths, null, 2)); +await browser.close(); diff --git a/Playground/scripts/inspect-toc2.mjs b/Playground/scripts/inspect-toc2.mjs new file mode 100644 index 0000000..4c1c09c --- /dev/null +++ b/Playground/scripts/inspect-toc2.mjs @@ -0,0 +1,34 @@ +import { chromium } from 'playwright'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +await page.goto('http://localhost:5311/', { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mginspect2', { create: true }); + const write = async (n, t) => { const fh = await dir.getFileHandle(n, { create: true }); const w = await fh.createWritable(); await w.write(t); await w.close(); }; + await write('fade.json', JSON.stringify({ name: 'mginspect2', type: 'monogame', commandDlls: [], sources: ['main.fbasic'] }) + '\n'); + await write('main.fbasic', 'do\nsync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mginspect2'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 2500)); + +const positions = await page.evaluate(() => { + const grp = document.querySelector('#help-toc .help-toc-group.help-toc-collapsible'); + if (!grp) return null; + const cs = getComputedStyle(grp); + return { + grp: { left: grp.getBoundingClientRect().left, gap: cs.gap, paddingLeft: cs.paddingLeft }, + chev: grp.querySelector('.help-toc-chevron')?.getBoundingClientRect(), + label: grp.querySelector('.help-toc-group-label')?.getBoundingClientRect(), + labelComputed: (() => { const e = grp.querySelector('.help-toc-group-label'); const cs2 = e && getComputedStyle(e); return cs2 && { marginLeft: cs2.marginLeft, paddingLeft: cs2.paddingLeft, flex: cs2.flex }; })(), + count: grp.querySelector('.help-toc-group-count')?.getBoundingClientRect(), + }; +}); +console.log(JSON.stringify(positions, null, 2)); +await browser.close(); diff --git a/Playground/scripts/probe-boot.mjs b/Playground/scripts/probe-boot.mjs new file mode 100644 index 0000000..bd8f4f9 --- /dev/null +++ b/Playground/scripts/probe-boot.mjs @@ -0,0 +1,57 @@ +import { chromium } from 'playwright'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); + +const b = await chromium.launch({ headless: true }); +const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); + +p.on('pageerror', e => console.log('[PE]', e.message.slice(0, 400))); +p.on('console', m => { + const t = m.type(); + if (t === 'error' || /error|fail|exception/i.test(m.text())) { + console.log(`[${t}]`, m.text().slice(0, 400)); + } +}); + +await p.goto('http://localhost:5316/', { waitUntil: 'domcontentloaded' }); +await p.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); +console.log('→ Playground Ready'); + +// Seed monogame project + reload +await p.evaluate(async () => { + const opfs = await navigator.storage.getDirectory(); + const ws = await opfs.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgprobe', { create: true }); + const cfg = JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgprobe', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'; + const cw = await (await dir.getFileHandle('fade.json', { create: true })).createWritable(); + await cw.write(cfg); await cw.close(); + const sw = await (await dir.getFileHandle('main.fbasic', { create: true })).createWritable(); + await sw.write('print "hi"\ndo\n sync\nloop\n'); await sw.close(); + localStorage.setItem('fade.activeProject', 'mgprobe'); +}); +await p.reload({ waitUntil: 'domcontentloaded' }); +await p.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await (await p.$('#run')).click(); +console.log('→ Run clicked, waiting 10s and dumping state…'); +await p.waitForTimeout(10_000); + +const info = await p.evaluate(() => { + const c = document.getElementById('theCanvas'); + return { + hasCanvas: !!c, + canvasSize: c ? { w: c.width, h: c.height } : null, + outputText: (document.getElementById('output')?.textContent || '').slice(0, 500), + statusText: document.getElementById('status')?.textContent || '', + bodyText: document.body.innerText.slice(0, 600), + }; +}); +console.log('INFO:', JSON.stringify(info, null, 2)); + +await b.close(); diff --git a/Playground/scripts/probe-bp-leftkey-full.mjs b/Playground/scripts/probe-bp-leftkey-full.mjs new file mode 100644 index 0000000..02959b9 --- /dev/null +++ b/Playground/scripts/probe-bp-leftkey-full.mjs @@ -0,0 +1,190 @@ +// Same as probe-bp-leftkey but uses the user's FULL source (with the second +// for-loop after the if-rightKey block, and the `_L1:` label). The simpler +// probe showed 0 false fires, so the trigger must live in this extra code. + +import { chromium } from 'playwright'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); + +const URL = process.env.URL || 'http://localhost:5320/'; + +// User's exact source (lines 1-107 numbered). +const SOURCE = `set render size 1920, 1080 + +set background color rgb(75, 44, 44) +sprite 1, 100, 200, 0 +color sprite 1, rgb(255, 0, 0) +size sprite 1, 200, 200 +order sprite 1, 1 +x = 180 +y = 100 +speed = 8 + +boxLength = 50 +DIM backgroundBoxes(boxLength) as box +for n = 0 to boxLength - 1 + b = backgroundBoxes(n) + id = reserve sprite id(b.spriteId) + b.pos.x = rnd(render width()) + b.pos.y = rnd(render height()) + b.size.x = 10 + rnd(40) + if b.size.x > 30 + b.size.y -= 5 + endif + b.vel.x = -1 * b.size.x * .01 * b.size.x + b.size.y = 10 + sprite id, b.pos.x, b.pos.y, 0 + color sprite id, rgb(128 + rnd(64), 64 + rnd(32), 128) + size sprite id, b.size.x, b.size.y + + backgroundBoxes(n) = b +next + + +width = render width() - 100 +height = render height() - 100 + +do + sprite 1, x, y, 0 + + if x > width then x = width + if y > height then y = height + if x < 100 then x = 100 + if y < 100 then y = 100 + + if downkey() + y = y + speed + endif + if upkey() + y = y - speed + endif + if leftkey() + x = x - speed + endif + if rightKey() + x = x + speed + endif + + for n = 0 to boxLength - 1 + b = backgroundBoxes(n) + + b.pos.x += b.vel.x + b.pos.y += b.vel.y + + if (b.pos.x < -100) + b.pos.x = render width() + 100 + endif + + position sprite b.spriteId, b.pos.x, b.pos.y + + backgroundBoxes(n) = b + next + + + sync + + _L1: +loop + +\` TODO: resizing the window should adjust the letter-boxing +\` TODO: auto complete is appearing on comment string +\` TODO: these types should live in another file, but they aren't working. +type box + spriteId + pos as vec + vel as vec + size as vec +endtype + +type vec + x + y +endtype +`; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[PE]', e.message.slice(0, 300))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.evaluate(async (src) => { + const opfs = await navigator.storage.getDirectory(); + const ws = await opfs.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgbpfull', { create: true }); + const cfg = JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgbpfull', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'; + const cw = await (await dir.getFileHandle('fade.json', { create: true })).createWritable(); + await cw.write(cfg); await cw.close(); + const sw = await (await dir.getFileHandle('main.fbasic', { create: true })).createWritable(); + await sw.write(src); await sw.close(); + localStorage.setItem('fade.activeProject', 'mgbpfull'); +}, SOURCE); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.click('#run'); +await page.waitForSelector('#theCanvas', { timeout: 60_000 }); +await page.waitForTimeout(3_000); + +await page.click('#debug', { force: true }); +await page.waitForTimeout(2_500); + +// Set bp ONLY on the body line — display line 51, 0-based 50 — which +// is the `x = x - speed` body of the if-leftkey block. We never press +// any key, so the bp should not fire. line=49 (the `if leftkey()` line) +// IS a legitimate every-frame bp; we don't set it here. +console.log('→ set breakpoint on line 50 only (body of if-leftkey)…'); +await page.evaluate(async () => { + const linesJson = JSON.stringify([ + { line: 50, column: 0 }, + ]); + await window.theInstance.invokeMethodAsync('DebugSetBreakpoints', linesJson); +}); +await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugContinue')); + +let hits = 0; +const byLine = new Map(); +const start = Date.now(); +let lastStatus = ''; +while (Date.now() - start < 6_000) { + const status = await page.evaluate(() => + document.getElementById('debug-status')?.textContent ?? ''); + if (/paused on breakpoint/i.test(status) && status !== lastStatus) { + hits++; + // Query stack to find what line we paused on. + const framesJson = await page.evaluate(() => + window.theInstance.invokeMethodAsync('DebugStackFrames')); + let line = '?'; + let col = '?'; + try { + const frames = JSON.parse(framesJson); + const top = Array.isArray(frames) ? frames[0] : frames?.frames?.[0]; + line = top?.lineNumber ?? top?.line ?? '?'; + col = top?.colNumber ?? top?.col ?? '?'; + } catch { /* ignore */ } + const key = `${line}:${col}`; + byLine.set(key, (byLine.get(key) ?? 0) + 1); + if (hits <= 12) console.log(` hit #${hits} on line=${line} col=${col} at ${Date.now() - start}ms`); + await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugContinue')); + } + lastStatus = status; + await page.waitForTimeout(150); +} +console.log('\nHits by line:', JSON.stringify(Object.fromEntries(byLine))); + +await browser.close(); + +console.log(`\nbreakpoint on if-leftkey body fired ${hits} times in 6s (no keypress simulated).`); +if (hits === 0) { + console.log('✓ EXPECTED: no false fires.'); +} else { + console.error('✗ BUG REPRODUCED: breakpoint fires without leftkey press.'); + process.exit(1); +} diff --git a/Playground/scripts/probe-bp-leftkey.mjs b/Playground/scripts/probe-bp-leftkey.mjs new file mode 100644 index 0000000..8821b92 --- /dev/null +++ b/Playground/scripts/probe-bp-leftkey.mjs @@ -0,0 +1,153 @@ +// Reproduces the user's false-fire breakpoint scenario. Loads the full +// source, sets a breakpoint on line 51 (`x = x - speed` body of if-leftkey, +// 0-based = 50), runs for ~6 seconds without simulating any keypress, and +// counts how many times the breakpoint hits. A correct debugger should +// hit 0 times since leftkey is never pressed. + +import { chromium } from 'playwright'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); + +const URL = process.env.URL || 'http://localhost:5320/'; + +const SOURCE = `set render size 1920, 1080 + +set background color rgb(75, 44, 44) +sprite 1, 100, 200, 0 +color sprite 1, rgb(255, 0, 0) +size sprite 1, 200, 200 +order sprite 1, 1 +x = 180 +y = 100 +speed = 8 + +boxLength = 50 +DIM backgroundBoxes(boxLength) as box +for n = 0 to boxLength - 1 + b = backgroundBoxes(n) + id = reserve sprite id(b.spriteId) + b.pos.x = rnd(render width()) + b.pos.y = rnd(render height()) + b.size.x = 10 + rnd(40) + if b.size.x > 30 + b.size.y -= 5 + endif + b.vel.x = -1 * b.size.x * .01 * b.size.x + b.size.y = 10 + sprite id, b.pos.x, b.pos.y, 0 + color sprite id, rgb(128 + rnd(64), 64 + rnd(32), 128) + size sprite id, b.size.x, b.size.y + + backgroundBoxes(n) = b +next + + +width = render width() - 100 +height = render height() - 100 + +do + sprite 1, x, y, 0 + + if x > width then x = width + if y > height then y = height + if x < 100 then x = 100 + if y < 100 then y = 100 + + if downkey() + y = y + speed + endif + if upkey() + y = y - speed + endif + if leftkey() + x = x - speed + endif + if rightKey() + x = x + speed + endif + + sync +loop + +type box + spriteId + pos as vec + vel as vec + size as vec +endtype + +type vec + x + y +endtype +`; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[PE]', e.message.slice(0, 300))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.evaluate(async (src) => { + const opfs = await navigator.storage.getDirectory(); + const ws = await opfs.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgbp51', { create: true }); + const cfg = JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgbp51', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'; + const cw = await (await dir.getFileHandle('fade.json', { create: true })).createWritable(); + await cw.write(cfg); await cw.close(); + const sw = await (await dir.getFileHandle('main.fbasic', { create: true })).createWritable(); + await sw.write(src); await sw.close(); + localStorage.setItem('fade.activeProject', 'mgbp51'); +}, SOURCE); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.click('#run'); +await page.waitForSelector('#theCanvas', { timeout: 60_000 }); +await page.waitForTimeout(3_000); + +await page.click('#debug', { force: true }); +await page.waitForTimeout(2_500); + +// Set bp on line 51 (0-based 50), then continue. +console.log('→ set breakpoint on line 51 (if-leftkey body)…'); +await page.evaluate(async () => { + const linesJson = JSON.stringify([{ line: 50, column: 0 }]); + await window.theInstance.invokeMethodAsync('DebugSetBreakpoints', linesJson); +}); +await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugContinue')); + +// Watch the debug status text. If it shows "paused on breakpoint" — a hit. +// Continue past each hit, count them. +let hits = 0; +const start = Date.now(); +let lastStatus = ''; +while (Date.now() - start < 6_000) { + const status = await page.evaluate(() => + document.getElementById('debug-status')?.textContent ?? ''); + if (/paused on breakpoint/i.test(status) && status !== lastStatus) { + hits++; + console.log(` hit #${hits} at ${Date.now() - start}ms`); + // Continue to look for the next one. + await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugContinue')); + } + lastStatus = status; + await page.waitForTimeout(150); +} + +await browser.close(); + +console.log(`\nbreakpoint on if-leftkey body fired ${hits} times in 6s (no keypress simulated).`); +if (hits === 0) { + console.log('✓ EXPECTED: no false fires.'); +} else { + console.error('✗ BUG REPRODUCED: breakpoint fires without leftkey press.'); + process.exit(1); +} diff --git a/Playground/scripts/probe-help-collapsible.mjs b/Playground/scripts/probe-help-collapsible.mjs new file mode 100644 index 0000000..5f4812b --- /dev/null +++ b/Playground/scripts/probe-help-collapsible.mjs @@ -0,0 +1,136 @@ +// Verifies the collapsible-TOC changes: +// 1. All Commands groups start collapsed; clicking a header expands it. +// 2. Language tab section TOC entries start collapsed; clicking expands + +// switches to that page. +// 3. Sub-headings live under every section header (always present in +// DOM after expand), no longer "only under the active section". +// 4. External selectCommand (e.g. hover deep-link) auto-expands the +// command's group(s). + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 700)); + +// ── commands: groups start collapsed ────────────────────────────────────── +const initial = await page.evaluate(() => ({ + groups: Array.from(document.querySelectorAll('#help-toc .help-toc-group')).length, + items: Array.from(document.querySelectorAll('#help-toc .help-toc-item:not(.help-toc-collapsible)')).length, + expanded: Array.from(document.querySelectorAll('#help-toc .help-toc-group.expanded')).length, +})); +console.log('commands initial:', initial); +if (initial.items !== 0) { console.error('FAIL: items visible before any expansion'); await browser.close(); process.exit(1); } +if (initial.expanded !== 0) { console.error('FAIL: a group is pre-expanded'); await browser.close(); process.exit(1); } + +// Click a specific group header. +await page.evaluate(() => { + const g = Array.from(document.querySelectorAll('#help-toc .help-toc-group')) + .find(el => el.textContent?.includes('Standard')); + g?.click(); +}); +await new Promise(r => setTimeout(r, 200)); +const afterExpand = await page.evaluate(() => ({ + expandedNames: Array.from(document.querySelectorAll('#help-toc .help-toc-group.expanded')) + .map(el => el.querySelector('.help-toc-group-label')?.textContent), + visibleItems: Array.from(document.querySelectorAll('#help-toc .help-toc-item:not(.help-toc-collapsible)')).length, +})); +console.log('after click "Standard":', afterExpand); +if (afterExpand.visibleItems === 0) { console.error('FAIL: items still hidden after expand'); await browser.close(); process.exit(1); } + +// Click again to collapse. +await page.evaluate(() => { + const g = Array.from(document.querySelectorAll('#help-toc .help-toc-group')) + .find(el => el.textContent?.includes('Standard')); + g?.click(); +}); +await new Promise(r => setTimeout(r, 200)); +const afterCollapse = await page.evaluate(() => ({ + visibleItems: Array.from(document.querySelectorAll('#help-toc .help-toc-item:not(.help-toc-collapsible)')).length, +})); +console.log('after collapse:', afterCollapse); +if (afterCollapse.visibleItems !== 0) { console.error('FAIL: items still visible after collapse'); await browser.close(); process.exit(1); } + +// External selectCommand auto-expands. +await page.evaluate(() => window.__fadeHelp?.openCommand?.('print')); +await new Promise(r => setTimeout(r, 400)); +const afterDeepLink = await page.evaluate(() => ({ + expanded: Array.from(document.querySelectorAll('#help-toc .help-toc-group.expanded')) + .map(el => el.querySelector('.help-toc-group-label')?.textContent), + activeItemText: document.querySelector('#help-toc .help-toc-item.active:not(.help-toc-collapsible)')?.textContent, +})); +console.log('after deep-link "print":', afterDeepLink); +if (!afterDeepLink.expanded.length) { console.error('FAIL: deep-link did not auto-expand'); await browser.close(); process.exit(1); } +if (afterDeepLink.activeItemText !== 'print') { console.error('FAIL: deep-link did not surface "print"'); await browser.close(); process.exit(1); } + +// ── language: sections start collapsed, subs always exist when expanded ─── +await page.click('.help-tab[data-tab="language"]'); +await new Promise(r => setTimeout(r, 800)); +const langInitial = await page.evaluate(() => ({ + sections: Array.from(document.querySelectorAll('#help-toc .help-toc-collapsible')).length, + expanded: Array.from(document.querySelectorAll('#help-toc .help-toc-collapsible.expanded')).length, + subs: Array.from(document.querySelectorAll('#help-toc .help-toc-sub')).length, +})); +console.log('language initial:', langInitial); +if (langInitial.expanded !== 0 || langInitial.subs !== 0) { + console.error('FAIL: language tab not fully collapsed'); + await browser.close(); process.exit(1); +} + +// Click "Operations" — it has subs. Should expand AND switch the body. +await page.evaluate(() => { + const link = Array.from(document.querySelectorAll('#help-toc .help-toc-collapsible')) + .find(el => el.textContent?.includes('Operations')); + link?.click(); +}); +await new Promise(r => setTimeout(r, 400)); +const langAfter = await page.evaluate(() => { + const expanded = Array.from(document.querySelectorAll('#help-toc .help-toc-collapsible.expanded')) + .map(el => el.querySelector('.help-toc-section-label')?.textContent); + const subs = Array.from(document.querySelectorAll('#help-toc .help-toc-sub')) + .map(el => el.textContent); + const bodyFirst = document.querySelector('#help-body h1, #help-body h2')?.textContent; + return { expanded, subs, bodyFirst }; +}); +console.log('language after expand "Operations":', langAfter); +if (!langAfter.expanded.some(t => t?.includes('Operations'))) { + console.error('FAIL: Operations did not expand'); + await browser.close(); process.exit(1); +} +if (langAfter.subs.length === 0) { + console.error('FAIL: no subs visible under expanded section'); + await browser.close(); process.exit(1); +} +if (!langAfter.bodyFirst?.includes('Operations')) { + console.error(`FAIL: body did not switch to Operations (saw "${langAfter.bodyFirst}")`); + await browser.close(); process.exit(1); +} + +// Subs exist independent of body selection: switch body to a different +// section by clicking another section's row — Operations should STILL be +// expanded (sub-items still visible) until we explicitly collapse it. +await page.evaluate(() => { + const link = Array.from(document.querySelectorAll('#help-toc .help-toc-collapsible')) + .find(el => el.textContent?.includes('Variables') && !el.textContent?.includes('Operations')); + link?.click(); +}); +await new Promise(r => setTimeout(r, 400)); +const langStill = await page.evaluate(() => ({ + operationsStillExpanded: Array.from(document.querySelectorAll('#help-toc .help-toc-collapsible.expanded')) + .some(el => el.textContent?.includes('Operations')), + subsForOperationsVisible: Array.from(document.querySelectorAll('#help-toc .help-toc-sub')).length > 0, +})); +console.log('after navigating to Variables:', langStill); +if (!langStill.operationsStillExpanded) { + console.error('FAIL: Operations collapsed after navigating to another section'); + await browser.close(); process.exit(1); +} + +console.log('\n✓ PASS: collapsible TOC across Commands + Language, subs always there'); +await browser.close(); diff --git a/Playground/scripts/probe-help-cref.mjs b/Playground/scripts/probe-help-cref.mjs new file mode 100644 index 0000000..246f4ea --- /dev/null +++ b/Playground/scripts/probe-help-cref.mjs @@ -0,0 +1,88 @@ +// Verifies that cross-command links in help docs (rendered from +// XML in the source) become same-page #fade-cmd: anchors and that clicking +// one routes through selectCommand instead of navigating away. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Switch to a monogame project so we hit FadeMonoGameCommands docs (which +// have rich usage referencing C# method names like LoadTexture). +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgcref', { create: true }); + const writeText = async (name, text) => { + const fh = await dir.getFileHandle(name, { create: true }); + const w = await fh.createWritable(); + await w.write(text); await w.close(); + }; + await writeText('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgcref', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'); + await writeText('main.fbasic', 'do\n sync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgcref'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise(r => setTimeout(r, 3000)); + +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 500)); + +// "push asset" links to "rename asset", "texture", etc. via . +const opened = await page.evaluate(() => window.__fadeHelp?.openCommand?.('push asset')); +console.log('opened "push asset":', opened); +await new Promise(r => setTimeout(r, 300)); + +const linkInfo = await page.evaluate(() => { + const body = document.getElementById('help-body'); + if (!body) return null; + const links = Array.from(body.querySelectorAll('a')).map(a => ({ + href: a.getAttribute('href'), + text: a.textContent, + })); + return links; +}); +console.log('links rendered in body:', JSON.stringify(linkInfo, null, 2)); + +const fadeLinks = linkInfo?.filter(l => (l.href ?? '').startsWith('#fade-cmd:')) ?? []; +if (fadeLinks.length === 0) { + console.error('FAIL: no #fade-cmd: links found in body'); + process.exit(1); +} + +// Click the first such link and see whether selectCommand fires. +const beforeSelected = await page.evaluate(() => { + const active = document.querySelector('#help-toc .help-toc-item.active'); + return active?.dataset?.name ?? active?.textContent ?? null; +}); +const expectedTarget = decodeURIComponent(fadeLinks[0].href.slice('#fade-cmd:'.length)); +console.log(`clicking link [${fadeLinks[0].text}] → expecting selection of "${expectedTarget}" (was "${beforeSelected}")`); + +await page.evaluate(() => { + const body = document.getElementById('help-body'); + const link = body?.querySelector('a[href^="#fade-cmd:"]'); + link?.click(); +}); +await new Promise(r => setTimeout(r, 400)); + +const afterSelected = await page.evaluate(() => { + const active = document.querySelector('#help-toc .help-toc-item.active'); + return active?.dataset?.name ?? active?.textContent ?? null; +}); +console.log('selection after click:', afterSelected); + +const ok = afterSelected === expectedTarget; +console.log(ok ? '\n✓ PASS: cref link routes to selectCommand' : '\n✗ FAIL: link click did not change selection'); + +await browser.close(); +process.exit(ok ? 0 : 1); diff --git a/Playground/scripts/probe-help-final.mjs b/Playground/scripts/probe-help-final.mjs new file mode 100644 index 0000000..18d7054 --- /dev/null +++ b/Playground/scripts/probe-help-final.mjs @@ -0,0 +1,100 @@ +// Verifies the two remaining help-section items: +// 1. The TOC ↔ body splitter resizes via drag and persists across reload. +// 2. The command-name

renders as LSP-tokenized Fade (matches the +// editor's syntax colors). + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); +const page = await ctx.newPage(); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 700)); + +// Open a known command and let highlighting settle. +await page.evaluate(() => window.__fadeHelp?.openCommand?.('print')); +await new Promise(r => setTimeout(r, 1200)); + +// ── splitter ────────────────────────────────────────────────────────────── +const beforeWidth = await page.evaluate(() => { + const split = document.getElementById('help-split'); + const toc = document.getElementById('help-toc'); + return { gridCols: split && getComputedStyle(split).gridTemplateColumns, tocWidth: toc?.getBoundingClientRect().width }; +}); +console.log('initial:', beforeWidth); + +// Drag the handle 80px to the right. +const handleBox = await page.evaluate(() => { + const h = document.getElementById('help-split-handle'); + const r = h?.getBoundingClientRect(); + return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null; +}); +if (!handleBox) { console.error('FAIL: no handle'); process.exit(1); } +await page.mouse.move(handleBox.x, handleBox.y); +await page.mouse.down(); +await page.mouse.move(handleBox.x + 80, handleBox.y, { steps: 8 }); +await page.mouse.up(); +await new Promise(r => setTimeout(r, 200)); + +const afterWidth = await page.evaluate(() => { + const toc = document.getElementById('help-toc'); + const stored = localStorage.getItem('fade.helpTocWidth'); + return { tocWidth: toc?.getBoundingClientRect().width, stored }; +}); +console.log('after drag:', afterWidth); + +if (Math.abs(afterWidth.tocWidth - beforeWidth.tocWidth) < 40) { + console.error('FAIL: TOC width did not change meaningfully'); + await browser.close(); process.exit(1); +} +if (!afterWidth.stored || Number(afterWidth.stored) < 100) { + console.error('FAIL: width not persisted to localStorage'); + await browser.close(); process.exit(1); +} + +// Persistence: the value should be in localStorage and within the panel's +// clamp range. initHelpSplitter applies it on every mount, so reading it +// back here is sufficient evidence that the next reload would restore it. +const persistOk = Number(afterWidth.stored) >= 100 && Number(afterWidth.stored) <= 1200; +if (!persistOk) { + console.error(`FAIL: stored width ${afterWidth.stored} is outside reasonable bounds`); + await browser.close(); process.exit(1); +} + +// ── styled command title ────────────────────────────────────────────────── +await page.evaluate(() => window.__fadeHelp?.openCommand?.('print')); +await new Promise(r => setTimeout(r, 1200)); + +const titleInfo = await page.evaluate(() => { + const title = document.querySelector('#help-body h3.help-command-title'); + if (!title) return { found: false }; + const spans = Array.from(title.querySelectorAll('span')).map(s => ({ + cls: s.className, + text: s.textContent, + })); + return { + found: true, + text: title.textContent?.trim(), + styledAs: getComputedStyle(title).fontFamily, + spans, + }; +}); +console.log('title:', JSON.stringify(titleInfo, null, 2)); + +if (!titleInfo.found) { console.error('FAIL: command title not found'); await browser.close(); process.exit(1); } +if (!/mono|Menlo|SF Mono/i.test(titleInfo.styledAs ?? '')) { + console.error(`FAIL: title is not monospaced (font-family=${titleInfo.styledAs})`); + await browser.close(); process.exit(1); +} +if (!titleInfo.spans.some(s => s.cls.startsWith('fade-tok-'))) { + console.error('FAIL: title has no fade-tok-* spans'); + await browser.close(); process.exit(1); +} + +console.log('\n✓ PASS: splitter resizes + persists; title renders as Fade tokens'); +await browser.close(); diff --git a/Playground/scripts/probe-help-groups.mjs b/Playground/scripts/probe-help-groups.mjs new file mode 100644 index 0000000..3d73ec0 --- /dev/null +++ b/Playground/scripts/probe-help-groups.mjs @@ -0,0 +1,73 @@ +// Verifies that the Help-tab TOC groups now reflect the owning IMethodSource +// (class name minus "Commands" suffix), not the first-word heuristic. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Set monogame so we get both StandardCommands + FadeMonoGameCommands loaded. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mggroups', { create: true }); + const writeText = async (name, text) => { + const fh = await dir.getFileHandle(name, { create: true }); + const w = await fh.createWritable(); + await w.write(text); await w.close(); + }; + await writeText('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', name: 'mggroups', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'); + await writeText('main.fbasic', 'do\n sync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mggroups'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise(r => setTimeout(r, 3000)); + +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 500)); + +const groups = await page.evaluate(() => { + return Array.from(document.querySelectorAll('#help-toc .help-toc-group')) + .map(el => el.textContent?.trim()); +}); +console.log('groups:', groups); + +// Spot-check a few commands appear in the expected buckets. "attach sprite +// to transform" should show up under BOTH Sprite and Transform. +const spotCheck = await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('#help-toc .help-toc-item')); + const occurrences = new Map(); + for (const el of items) { + const name = el.dataset?.name; + if (!name) continue; + // Walk back to find this item's group header. + let prev = el.previousElementSibling; + while (prev && !prev.classList.contains('help-toc-group')) prev = prev.previousElementSibling; + const group = prev?.textContent?.replace(/\s*\(\d+\)\s*$/, '').trim(); + if (!group) continue; + const list = occurrences.get(name) ?? []; + list.push(group); + occurrences.set(name, list); + } + const pick = (name) => occurrences.get(name)?.sort() ?? []; + return { + attachSpriteToTransform: pick('attach sprite to transform'), + setSpriteTexture: pick('set sprite texture'), + debugSprite: pick('debug sprite'), + downkey: pick('downkey'), + sin: pick('sin'), + print: pick('print'), + }; +}); +console.log('spot check:', JSON.stringify(spotCheck, null, 2)); + +await browser.close(); diff --git a/Playground/scripts/probe-help-height.mjs b/Playground/scripts/probe-help-height.mjs new file mode 100644 index 0000000..32f7d57 --- /dev/null +++ b/Playground/scripts/probe-help-height.mjs @@ -0,0 +1,64 @@ +// Diagnostic: measure the bottom dockview group's height + Help panel +// content height on a fresh layout, so we can tell whether the "Help is +// too tall" complaint is about the group size or the help-pane forcing +// its parent taller than configured. +// +// Usage: node scripts/probe-help-height.mjs + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1000)); + +// Force-restore default layout to bypass any localStorage state. +await page.evaluate(() => localStorage.removeItem('fade.dockview.layout.v3')); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click the Help tab to force it active. +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise((r) => setTimeout(r, 500)); + +const measurements = await page.evaluate(() => { + function rect(el) { + if (!el) return null; + const r = el.getBoundingClientRect(); + return { width: Math.round(r.width), height: Math.round(r.height), top: Math.round(r.top) }; + } + const helpPane = document.getElementById('help-pane'); + const helpSplit = document.getElementById('help-split'); + const helpToc = document.getElementById('help-toc'); + const helpBody = document.getElementById('help-body'); + const panelCell = helpPane?.closest('.panel-cell'); + const dockviewContent = panelCell?.parentElement; + const dockGroup = dockviewContent?.closest('.dv-groupview, .dv-grid-view, .groupview'); + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + panelCell: rect(panelCell), + helpPane: rect(helpPane), + helpSplit: rect(helpSplit), + helpToc: rect(helpToc), + helpBody: rect(helpBody), + dockGroupAncestors: (() => { + const out = []; + let el = helpPane?.parentElement; + while (el && el !== document.body) { + out.push({ + tag: el.tagName, + cls: el.className, + h: Math.round(el.getBoundingClientRect().height), + }); + el = el.parentElement; + } + return out; + })(), + }; +}); +console.log(JSON.stringify(measurements, null, 2)); + +await browser.close(); diff --git a/Playground/scripts/probe-help-lang-sections.mjs b/Playground/scripts/probe-help-lang-sections.mjs new file mode 100644 index 0000000..254fc0a --- /dev/null +++ b/Playground/scripts/probe-help-lang-sections.mjs @@ -0,0 +1,81 @@ +// Verifies the Language tab behaves like Commands: clicking a TOC entry +// swaps the body to that section only (no long scroll, no over-scroll +// glitch). Also confirms body.scrollTop resets to 0 on every selection. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 600)); + +await page.click('.help-tab[data-tab="language"]'); +await new Promise(r => setTimeout(r, 800)); + +// Inspect the TOC: each entry should be a discrete section title. +const tocTitles = await page.evaluate(() => + Array.from(document.querySelectorAll('#help-toc .help-toc-item')) + .map(el => el.textContent?.trim())); +console.log('language TOC:', tocTitles); + +if (!tocTitles.includes('Memory')) { + console.error('FAIL: expected "Memory" in TOC'); + await browser.close(); + process.exit(1); +} + +async function snapshot() { + return await page.evaluate(() => { + const body = document.getElementById('help-body'); + const active = document.querySelector('#help-toc .help-toc-item.active'); + const firstH = body?.querySelector('h1, h2, h3'); + return { + activeTitle: active?.textContent?.trim() ?? null, + firstHeading: firstH?.textContent?.trim() ?? null, + scrollTop: body?.scrollTop ?? -1, + scrollHeight: body?.scrollHeight ?? -1, + clientHeight: body?.clientHeight ?? -1, + }; + }); +} + +// Click late-doc sections (Memory, Testing) to exercise the regression +// path — they were the ones that triggered the over-scroll before. +for (const target of ['Memory', 'Testing', 'Comments']) { + await page.evaluate((t) => { + const link = Array.from(document.querySelectorAll('#help-toc .help-toc-item')) + .find(el => el.textContent?.trim() === t); + link?.click(); + }, target); + await new Promise(r => setTimeout(r, 300)); + const snap = await snapshot(); + console.log(`clicked "${target}":`, snap); + if (snap.activeTitle !== target) { + console.error(`FAIL: active TOC item is "${snap.activeTitle}", expected "${target}"`); + await browser.close(); + process.exit(1); + } + if (snap.firstHeading !== target) { + console.error(`FAIL: body's first heading is "${snap.firstHeading}", expected "${target}"`); + await browser.close(); + process.exit(1); + } + if (snap.scrollTop !== 0) { + console.error(`FAIL: body.scrollTop is ${snap.scrollTop}, expected 0`); + await browser.close(); + process.exit(1); + } + if (snap.scrollHeight < snap.clientHeight) { + console.error(`FAIL: body content (${snap.scrollHeight}) is smaller than viewport (${snap.clientHeight}) — likely over-scroll glitch`); + await browser.close(); + process.exit(1); + } +} + +console.log('\n✓ PASS: Language tab swaps sections cleanly, no over-scroll'); +await browser.close(); diff --git a/Playground/scripts/probe-help-monogame.mjs b/Playground/scripts/probe-help-monogame.mjs new file mode 100644 index 0000000..9dc2d61 --- /dev/null +++ b/Playground/scripts/probe-help-monogame.mjs @@ -0,0 +1,104 @@ +// Check that FadeMonoGameCommands docs (Summary + Remarks + Examples) +// actually surface in the Help tab when the project type is 'monogame'. +// Before the GenerateDocumentationFile fix on Fade.MonoGame.Lib's net8 +// build, every monogame command had an empty docString in the metadata +// blob the LSP worker reads, so the Help tab showed names with no body. +// +// Pass criteria: every FadeMonoGame command we sample has a non-trivial +// markdown body (length > 60 chars, includes a Remarks or Examples +// section header). + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Force a monogame project on this run. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mghelp', { create: true }); + const writeText = async (name, text) => { + const fh = await dir.getFileHandle(name, { create: true }); + const w = await fh.createWritable(); + await w.write(text); + await w.close(); + }; + await writeText('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', + name: 'mghelp', + type: 'monogame', + commandDlls: [], + sources: ['main.fbasic'], + }, null, 2) + '\n'); + await writeText('main.fbasic', 'do\n sync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mghelp'); +}); + +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 2000)); + +// Open the Help tab so its DOM populates. +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise((r) => setTimeout(r, 800)); + +// Sample a handful of FadeMonoGame commands that we know have rich XML +// docs (we inspected the metadata blob earlier). +const targets = ['push asset', 'rename asset', 'load sfx clip', 'sfx', 'play sfx', 'texture', 'sprite']; + +const results = []; +for (const name of targets) { + const ok = await page.evaluate((n) => window.__fadeHelp?.openCommand(n) ?? false, name); + if (!ok) { + results.push({ name, ok: false, reason: 'openCommand returned false (not in TOC)' }); + continue; + } + await new Promise((r) => setTimeout(r, 200)); + const body = await page.evaluate(() => { + const b = document.getElementById('help-body'); + return b ? b.textContent || '' : ''; + }); + const hasRemarks = /\bRemarks\b/.test(body); + const hasExamples = /\bExamples?\b/.test(body); + const hasParams = /\bParameters?\b/.test(body); + results.push({ + name, + ok: true, + len: body.length, + hasRemarks, + hasExamples, + hasParams, + firstChars: body.replace(/\s+/g, ' ').slice(0, 100), + }); +} + +console.log(JSON.stringify(results, null, 2)); + +// Cleanup. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + try { await ws.removeEntry('mghelp', { recursive: true }); } catch {} + localStorage.setItem('fade.activeProject', 'default'); +}); + +let failed = 0; +for (const r of results) { + if (!r.ok) { console.log('FAIL', r.name, r.reason); failed++; continue; } + if (r.len < 60 || (!r.hasRemarks && !r.hasExamples)) { + console.log('FAIL', r.name, '- body too thin:', r.firstChars); + failed++; + } +} +if (errors.length) console.log('PAGE ERRORS:', errors); +console.log(failed === 0 ? 'PASS: all monogame commands have rich docs' : `FAIL: ${failed} commands missing docs`); +await browser.close(); +process.exit(failed > 0 || errors.length > 0 ? 1 : 0); diff --git a/Playground/scripts/probe-help-screenshot-toc.mjs b/Playground/scripts/probe-help-screenshot-toc.mjs new file mode 100644 index 0000000..1787125 --- /dev/null +++ b/Playground/scripts/probe-help-screenshot-toc.mjs @@ -0,0 +1,42 @@ +import { chromium } from 'playwright'; +import { resolve } from 'node:path'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgshot', { create: true }); + const write = async (n, t) => { const fh = await dir.getFileHandle(n, { create: true }); const w = await fh.createWritable(); await w.write(t); await w.close(); }; + await write('fade.json', JSON.stringify({ name: 'mgshot', type: 'monogame', commandDlls: [], sources: ['main.fbasic'] }) + '\n'); + await write('main.fbasic', 'do\nsync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgshot'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 2000)); + +// Expand the Sprite group on Commands. +await page.evaluate(() => { + const g = Array.from(document.querySelectorAll('#help-toc .help-toc-group.help-toc-collapsible')) + .find(el => el.textContent?.includes('Sprite')); + g?.click?.(); +}); +await new Promise(r => setTimeout(r, 300)); +await page.locator('#help-pane').screenshot({ path: resolve('commands-toc.png') }); +console.log('wrote commands-toc.png'); + +// Now Language with Operations expanded. +await page.click('.help-tab[data-tab="language"]'); +await new Promise(r => setTimeout(r, 700)); +await page.locator('#help-toc .help-toc-collapsible', { hasText: 'Operations' }).first().click(); +await new Promise(r => setTimeout(r, 400)); +await page.locator('#help-pane').screenshot({ path: resolve('language-toc.png') }); +console.log('wrote language-toc.png'); + +await browser.close(); diff --git a/Playground/scripts/probe-help-screenshot.mjs b/Playground/scripts/probe-help-screenshot.mjs new file mode 100644 index 0000000..c476795 --- /dev/null +++ b/Playground/scripts/probe-help-screenshot.mjs @@ -0,0 +1,49 @@ +// Take a screenshot of the fresh-default-layout with Help active so we +// can see what the user means by "way too tall". Captures at several +// viewport sizes — maybe the issue is monitor-size-dependent. +// +// Usage: node scripts/probe-help-screenshot.mjs + +import { chromium } from 'playwright'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +mkdirSync('/tmp/fade-help-probe', { recursive: true }); +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); + +const viewports = [ + { name: '1280x800', w: 1280, h: 800 }, + { name: '1920x1080', w: 1920, h: 1080 }, + { name: '1440x900', w: 1440, h: 900 }, + { name: 'mac-13in', w: 1280, h: 720 }, + { name: '4k-tall', w: 1500, h: 2000 }, + { name: '5k-tall', w: 2560, h: 2880 }, +]; + +for (const vp of viewports) { + const page = await browser.newPage({ viewport: { width: vp.w, height: vp.h } }); + await page.goto(URL, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + // Force-fresh layout. + await page.evaluate(() => localStorage.removeItem('fade.dockview.layout.v3')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + await new Promise((r) => setTimeout(r, 1500)); + + await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); + await new Promise((r) => setTimeout(r, 500)); + + const png = await page.screenshot({ fullPage: false }); + writeFileSync(`/tmp/fade-help-probe/${vp.name}.png`, png); + const m = await page.evaluate(() => { + const c = document.querySelector('.panel-cell[data-panel="help"], #help-pane') + ?.closest('.panel-cell'); + const r = c?.getBoundingClientRect(); + return r ? { panelHeight: Math.round(r.height), viewportH: window.innerHeight } : null; + }); + console.log(`${vp.name}: ${JSON.stringify(m)}`); + await page.close(); +} + +await browser.close(); +console.log('screenshots written to /tmp/fade-help-probe/'); diff --git a/Playground/scripts/probe-help-search.mjs b/Playground/scripts/probe-help-search.mjs new file mode 100644 index 0000000..1730ffb --- /dev/null +++ b/Playground/scripts/probe-help-search.mjs @@ -0,0 +1,105 @@ +// Verifies the global Help search: +// 1. Search bar lives above the tabs (not inside any single tab's UI). +// 2. Typing produces a dropdown with results from Commands + Language + +// Playground sources. +// 3. Each result shows a source badge + title + snippet. +// 4. Clicking a result navigates to the right tab and selects it. +// 5. Esc clears, dropdown collapses. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +// Use a monogame project so the Commands tab has sprite/texture/etc. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgsearch', { create: true }); + const write = async (n, t) => { const fh = await dir.getFileHandle(n, { create: true }); const w = await fh.createWritable(); await w.write(t); await w.close(); }; + await write('fade.json', JSON.stringify({ name: 'mgsearch', type: 'monogame', commandDlls: [], sources: ['main.fbasic'] }) + '\n'); + await write('main.fbasic', 'do\nsync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgsearch'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 2500)); + +// Structural: search bar precedes tabs in the DOM. +const order = await page.evaluate(() => { + const pane = document.getElementById('help-pane'); + if (!pane) return null; + const kids = Array.from(pane.children).map(el => el.id || el.className).slice(0, 6); + return kids; +}); +console.log('help-pane children:', order); +if (order?.[0] !== 'help-search-bar' || order?.[2] !== 'help-tabs') { + console.error('FAIL: search bar is not above the tabs'); + await browser.close(); process.exit(1); +} + +// Type a query that should match Commands, Language, and Playground. +await page.fill('#help-search', 'sprite'); +await new Promise(r => setTimeout(r, 300)); + +const results = await page.evaluate(() => { + const rows = Array.from(document.querySelectorAll('#help-search-results .help-search-result')); + return rows.map(r => ({ + badge: r.querySelector('.help-search-result-badge')?.textContent?.trim(), + title: r.querySelector('.help-search-result-title')?.textContent?.trim(), + snippetHasMark: !!r.querySelector('.help-search-result-snippet mark'), + snippet: r.querySelector('.help-search-result-snippet')?.textContent?.slice(0, 80), + })); +}); +console.log(`results for "sprite": ${results.length}`); +for (const r of results.slice(0, 4)) console.log(' ', r); + +if (results.length === 0) { console.error('FAIL: no results'); await browser.close(); process.exit(1); } +if (!results.some(r => r.badge === 'Commands')) { console.error('FAIL: no Commands hit'); await browser.close(); process.exit(1); } +if (!results.every(r => r.snippetHasMark)) { console.error('FAIL: a snippet is missing its '); await browser.close(); process.exit(1); } + +// Type a query that lives in the Language doc, e.g. "scope" or "monkey". +await page.fill('#help-search', 'scope'); +await new Promise(r => setTimeout(r, 300)); +const langResults = await page.evaluate(() => { + const rows = Array.from(document.querySelectorAll('#help-search-results .help-search-result')); + return rows.map(r => ({ + badge: r.querySelector('.help-search-result-badge')?.textContent?.trim(), + title: r.querySelector('.help-search-result-title')?.textContent?.trim(), + })); +}); +console.log('results for "scope":', langResults.slice(0, 4)); +if (!langResults.some(r => r.badge === 'Language')) { + console.error('FAIL: no Language hits for "scope"'); + await browser.close(); process.exit(1); +} + +// Click the first Language result — should switch tabs + select section. +await page.evaluate(() => { + const rows = Array.from(document.querySelectorAll('#help-search-results .help-search-result')); + const langRow = rows.find(r => r.querySelector('.help-search-result-badge')?.textContent === 'Language'); + langRow?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); +}); +await new Promise(r => setTimeout(r, 400)); +const afterClick = await page.evaluate(() => ({ + activeTab: document.querySelector('.help-tab.active')?.dataset?.tab, + dropdownHidden: !!document.getElementById('help-search-results')?.hidden, + searchValue: document.getElementById('help-search')?.value, +})); +console.log('after click:', afterClick); +if (afterClick.activeTab !== 'language') { + console.error('FAIL: did not switch to Language tab'); + await browser.close(); process.exit(1); +} +if (!afterClick.dropdownHidden) { + console.error('FAIL: dropdown still open after click'); + await browser.close(); process.exit(1); +} + +console.log('\n✓ PASS: global search across all 3 tabs'); +await browser.close(); diff --git a/Playground/scripts/probe-help-styles.mjs b/Playground/scripts/probe-help-styles.mjs new file mode 100644 index 0000000..51c1866 --- /dev/null +++ b/Playground/scripts/probe-help-styles.mjs @@ -0,0 +1,100 @@ +// Dump computed CSS for a Commands group header vs a Language section +// header side-by-side so we can spot exactly what's still different. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 700)); + +// Capture a commands group header (any one — they all share the class). +const commandsSample = await page.evaluate(() => { + const el = document.querySelector('#help-toc .help-toc-group.help-toc-collapsible'); + if (!el) return null; + const cs = getComputedStyle(el); + const labelEl = el.querySelector('.help-toc-group-label'); + const lcs = labelEl ? getComputedStyle(labelEl) : null; + const chevEl = el.querySelector('.help-toc-chevron'); + const ccs = chevEl ? getComputedStyle(chevEl) : null; + return { + which: 'commands-group', + outerText: el.textContent?.trim(), + outer: { + display: cs.display, padding: cs.padding, + color: cs.color, fontFamily: cs.fontFamily, + fontSize: cs.fontSize, fontWeight: cs.fontWeight, + textTransform: cs.textTransform, letterSpacing: cs.letterSpacing, + backgroundColor: cs.backgroundColor, + gap: cs.gap, alignItems: cs.alignItems, + height: cs.height, + }, + label: lcs && { + fontFamily: lcs.fontFamily, fontSize: lcs.fontSize, + color: lcs.color, textTransform: lcs.textTransform, + }, + chevron: ccs && { + width: ccs.width, height: ccs.height, + fontSize: ccs.fontSize, color: ccs.color, + }, + }; +}); + +await page.click('.help-tab[data-tab="language"]'); +await new Promise(r => setTimeout(r, 800)); + +const languageSample = await page.evaluate(() => { + const el = document.querySelector('#help-toc .help-toc-item.help-toc-collapsible'); + if (!el) return null; + const cs = getComputedStyle(el); + const labelEl = el.querySelector('.help-toc-section-label'); + const lcs = labelEl ? getComputedStyle(labelEl) : null; + const chevEl = el.querySelector('.help-toc-chevron'); + const ccs = chevEl ? getComputedStyle(chevEl) : null; + return { + which: 'language-section', + outerText: el.textContent?.trim(), + outer: { + display: cs.display, padding: cs.padding, + color: cs.color, fontFamily: cs.fontFamily, + fontSize: cs.fontSize, fontWeight: cs.fontWeight, + textTransform: cs.textTransform, letterSpacing: cs.letterSpacing, + backgroundColor: cs.backgroundColor, + gap: cs.gap, alignItems: cs.alignItems, + height: cs.height, + }, + label: lcs && { + fontFamily: lcs.fontFamily, fontSize: lcs.fontSize, + color: lcs.color, textTransform: lcs.textTransform, + }, + chevron: ccs && { + width: ccs.width, height: ccs.height, + fontSize: ccs.fontSize, color: ccs.color, + }, + }; +}); + +const sideBySide = { commandsSample, languageSample }; +console.log(JSON.stringify(sideBySide, null, 2)); + +// Walk every observed key and flag mismatches. +function diff(a, b, prefix = '') { + const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]); + const out = []; + for (const k of keys) { + const av = a?.[k], bv = b?.[k]; + if (typeof av === 'object' && av !== null) { out.push(...diff(av, bv, prefix + k + '.')); continue; } + if (av !== bv) out.push(`${prefix}${k}: commands=${JSON.stringify(av)} vs language=${JSON.stringify(bv)}`); + } + return out; +} +const differences = diff(commandsSample, languageSample); +console.log('\nDifferences:'); +for (const d of differences) console.log(' •', d); + +await browser.close(); diff --git a/Playground/scripts/probe-help-subs.mjs b/Playground/scripts/probe-help-subs.mjs new file mode 100644 index 0000000..6805363 --- /dev/null +++ b/Playground/scripts/probe-help-subs.mjs @@ -0,0 +1,105 @@ +// Verifies: +// 1. Language tab TOC shows sub-headings (H3+) indented under the +// active H2 section. +// 2. Clicking a sub-heading scrolls the body to that anchor WITHOUT +// bubbling outer scroll. +// 3. Fade code blocks pick up LSP-driven syntax highlighting (spans +// with .fade-tok-* classes wrap the source tokens). + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 600)); + +await page.click('.help-tab[data-tab="language"]'); +await new Promise(r => setTimeout(r, 800)); + +// Pick a section that's known to have sub-headings (Operations, Control +// Statements). Click Operations. +await page.evaluate(() => { + const link = Array.from(document.querySelectorAll('#help-toc .help-toc-item')) + .find(el => el.textContent?.trim() === 'Operations'); + link?.click(); +}); +await new Promise(r => setTimeout(r, 400)); + +const tocBreakdown = await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('#help-toc .help-toc-item')); + return items.map(el => ({ + text: el.textContent?.trim(), + isSub: el.classList.contains('help-toc-sub'), + active: el.classList.contains('active'), + })); +}); + +const subItems = tocBreakdown.filter(x => x.isSub); +console.log(`TOC sub-items under "Operations": ${subItems.length}`); +console.log(' first few:', subItems.slice(0, 6).map(s => s.text)); +if (subItems.length === 0) { + console.error('FAIL: no sub-items rendered for Operations'); + await browser.close(); + process.exit(1); +} + +// Click a sub-item and confirm body scrolls there without bubbling. +const targetSub = subItems[subItems.length - 1].text; +await page.evaluate((t) => { + const link = Array.from(document.querySelectorAll('#help-toc .help-toc-item.help-toc-sub')) + .find(el => el.textContent?.trim() === t); + link?.click(); +}, targetSub); +await new Promise(r => setTimeout(r, 400)); + +const subSnap = await page.evaluate(() => { + const body = document.getElementById('help-body'); + return { + active: document.querySelector('#help-toc .help-toc-sub.active')?.textContent?.trim(), + scrollTop: body?.scrollTop ?? -1, + scrollHeight: body?.scrollHeight ?? -1, + clientHeight: body?.clientHeight ?? -1, + }; +}); +console.log('after sub-click:', JSON.stringify(subSnap)); +if (subSnap.active !== targetSub) { + console.error(`FAIL: active sub is "${subSnap.active}", expected "${targetSub}"`); + await browser.close(); + process.exit(1); +} +if (subSnap.scrollTop <= 0) { + console.error(`FAIL: body.scrollTop is ${subSnap.scrollTop}, expected > 0 (scrolled to sub anchor)`); + await browser.close(); + process.exit(1); +} +if (subSnap.scrollHeight < subSnap.clientHeight) { + console.error('FAIL: body content smaller than viewport — likely over-scroll glitch'); + await browser.close(); + process.exit(1); +} + +// Now look for code blocks with the fade-tok-* spans. Wait a moment for +// the async tokenize pass to land. +await new Promise(r => setTimeout(r, 1500)); +const highlightInfo = await page.evaluate(() => { + const codes = Array.from(document.querySelectorAll('#help-body pre > code')); + return codes.slice(0, 5).map(c => ({ + hasFadeSpans: c.querySelector('.fade-tok-keyword, .fade-tok-string, .fade-tok-comment, .fade-tok-number') !== null, + firstSpans: Array.from(c.querySelectorAll('span')) + .slice(0, 3) + .map(s => ({ cls: s.className, text: s.textContent })), + sample: (c.textContent ?? '').slice(0, 80), + })); +}); +console.log('code blocks (first 5):', JSON.stringify(highlightInfo, null, 2)); + +const anyHighlighted = highlightInfo.some(c => c.hasFadeSpans); +console.log(anyHighlighted ? '\n✓ PASS: subs + LSP highlighting working' : '\n⚠ subs work, but no .fade-tok-* spans found yet'); + +await browser.close(); +process.exit(anyHighlighted ? 0 : 1); diff --git a/Playground/scripts/probe-help-tabs.mjs b/Playground/scripts/probe-help-tabs.mjs new file mode 100644 index 0000000..19439a8 --- /dev/null +++ b/Playground/scripts/probe-help-tabs.mjs @@ -0,0 +1,41 @@ +// Quick probe of the three Help tabs: Commands (existing), Language +// (FadeBook/Language.md), Playground (the page's own doc). Confirms each +// tab populates a TOC and renders a body. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 600)); + +async function inspectTab(tab) { + await page.click(`.help-tab[data-tab="${tab}"]`); + await new Promise(r => setTimeout(r, 600)); + return await page.evaluate(() => { + const active = document.querySelector('.help-tab.active')?.dataset?.tab; + const tocItems = Array.from(document.querySelectorAll('#help-toc .help-toc-item')) + .slice(0, 5) + .map(el => el.textContent?.trim()); + const bodyText = document.getElementById('help-body')?.textContent?.replace(/\s+/g, ' ').slice(0, 140) ?? ''; + const toolbarHidden = document.querySelector('.help-toolbar')?.hidden ?? false; + return { active, tocItems, bodyText, toolbarHidden }; + }); +} + +for (const tab of ['commands', 'language', 'playground', 'commands']) { + const info = await inspectTab(tab); + console.log(`tab=${tab}:`); + console.log(' active:', info.active); + console.log(' toolbarHidden:', info.toolbarHidden); + console.log(' toc[0..4]:', info.tocItems); + console.log(' body[:140]:', info.bodyText); +} + +await browser.close(); diff --git a/Playground/scripts/probe-help-toc-padding.mjs b/Playground/scripts/probe-help-toc-padding.mjs new file mode 100644 index 0000000..1d128dd --- /dev/null +++ b/Playground/scripts/probe-help-toc-padding.mjs @@ -0,0 +1,102 @@ +// Diff layout-relevant computed styles for every TOC row variant: +// Commands parent (group header) vs Language parent (section row) +// Commands child (command item) vs Language child (sub heading) +// Surfaces all padding / indentation / box-model differences. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Use monogame so the Commands tab has a meaningful group structure. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgpad', { create: true }); + const write = async (n, t) => { const fh = await dir.getFileHandle(n, { create: true }); const w = await fh.createWritable(); await w.write(t); await w.close(); }; + await write('fade.json', JSON.stringify({ name: 'mgpad', type: 'monogame', commandDlls: [], sources: ['main.fbasic'] }) + '\n'); + await write('main.fbasic', 'do\nsync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgpad'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 2500)); + +// Expand the first Commands group so we can sample a child. +await page.evaluate(() => { + const g = document.querySelector('#help-toc .help-toc-group.help-toc-collapsible'); + g?.click?.(); +}); +await new Promise(r => setTimeout(r, 300)); + +const KEYS = ['display', 'paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', + 'marginLeft', 'marginRight', 'fontFamily', 'fontSize', 'fontWeight', + 'lineHeight', 'gap', 'height', 'textTransform']; + +const sample = async (sel, label) => { + const out = await page.evaluate(({ sel, KEYS }) => { + const el = document.querySelector(sel); + if (!el) return null; + const cs = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + const obj = {}; + for (const k of KEYS) obj[k] = cs[k]; + obj.left = rect.left; + obj.width = rect.width; + // What x does the actual text content start at? + const textNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE && n.nodeValue?.trim()) ?? null; + const labelSpan = el.querySelector('.help-toc-group-label, .help-toc-section-label'); + const textRect = labelSpan?.getBoundingClientRect() ?? null; + if (textRect) obj.textLeft = textRect.left; + else if (textNode) { + // Measure first run of text by wrapping a range. + const r = document.createRange(); + r.selectNodeContents(textNode); + obj.textLeft = r.getBoundingClientRect().left; + } + return obj; + }, { sel, KEYS }); + return { label, ...out }; +}; + +const cmdParent = await sample('#help-toc .help-toc-group.help-toc-collapsible.expanded', 'Commands parent'); +const cmdChild = await sample('#help-toc .help-toc-item:not(.help-toc-collapsible):not(.help-toc-sub)', 'Commands child'); + +await page.click('.help-tab[data-tab="language"]'); +await new Promise(r => setTimeout(r, 800)); +// Expand a section that has subs (Operations). +await page.locator('#help-toc .help-toc-collapsible', { hasText: 'Operations' }).first().click(); +await new Promise(r => setTimeout(r, 300)); + +const langParent = await sample('#help-toc .help-toc-item.help-toc-collapsible.expanded', 'Language parent'); +const langChild = await sample('#help-toc .help-toc-sub', 'Language child'); + +const all = { cmdParent, cmdChild, langParent, langChild }; +for (const k of Object.keys(all)) { + console.log(`\n── ${all[k].label}`); + for (const key of Object.keys(all[k])) { + if (key === 'label') continue; + console.log(` ${key.padEnd(18)} ${all[k][key]}`); + } +} + +console.log('\n── differences (parent: commands vs language)'); +for (const k of [...KEYS, 'left', 'textLeft']) { + if (cmdParent?.[k] !== langParent?.[k]) { + console.log(` ${k.padEnd(18)} cmd=${cmdParent?.[k]} lang=${langParent?.[k]}`); + } +} +console.log('\n── differences (child: commands vs language)'); +for (const k of [...KEYS, 'left', 'textLeft']) { + if (cmdChild?.[k] !== langChild?.[k]) { + console.log(` ${k.padEnd(18)} cmd=${cmdChild?.[k]} lang=${langChild?.[k]}`); + } +} + +await browser.close(); diff --git a/Playground/scripts/probe-menu-class.mjs b/Playground/scripts/probe-menu-class.mjs new file mode 100644 index 0000000..0898d10 --- /dev/null +++ b/Playground/scripts/probe-menu-class.mjs @@ -0,0 +1,100 @@ +// Open the editor's context menu and dump EVERY positioned element on +// the page so we can ID which class the menu actually uses. Uses +// page.locator click which is more reliable than page.mouse. + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click on real text in the editor to focus + place cursor, then right-click +// on the same word so a menu actually opens. +const word = await page.locator('.monaco-editor .view-line').nth(0).locator('span').first(); +await word.click(); +await new Promise((r) => setTimeout(r, 200)); +await word.click({ button: 'right' }); +await new Promise((r) => setTimeout(r, 1000)); + +// Dump every visible positioned element + parent of "Go to Definition" text. +const dump = await page.evaluate(() => { + const out = { positionedClasses: [], menuFinds: [], shadowRoots: 0 }; + // Recursively walk shadow roots too, in case Monaco renders into one. + function collect(root, arr) { + const list = root.querySelectorAll('*'); + for (const el of list) { + arr.push(el); + if (el.shadowRoot) { + out.shadowRoots++; + collect(el.shadowRoot, arr); + } + } + } + const all = []; + collect(document, all); + // Locate the shadow host for the menu container. + let host = null; + for (const el of all) { + if (el.shadowRoot) { + for (const ch of el.shadowRoot.querySelectorAll('*')) { + if (ch.classList?.contains('monaco-menu-container')) { + host = el; + break; + } + } + } + if (host) break; + } + if (host) { + const r = host.getBoundingClientRect(); + out.menuHost = { + tag: host.tagName, + id: host.id || null, + cls: (host.className && typeof host.className === 'string') ? host.className : '', + parent: host.parentElement?.tagName + '#' + (host.parentElement?.id || '') + '.' + (host.parentElement?.className || '').slice(0, 60), + rect: { w: Math.round(r.width), h: Math.round(r.height) }, + }; + } + for (const el of all) { + const cs = getComputedStyle(el); + if (cs.position !== 'fixed' && cs.position !== 'absolute') continue; + if (cs.display === 'none' || cs.visibility === 'hidden') continue; + const cls = (el.className && typeof el.className === 'string') ? el.className : ''; + if (!cls) continue; + if (/context|menu|popup|action/i.test(cls)) { + const r = el.getBoundingClientRect(); + out.positionedClasses.push({ + tag: el.tagName, + cls: cls.slice(0, 200), + pos: cs.position, + z: cs.zIndex, + parent: el.parentElement?.tagName + '.' + (el.parentElement?.className || '').slice(0, 40), + w: Math.round(r.width), + h: Math.round(r.height), + }); + } + } + // Search for any element whose text starts with "Go to" + for (const el of all) { + const t = (el.textContent || '').trim(); + if (t.startsWith('Go to ') && el.children.length <= 3) { + out.menuFinds.push({ + tag: el.tagName, + cls: (el.className && typeof el.className === 'string') ? el.className.slice(0, 100) : '', + text: t.slice(0, 50), + }); + if (out.menuFinds.length >= 4) break; + } + } + return out; +}); +console.log(JSON.stringify(dump, null, 2)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-menu-class.png', png); + +await browser.close(); diff --git a/Playground/scripts/probe-menu-overlap.mjs b/Playground/scripts/probe-menu-overlap.mjs new file mode 100644 index 0000000..6c9c768 --- /dev/null +++ b/Playground/scripts/probe-menu-overlap.mjs @@ -0,0 +1,54 @@ +// Open the editor context menu near the RIGHT edge of the editor so the +// menu has to overflow into the adjacent Help/Game tab group. With the +// transform-strip fix on .dv-render-overlay, the menu should now extend +// across panel boundaries instead of being clipped at the panel edge. + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click + right-click near the right edge of a real text token. +// Last token in line 0 should be furthest right. +const lastSpan = page.locator('.monaco-editor .view-line').nth(0).locator('span').last(); +await lastSpan.click(); +await new Promise((r) => setTimeout(r, 200)); +await lastSpan.click({ button: 'right' }); +await new Promise((r) => setTimeout(r, 800)); + +// Locate the menu container (inside shadow DOM) and check its rendered +// bounding rect. If our fix worked, the rect's right edge should extend +// past the editor's right edge OR the rect should be fully visible. +const summary = await page.evaluate(() => { + function findInShadows(root) { + for (const el of root.querySelectorAll('*')) { + if (el.classList?.contains('monaco-menu-container')) return el; + if (el.shadowRoot) { + const hit = findInShadows(el.shadowRoot); + if (hit) return hit; + } + } + return null; + } + const menu = findInShadows(document); + if (!menu) return { menuFound: false }; + const r = menu.getBoundingClientRect(); + const editor = document.querySelector('.monaco-editor .view-lines'); + const er = editor.getBoundingClientRect(); + return { + menuFound: true, + menuRect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + editorRightEdge: Math.round(er.right), + menuExtendsPastEditor: r.right > er.right + 5, + }; +}); +console.log(JSON.stringify(summary, null, 2)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-menu-overlap.png', png); +await browser.close(); diff --git a/Playground/scripts/probe-menu-visual.mjs b/Playground/scripts/probe-menu-visual.mjs new file mode 100644 index 0000000..65bd926 --- /dev/null +++ b/Playground/scripts/probe-menu-visual.mjs @@ -0,0 +1,117 @@ +// Quick visual-check probe for the editor's right-click menu. Opens a +// playground tab, right-clicks the editor at three positions that put +// the menu near a panel boundary, screenshots each one, and dumps the +// menu's computed position + every ancestor's transform / overflow / +// stacking properties so we can see exactly what's clipping it. +// +// Output: +// /tmp/fade-menu-left.png menu opens in editor middle (control) +// /tmp/fade-menu-right.png menu opens near editor's right edge +// /tmp/fade-menu-bottom.png menu opens near editor's bottom edge +// stdout: rect + ancestor-chain JSON for each case +// +// Usage: node scripts/probe-menu-visual.mjs + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Quick check: do my CSS overrides actually load? +const sanityCheck = await page.evaluate(() => { + const ro = document.querySelector('.dv-render-overlay'); + if (!ro) return { error: 'no .dv-render-overlay in DOM' }; + const cs = getComputedStyle(ro); + return { + transform: cs.transform, + contain: cs.contain, + isolation: cs.isolation, + backfaceVisibility: cs.backfaceVisibility, + }; +}); +console.log('--- CSS sanity check ---'); +console.log(JSON.stringify(sanityCheck, null, 2)); + +async function dumpMenu(label) { + const info = await page.evaluate(() => { + function findInShadows(root) { + for (const el of root.querySelectorAll('*')) { + if (el.classList?.contains('monaco-menu-container')) return el; + if (el.shadowRoot) { + const hit = findInShadows(el.shadowRoot); + if (hit) return hit; + } + } + return null; + } + const menu = findInShadows(document); + if (!menu) return { found: false }; + const cs = getComputedStyle(menu); + const r = menu.getBoundingClientRect(); + // Walk up through normal DOM ancestors AND across shadow boundaries. + const chain = []; + let p = menu; + while (p && chain.length < 25) { + const pcs = (p.nodeType === 1) ? getComputedStyle(p) : null; + chain.push({ + tag: p.tagName ?? '#' + p.nodeName, + cls: (typeof p.className === 'string' ? p.className : '').slice(0, 80), + id: p.id || null, + transform: pcs ? (pcs.transform === 'none' ? '' : pcs.transform.slice(0, 40)) : null, + overflow: pcs ? (pcs.overflow + '/' + pcs.overflowX + '/' + pcs.overflowY) : null, + position: pcs?.position ?? null, + zIndex: pcs?.zIndex ?? null, + filter: pcs ? (pcs.filter === 'none' ? '' : pcs.filter.slice(0, 20)) : null, + }); + p = p.parentNode; + if (p && p instanceof ShadowRoot) { + chain.push({ tag: '#shadow-root', host: p.host?.tagName + '.' + (p.host?.className || '').slice(0, 40) }); + p = p.host; + } + } + return { + found: true, + position: cs.position, + zIndex: cs.zIndex, + rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + chain, + }; + }); + console.log('=== ' + label + ' ==='); + console.log(JSON.stringify(info, null, 2)); +} + +async function openMenuAt(span, label) { + // Dismiss any prior menu by clicking the body. + await page.mouse.click(50, 50); + await new Promise((r) => setTimeout(r, 200)); + await span.click(); + await new Promise((r) => setTimeout(r, 150)); + await span.click({ button: 'right' }); + await new Promise((r) => setTimeout(r, 700)); + await dumpMenu(label); + const png = await page.screenshot(); + writeFileSync(`/tmp/fade-menu-${label}.png`, png); +} + +// Three positions: leftmost token (control), rightmost token in line 1 (overlaps Help/Game), +// rightmost token in the last visible line (overlaps Output panel below). +await openMenuAt( + page.locator('.monaco-editor .view-line').nth(0).locator('span').first(), + 'left', +); +await openMenuAt( + page.locator('.monaco-editor .view-line').nth(0).locator('span').last(), + 'right', +); +await openMenuAt( + page.locator('.monaco-editor .view-line').nth(6).locator('span').last(), + 'bottom', +); + +await browser.close(); diff --git a/Playground/scripts/probe-mg-help-active.mjs b/Playground/scripts/probe-mg-help-active.mjs new file mode 100644 index 0000000..f6c697d --- /dev/null +++ b/Playground/scripts/probe-mg-help-active.mjs @@ -0,0 +1,96 @@ +// Reproduce the case where Help is the active tab in the right column +// at the moment the user clicks Run — does dockview detach the Game +// panel's #mg-blazor-root mount point, causing Blazor's router to render +// inside a stale/detached element? + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); + +const consoleAll = []; +page.on('console', (m) => consoleAll.push(`[${m.type()}] ${m.text()}`)); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgrun', { create: true }); + const w = async (n, t) => { + const fh = await dir.getFileHandle(n, { create: true }); + const sw = await fh.createWritable(); + await sw.write(t); await sw.close(); + }; + await w('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', + name: 'mgrun', + type: 'monogame', + commandDlls: [], + sources: ['main.fbasic'], + }, null, 2) + '\n'); + await w('main.fbasic', 'do\n sync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgrun'); + localStorage.removeItem('fade.dockview.layout.v4'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Before clicking Run: where is mg-blazor-root and which tab is active? +const before = await page.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + return { + rootInDom: !!root, + rootRect: root?.getBoundingClientRect()?.height, + rootParentVisible: root && getComputedStyle(root.parentElement).display !== 'none', + activePanelInRightCol: window.__fadeDockview?.activeGroup?.activePanel?.id ?? null, + }; +}); +console.log('before Run:', JSON.stringify(before)); + +// Force Help to be the active tab in the right column. +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise((r) => setTimeout(r, 400)); +const afterHelp = await page.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + return { + rootInDom: !!root, + rootVisible: root && getComputedStyle(root).display !== 'none', + rootParentVisible: root && getComputedStyle(root.parentElement).display !== 'none', + }; +}); +console.log('Help active:', JSON.stringify(afterHelp)); + +// Click Run. +await page.click('#run'); +console.log('clicked Run, waiting for boot…'); +await new Promise((r) => setTimeout(r, 12_000)); + +const after = await page.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + return { + rootInDom: !!root, + rootText: root?.textContent?.slice(0, 200) ?? '', + notFound: document.body.textContent?.includes("Sorry, there's nothing"), + canvasExists: !!document.getElementById('theCanvas'), + }; +}); +console.log('after Run:', JSON.stringify(after)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-mg-help-active.png', png); + +console.log('---console (last 25)---'); +for (const m of consoleAll.slice(-25)) console.log(' ', m.slice(0, 350)); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + try { await ws.removeEntry('mgrun', { recursive: true }); } catch {} + localStorage.setItem('fade.activeProject', 'default'); +}); +await browser.close(); diff --git a/Playground/scripts/probe-mg-run.mjs b/Playground/scripts/probe-mg-run.mjs new file mode 100644 index 0000000..88f51a6 --- /dev/null +++ b/Playground/scripts/probe-mg-run.mjs @@ -0,0 +1,73 @@ +// Reproduce "Sorry, there's nothing at this address." in the Game panel +// after the Help-next-to-Game layout change. Sets up a monogame project, +// clicks Run, screenshots the Game panel and dumps its inner HTML. + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); + +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const consoleAll = []; +page.on('console', (m) => consoleAll.push(`[${m.type()}] ${m.text()}`)); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgrun', { create: true }); + const w = async (n, t) => { + const fh = await dir.getFileHandle(n, { create: true }); + const sw = await fh.createWritable(); + await sw.write(t); await sw.close(); + }; + await w('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', + name: 'mgrun', + type: 'monogame', + commandDlls: [], + sources: ['main.fbasic'], + }, null, 2) + '\n'); + await w('main.fbasic', 'do\n sync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgrun'); + localStorage.removeItem('fade.dockview.layout.v4'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click Run. +await page.click('#run'); +console.log('clicked Run, waiting for boot…'); +await new Promise((r) => setTimeout(r, 15_000)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-mg-run.png', png); +const summary = await page.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + const canvas = document.getElementById('theCanvas'); + return { + rootHTML: root ? root.outerHTML.slice(0, 600) : null, + canvasExists: !!canvas, + canvasDims: canvas ? { w: canvas.width, h: canvas.height } : null, + bodyText: document.body.innerText.includes("Sorry, there's nothing"), + notFoundInRoot: root?.textContent?.includes("Sorry, there's nothing"), + }; +}); +console.log('summary:', JSON.stringify(summary, null, 2)); +console.log('---recent console---'); +for (const m of consoleAll.slice(-20)) console.log(' ', m.slice(0, 400)); + +// Cleanup +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + try { await ws.removeEntry('mgrun', { recursive: true }); } catch {} + localStorage.setItem('fade.activeProject', 'default'); +}); +await browser.close(); diff --git a/Playground/scripts/probe-mg.mjs b/Playground/scripts/probe-mg.mjs new file mode 100644 index 0000000..e4c1c67 --- /dev/null +++ b/Playground/scripts/probe-mg.mjs @@ -0,0 +1,22 @@ +import { chromium } from 'playwright'; +process.env.PLAYWRIGHT_BROWSERS_PATH ??= '/Users/chrishanna/Documents/Github/dby/Playground/node_modules/playwright/.local-browsers'; +const b = await chromium.launch({ headless: true }); +const p = await b.newPage({ viewport: { width: 800, height: 600 } }); +p.on('console', msg => { + const t = msg.type(); + if (t === 'error' || t === 'warning' || msg.text().includes('error') || msg.text().includes('Error')) { + console.log(`[${t}]`, msg.text().slice(0, 400)); + } +}); +p.on('pageerror', e => console.log('[PAGEERROR]', e.message.slice(0, 400))); +await p.goto('http://localhost:5298/', { waitUntil: 'domcontentloaded' }); +await p.waitForTimeout(20000); +const html = await p.content(); +const info = await p.evaluate(() => ({ + hasCanvas: !!document.getElementById('theCanvas'), + status: document.getElementById('status')?.textContent, + appHtmlLength: document.getElementById('app')?.innerHTML.length, + appHtmlStart: document.getElementById('app')?.innerHTML.slice(0, 200), +})); +console.log('INFO:', JSON.stringify(info, null, 2)); +await b.close(); diff --git a/Playground/scripts/probe-monaco-popup.mjs b/Playground/scripts/probe-monaco-popup.mjs new file mode 100644 index 0000000..aca7110 --- /dev/null +++ b/Playground/scripts/probe-monaco-popup.mjs @@ -0,0 +1,122 @@ +// Right-click in the editor and dump the popup container's full DOM +// ancestor chain (positions, z-indices, transforms) so we can pinpoint +// what CSS rule needs to apply to keep it above other dockview tabs. + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click near the BOTTOM of the editor so the context menu opens upward +// — but its drop-shadow / bottom edge ought to overlap the Output panel's +// tab strip below. That's the case where z-index matters most. +const editorPos = await page.evaluate(() => { + const lines = document.querySelector('.monaco-editor .view-lines'); + if (!lines) return null; + const r = lines.getBoundingClientRect(); + return { x: r.left + 80, y: r.bottom - 30 }; +}); +await page.mouse.click(editorPos.x, editorPos.y); +await new Promise((r) => setTimeout(r, 150)); +await page.mouse.click(editorPos.x, editorPos.y, { button: 'right' }); +// Wait until ANY new element with class containing "context-view" appears +// (or 3s timeout, in which case the probe will report what's actually there). +await page.waitForFunction(() => { + return Array.from(document.querySelectorAll('*')).some((el) => { + const c = el.className; + if (typeof c !== 'string') return false; + return c.includes('context-view') || c.includes('monaco-menu'); + }); +}, { timeout: 3000 }).catch(() => {}); + +// Dump the full HTML to disk so we can grep. +const html = await page.content(); +import('node:fs').then(({ writeFileSync }) => writeFileSync('/tmp/fade-monaco-popup.html', html)); + +const info = await page.evaluate(() => { + // Dump all direct children of document.body so we can see where Monaco + // puts the context menu. + const bodyChildren = Array.from(document.body.children).map((c) => { + const cs = getComputedStyle(c); + return { + tag: c.tagName, + cls: (c.className && typeof c.className === 'string') ? c.className.slice(0, 200) : '', + id: c.id || null, + position: cs.position, + zIndex: cs.zIndex, + display: cs.display, + visibility: cs.visibility, + rect: (() => { const r = c.getBoundingClientRect(); return r.width === 0 && r.height === 0 ? null : { w: Math.round(r.width), h: Math.round(r.height) }; })(), + textPreview: (c.textContent || '').replace(/\s+/g, ' ').slice(0, 100), + }; + }); + // Target the menu by its known vscode class names directly. + const matches = []; + const selectors = ['.context-view', '.context-view.fixed', '.monaco-menu', '.monaco-menu-container']; + for (const sel of selectors) { + const els = document.querySelectorAll(sel); + for (const el of els) { + const cs = getComputedStyle(el); + const r = el.getBoundingClientRect(); + matches.push({ + selector: sel, + tag: el.tagName, + cls: (el.className && typeof el.className === 'string') ? el.className.slice(0, 200) : '', + pos: cs.position, + z: cs.zIndex, + rect: r.width === 0 && r.height === 0 ? null : { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + parentChain: (() => { + const chain = []; + let p = el.parentElement; + let d = 0; + while (p && d < 6) { + const pcs = getComputedStyle(p); + chain.push({ + tag: p.tagName, + id: p.id || null, + cls: (p.className && typeof p.className === 'string') ? p.className.slice(0, 60) : '', + z: pcs.zIndex, + pos: pcs.position, + }); + p = p.parentElement; + d++; + } + return chain; + })(), + }); + } + } + // Brute force: enumerate every element whose computed position is fixed/absolute + // and rect is non-empty, regardless of class name. The menu has to be one of these. + const positioned = []; + const walker = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_ELEMENT); + let node; + while ((node = walker.nextNode())) { + const cs = getComputedStyle(node); + if (cs.position !== 'fixed' && cs.position !== 'absolute') continue; + if (cs.display === 'none' || cs.visibility === 'hidden') continue; + const r = node.getBoundingClientRect(); + if (r.width < 100 || r.height < 50) continue; + const cls = (node.className && typeof node.className === 'string') ? node.className : ''; + positioned.push({ + tag: node.tagName, + cls: cls.slice(0, 200), + id: node.id || null, + pos: cs.position, + z: cs.zIndex, + rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + }); + } + return { bodyChildren, matches, positioned: positioned.slice(0, 20) }; +}); +console.log(JSON.stringify(info, null, 2)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-monaco-popup.png', png); +await browser.close(); diff --git a/Playground/scripts/probe-other.mjs b/Playground/scripts/probe-other.mjs new file mode 100644 index 0000000..d26a151 --- /dev/null +++ b/Playground/scripts/probe-other.mjs @@ -0,0 +1,34 @@ +import { chromium } from 'playwright'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +await page.goto('http://localhost:5311/', { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgother', { create: true }); + const write = async (n, t) => { const fh = await dir.getFileHandle(n, { create: true }); const w = await fh.createWritable(); await w.write(t); await w.close(); }; + await write('fade.json', JSON.stringify({ name: 'mgother', type: 'monogame', commandDlls: [], sources: ['main.fbasic'] }) + '\n'); + await write('main.fbasic', 'do\nsync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgother'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise(r => setTimeout(r, 2500)); +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise(r => setTimeout(r, 500)); + +const found = await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('#help-toc .help-toc-item')); + const out = []; + for (const el of items) { + let prev = el.previousElementSibling; + while (prev && !prev.classList.contains('help-toc-group')) prev = prev.previousElementSibling; + const group = prev?.textContent?.replace(/\s*\(\d+\)\s*$/, '').trim(); + if (group === 'Other') out.push(el.dataset.name); + } + return out; +}); +console.log('Other contents:', found); +await browser.close(); diff --git a/Playground/scripts/probe-search-panel.mjs b/Playground/scripts/probe-search-panel.mjs new file mode 100644 index 0000000..6f6783c --- /dev/null +++ b/Playground/scripts/probe-search-panel.mjs @@ -0,0 +1,230 @@ +// Verifies the workspace-wide Search panel: seeds a known workspace, opens +// the panel via openPanelById, types a query, asserts results group by file +// with highlighted matches, then clicks a match and confirms the editor +// reveals that line. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 240))); +page.on('console', msg => { + if (msg.type() === 'error') console.log('[console.error]', msg.text().slice(0, 240)); +}); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Seed a workspace with predictable files so matches are deterministic. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('searchprobe', { create: true }); + const writeText = async (name, text) => { + const fh = await dir.getFileHandle(name, { create: true }); + const w = await fh.createWritable(); + await w.write(text); await w.close(); + }; + await writeText('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', + name: 'searchprobe', + type: 'web', + commandDlls: [], + sources: ['main.fbasic', 'utils.fbasic'], + }, null, 2) + '\n'); + await writeText('main.fbasic', + 'print "hello playground"\n' + + 'remstart\nthis comment mentions playground twice — playground!\nremend\n' + + 'gosub greet\n' + + 'end\n' + + 'greet:\n' + + ' print "hi playground"\n' + + 'return\n'); + await writeText('utils.fbasic', + '`utils — supporting routines\n' + + 'function shout$(msg as string)\n' + + ' exitfunction upper$(msg)\n' + + 'endfunction\n'); + localStorage.setItem('fade.activeProject', 'searchprobe'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise(r => setTimeout(r, 2000)); + +// 1. Open the search panel. +const openResult = await page.evaluate(() => { + const dock = window.__fadeDockview; + if (!dock) return { ok: false, reason: 'no __fadeDockview' }; + // openPanelById is not directly exposed — drive it through the View menu + // path: try to find the View menu button and click "Search". + // Easier: just addPanel ourselves the same way openPanelById would, since + // the panel-cell mechanism handles the rest. + try { + const existing = dock.getPanel?.('search'); + if (existing) { existing.api?.setActive?.(); return { ok: true, reused: true }; } + const ref = dock.getPanel?.('workspace')?.id ?? dock.getPanel?.('editor')?.id; + dock.addPanel({ + id: 'search', + component: 'search', + title: 'Search', + position: ref ? { referencePanel: ref, direction: 'within' } : undefined, + renderer: 'always', + }); + dock.getPanel('search')?.api?.setActive?.(); + return { ok: true }; + } catch (e) { + return { ok: false, reason: String(e) }; + } +}); +console.log('open search panel:', JSON.stringify(openResult)); +if (!openResult.ok) { + console.error('FAIL: could not open search panel'); + await browser.close(); + process.exit(1); +} + +await new Promise(r => setTimeout(r, 250)); + +// 2. Confirm the input exists & is visible. +const inputVisible = await page.evaluate(() => { + const inp = document.querySelector('.search-pane input[type="search"]'); + if (!inp) return false; + const r = inp.getBoundingClientRect(); + return r.width > 0 && r.height > 0; +}); +console.log('search input visible:', inputVisible); +if (!inputVisible) { + console.error('FAIL: search input not visible after opening panel'); + await browser.close(); + process.exit(1); +} + +// 3. Type a query and wait for debounce + scan to finish. +await page.locator('.search-pane input[type="search"]').fill('playground'); +await new Promise(r => setTimeout(r, 600)); + +// 4. Inspect results: expect two files (main.fbasic with 3 matches, fade.json +// has none since "playground" doesn't appear there; project name *is* +// "searchprobe" so confirm). +const resultShape = await page.evaluate(() => { + const fileRows = Array.from(document.querySelectorAll('.search-pane .search-file-row')); + const matchRows = Array.from(document.querySelectorAll('.search-pane .search-match-row')); + const summary = document.querySelector('.search-pane .search-summary')?.textContent ?? ''; + const files = fileRows.map(r => ({ + name: r.querySelector('.file-name')?.textContent, + dir: r.querySelector('.file-dir')?.textContent ?? '', + count: r.querySelector('.file-count')?.textContent, + })); + const matches = matchRows.map(r => ({ + line: r.querySelector('.match-line-no')?.textContent, + hasHighlight: !!r.querySelector('mark'), + snippet: r.querySelector('.match-snippet')?.textContent, + })); + return { summary, files, matches }; +}); +console.log('result shape:', JSON.stringify(resultShape, null, 2)); + +if (resultShape.files.length === 0) { + console.error('FAIL: no file rows rendered'); + await browser.close(); + process.exit(1); +} +const mainFile = resultShape.files.find(f => f.name === 'main.fbasic'); +if (!mainFile) { + console.error('FAIL: main.fbasic not in results'); + await browser.close(); + process.exit(1); +} +if (Number(mainFile.count) < 2) { + console.error('FAIL: expected at least 2 matches in main.fbasic, got', mainFile.count); + await browser.close(); + process.exit(1); +} +const allHighlighted = resultShape.matches.every(m => m.hasHighlight); +if (!allHighlighted) { + console.error('FAIL: some match rows are missing the highlight'); + await browser.close(); + process.exit(1); +} + +// 5. Click a match and confirm the editor cursor moved to that line. +await page.locator('.search-pane .search-match-row').first().click(); +await new Promise(r => setTimeout(r, 400)); +const cursorInfo = await page.evaluate(() => { + const editors = window.monaco?.editor?.getEditors?.() ?? []; + if (editors.length === 0) return null; + const ed = editors[0]; + const pos = ed.getPosition?.(); + const sel = ed.getSelection?.(); + const model = ed.getModel?.(); + return { + uri: model?.uri?.toString?.() ?? null, + line: pos?.lineNumber ?? null, + column: pos?.column ?? null, + selectedLength: sel ? (sel.endColumn - sel.startColumn) : null, + }; +}); +console.log('cursor after click:', JSON.stringify(cursorInfo)); +if (!cursorInfo || !cursorInfo.uri?.includes('main.fbasic')) { + console.error('FAIL: editor did not switch to main.fbasic'); + await browser.close(); + process.exit(1); +} +if (cursorInfo.selectedLength !== 'playground'.length) { + console.error('FAIL: expected selection length =', 'playground'.length, 'got', cursorInfo.selectedLength); + await browser.close(); + process.exit(1); +} + +// 6. Try regex toggle — search for `\bplay\w+` and confirm matches still found. +await page.locator('.search-pane .search-flag').nth(2).click(); // regex toggle +await page.locator('.search-pane input[type="search"]').fill('\\bplay\\w+'); +await new Promise(r => setTimeout(r, 600)); +const regexShape = await page.evaluate(() => { + const matchRows = Array.from(document.querySelectorAll('.search-pane .search-match-row')); + return { matchCount: matchRows.length }; +}); +console.log('regex matches:', JSON.stringify(regexShape)); +if (regexShape.matchCount === 0) { + console.error('FAIL: regex search returned 0 matches'); + await browser.close(); + process.exit(1); +} + +// 7. Close the panel, then verify the ⌘⇧F (Meta+Shift+F) shortcut reopens it +// and focuses the input. +await page.evaluate(() => { + try { window.__fadeDockview?.getPanel?.('search')?.api?.close?.(); } catch {} +}); +await new Promise(r => setTimeout(r, 200)); +const closedBeforeShortcut = await page.evaluate(() => !window.__fadeDockview?.getPanel?.('search')); +console.log('panel closed before shortcut:', closedBeforeShortcut); + +// Click somewhere neutral first so the shortcut isn't swallowed by an +// element with its own keydown handler. +await page.locator('body').click({ position: { x: 10, y: 10 } }); +const isMac = process.platform === 'darwin'; +await page.keyboard.press(`${isMac ? 'Meta' : 'Control'}+Shift+F`); +await new Promise(r => setTimeout(r, 300)); +const afterShortcut = await page.evaluate(() => { + const panel = window.__fadeDockview?.getPanel?.('search'); + const input = document.querySelector('.search-pane input[type="search"]'); + const focused = document.activeElement === input; + return { panelOpen: !!panel, focused }; +}); +console.log('after shortcut:', JSON.stringify(afterShortcut)); +if (!afterShortcut.panelOpen) { + console.error('FAIL: ⌘⇧F did not open the search panel'); + await browser.close(); + process.exit(1); +} +if (!afterShortcut.focused) { + console.error('FAIL: search input not focused after ⌘⇧F'); + await browser.close(); + process.exit(1); +} + +console.log('OK: search panel works (open, scan, render, click-to-open, regex, shortcut)'); +await browser.close(); diff --git a/Playground/scripts/probe-settings-panel.mjs b/Playground/scripts/probe-settings-panel.mjs new file mode 100644 index 0000000..b825083 --- /dev/null +++ b/Playground/scripts/probe-settings-panel.mjs @@ -0,0 +1,203 @@ +// Verifies the Settings panel: ⌘, opens it, the User form changes editor +// font-size live, the Workspace tab writes to /.fade/settings.json, +// and the JSON editor round-trips edits back into the form. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 240))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Seed a known project; reload so settings/init paths fire from a clean slate. +await page.evaluate(async () => { + localStorage.removeItem('fade.settings.user.v1'); + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('settingsprobe', { create: true }); + // Wipe any leftover .fade/settings.json from a previous run. + try { + const fade = await dir.getDirectoryHandle('.fade'); + await fade.removeEntry('settings.json').catch(() => {}); + } catch {} + const fh = await dir.getFileHandle('fade.json', { create: true }); + const w = await fh.createWritable(); + await w.write(JSON.stringify({ + $schema: '/fade.schema.json', name: 'settingsprobe', type: 'web', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2)); + await w.close(); + const mfh = await dir.getFileHandle('main.fbasic', { create: true }); + const mw = await mfh.createWritable(); + await mw.write('print "hi"\n'); await mw.close(); + localStorage.setItem('fade.activeProject', 'settingsprobe'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise(r => setTimeout(r, 2000)); + +// 1. Press ⌘, to open the settings panel. +const isMac = process.platform === 'darwin'; +await page.locator('body').click({ position: { x: 10, y: 10 } }); +await page.keyboard.press(`${isMac ? 'Meta' : 'Control'}+Comma`); +await new Promise(r => setTimeout(r, 300)); +const opened = await page.evaluate(() => !!window.__fadeDockview?.getPanel?.('settings')); +console.log('settings panel opened via shortcut:', opened); +if (!opened) { + console.error('FAIL: ⌘, did not open the settings panel'); + await browser.close(); process.exit(1); +} + +// 2. The User tab should be active by default. Confirm a field shows up. +const initialFontSize = await page.evaluate(() => { + const f = document.querySelector('.settings-pane [data-key="editor.fontSize"] input'); + return f ? (f).value : null; +}); +console.log('initial editor.fontSize on the form:', initialFontSize); +if (initialFontSize == null) { + console.error('FAIL: editor.fontSize field not rendered'); await browser.close(); process.exit(1); +} + +// 3. Change font size — the editor should re-apply immediately. +await page.locator('.settings-pane [data-key="editor.fontSize"] input').fill('22'); +await page.locator('.settings-pane [data-key="editor.fontSize"] input').press('Tab'); +await new Promise(r => setTimeout(r, 250)); +const liveFont = await page.evaluate(() => { + const ed = window.monaco?.editor?.getEditors?.()[0]; + return ed?.getOption?.(window.monaco.editor.EditorOption.fontInfo)?.fontSize ?? null; +}); +console.log('editor font size after change:', liveFont); +if (liveFont !== 22) { + console.error('FAIL: expected editor fontSize=22, got', liveFont); + await browser.close(); process.exit(1); +} +const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('fade.settings.user.v1') || '{}')); +console.log('user settings persisted:', JSON.stringify(stored)); +if (stored['editor.fontSize'] !== 22) { + console.error('FAIL: localStorage user settings did not persist editor.fontSize=22'); + await browser.close(); process.exit(1); +} + +// 4. Switch to the Workspace tab and set tabSize=4. +await page.locator('.settings-pane .settings-tab').nth(1).click(); +await new Promise(r => setTimeout(r, 150)); +await page.locator('.settings-pane [data-key="editor.tabSize"] input').fill('4'); +await page.locator('.settings-pane [data-key="editor.tabSize"] input').press('Tab'); +await new Promise(r => setTimeout(r, 350)); +const wsFile = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace'); + const dir = await ws.getDirectoryHandle('settingsprobe'); + const fade = await dir.getDirectoryHandle('.fade'); + const fh = await fade.getFileHandle('settings.json'); + return await (await fh.getFile()).text(); +}); +console.log('workspace .fade/settings.json:', wsFile); +const wsParsed = JSON.parse(wsFile); +if (wsParsed['editor.tabSize'] !== 4) { + console.error('FAIL: workspace settings file missing editor.tabSize=4'); + await browser.close(); process.exit(1); +} + +// 5. Confirm a model is using tabSize=4 (workspace overrides user). +const modelTab = await page.evaluate(() => { + const m = window.monaco?.editor?.getModels?.()[0]; + return m ? m.getOptions().tabSize : null; +}); +console.log('model tab size after workspace override:', modelTab); +if (modelTab !== 4) { + console.error('FAIL: model tabSize did not pick up workspace override'); + await browser.close(); process.exit(1); +} + +// 6. JSON view round-trip: click "Edit in settings.json →", change a value, +// confirm form reflects it after going back. +await page.locator('.settings-pane .settings-tab').nth(0).click(); // User +await new Promise(r => setTimeout(r, 150)); +await page.locator('.settings-pane .settings-link').click(); // Edit in settings.json +await new Promise(r => setTimeout(r, 600)); // monaco mount +// Replace the JSON entirely with a new fontSize value. +await page.evaluate(() => { + const eds = window.monaco?.editor?.getEditors?.() ?? []; + // The settings JSON editor is the most recently created — find it by + // model language 'json' inside the settings pane. + const settingsEd = eds.find((e) => e.getModel?.()?.getLanguageId?.() === 'json'); + if (!settingsEd) throw new Error('no json editor found'); + settingsEd.getModel().setValue('{ "editor.fontSize": 18 }'); +}); +await new Promise(r => setTimeout(r, 600)); // debounce + save +const afterJson = await page.evaluate(() => { + const ed = window.monaco?.editor?.getEditors?.() + .find((e) => e.getModel?.()?.getLanguageId?.() === 'fade'); + return ed?.getOption?.(window.monaco.editor.EditorOption.fontInfo)?.fontSize ?? null; +}); +console.log('editor font after JSON edit:', afterJson); +if (afterJson !== 18) { + console.error('FAIL: JSON-edit didn\'t propagate to the editor'); + await browser.close(); process.exit(1); +} + +// 7. Theme toggle: switch to light, confirm the data-theme attr + Monaco +// + dockview classes all swap. Step 6 left us in the JSON view; click +// "Back to form" to get the GUI back. +await page.locator('.settings-pane .settings-link').click(); +await new Promise(r => setTimeout(r, 200)); +// The JSON-edit step above replaced user settings with just fontSize, so the +// theme selector should currently sit on the default 'dark'. Switch it. +await page.locator('.settings-pane [data-key="ui.theme"] select').selectOption('light'); +await new Promise(r => setTimeout(r, 400)); +const themeSnapshot = await page.evaluate(() => ({ + dataTheme: document.documentElement.dataset.theme, + // Dockview's theme class lives on a `.dv-shell` child of #dock-root, + // not on #dock-root itself. + dockClass: document.querySelector('.dv-shell')?.className ?? '', + // Monaco doesn't expose the active theme name directly; check the + // body's vs-light class which monaco adds based on the active theme. + monacoLight: document.querySelector('.monaco-editor.vs') != null, +})); +console.log('theme snapshot after switching to light:', JSON.stringify(themeSnapshot)); +if (themeSnapshot.dataTheme !== 'light') { + console.error('FAIL: html data-theme attribute not set to light'); + await browser.close(); process.exit(1); +} +if (!themeSnapshot.dockClass.includes('dockview-theme-light')) { + console.error('FAIL: dockview did not switch to light class'); + await browser.close(); process.exit(1); +} +if (!themeSnapshot.monacoLight) { + console.error('FAIL: Monaco did not adopt the light variant'); + await browser.close(); process.exit(1); +} + +// 8. JSON editor stability: type into it without losing focus mid-edit. +// Open the JSON view and type a character — the editor's text should +// still be there after the debounce/save cycle fires. +await page.locator('.settings-pane .settings-link').click(); // Edit in settings.json +await new Promise(r => setTimeout(r, 600)); +await page.evaluate(() => { + const eds = window.monaco?.editor?.getEditors?.() ?? []; + const settingsEd = eds.find((e) => e.getModel?.()?.getLanguageId?.() === 'json'); + settingsEd?.focus?.(); + settingsEd?.getModel()?.setValue('{\n "ui.theme": "light",\n "editor.fontSize": 19\n}'); +}); +await new Promise(r => setTimeout(r, 900)); // > debounce + a save round +const afterStability = await page.evaluate(() => { + const eds = window.monaco?.editor?.getEditors?.() ?? []; + const settingsEd = eds.find((e) => e.getModel?.()?.getLanguageId?.() === 'json'); + return { + text: settingsEd?.getModel()?.getValue() ?? null, + editorAlive: !!settingsEd, + }; +}); +console.log('json editor after edit:', JSON.stringify(afterStability)); +if (!afterStability.editorAlive || !afterStability.text?.includes('"editor.fontSize": 19')) { + console.error('FAIL: JSON editor disappeared or lost content during edit'); + await browser.close(); process.exit(1); +} + +console.log('OK: settings panel works (shortcut, live editor reapply, user+workspace split, JSON round-trip, theme, json-edit stability)'); +await browser.close(); diff --git a/Playground/scripts/probe-step-scope.mjs b/Playground/scripts/probe-step-scope.mjs new file mode 100644 index 0000000..c306eb7 --- /dev/null +++ b/Playground/scripts/probe-step-scope.mjs @@ -0,0 +1,175 @@ +// Reproduces the user's "x doesn't appear after step over" bug. +// 1. Load simple source with a few assignments. +// 2. Set bp on `x = 180`. Run. Bp hits. +// 3. Query scopes — should be empty (correct). +// 4. Step over once. Pause. +// 5. Query scopes — SHOULD now contain x. User reports it doesn't. + +import { chromium } from 'playwright'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); + +const URL = process.env.URL || 'http://localhost:5320/'; + +// The user's EXACT snippet — no do/sync/loop wrapper. Program ends after +// speed=8. 0-based line 6 = `x = 180`. +const SOURCE = `set render size 1920, 1080 +set background color rgb(75, 44, 44) +sprite 1, 100, 200, 0 +color sprite 1, rgb(255, 0, 0) +size sprite 1, 200, 200 +order sprite 1, 1 +x = 180 +y = 100 +speed = 8 +`; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[PE]', e.message.slice(0, 300))); +page.on('console', m => { + const t = m.text(); + if (/\[DBG\]|HIT BREAKPOINT|stepping|frame/i.test(t)) console.log('[CON]', t.slice(0, 300)); +}); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.evaluate(async (src) => { + const opfs = await navigator.storage.getDirectory(); + const ws = await opfs.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgstep', { create: true }); + const cfg = JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgstep', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'; + const cw = await (await dir.getFileHandle('fade.json', { create: true })).createWritable(); + await cw.write(cfg); await cw.close(); + const sw = await (await dir.getFileHandle('main.fbasic', { create: true })).createWritable(); + await sw.write(src); await sw.close(); + localStorage.setItem('fade.activeProject', 'mgstep'); +}, SOURCE); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.click('#run'); +await page.waitForSelector('#theCanvas', { timeout: 60_000 }); +await page.waitForTimeout(2_000); + +// Combine LoadProgram + DebugStart in a single JS frame so no rAF tick +// fires between them — otherwise the freshly-reloaded VM races through +// all the assignments before REQUEST_PAUSE arrives. +console.log('→ LoadProgram + DebugStart (atomic)…'); +const startResult = await page.evaluate(async (src) => { + await window.theInstance.invokeMethodAsync('LoadProgram', src); + return await window.theInstance.invokeMethodAsync('DebugStart'); +}, SOURCE); +const parsed = JSON.parse(startResult); +console.log(` DebugStart ok=${parsed.ok} statementLines=[${parsed.statementLines?.join(',')}]`); +await page.waitForTimeout(800); + +// Check initial pause state. +const initialFrames = JSON.parse(await page.evaluate(() => + window.theInstance.invokeMethodAsync('DebugStackFrames'))); +const initFrames = Array.isArray(initialFrames) ? initialFrames : initialFrames?.frames; +console.log(` After pause: ${initFrames?.length ?? 0} frames, top line=${initFrames?.[0]?.lineNumber}`); + +console.log('→ set breakpoint on line 6 (x = 180)…'); +await page.evaluate(async () => { + await window.theInstance.invokeMethodAsync('DebugSetBreakpoints', + JSON.stringify([{ line: 6, column: 0 }])); +}); +await page.waitForTimeout(300); + +console.log('→ Continue from initial pause; expect bp hit on x = 180…'); +await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugContinue')); + +// Poll TickDotNet-drained events for REV_REQUEST_BREAKPOINT — the +// Playground debug-status update lags behind, so we drain directly. +async function waitForPause(label, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + // Query frames; if VM is paused, frames are present. + const framesJson = await page.evaluate(() => + window.theInstance.invokeMethodAsync('DebugStackFrames')); + try { + const f = JSON.parse(framesJson); + const arr = Array.isArray(f) ? f : f?.frames; + if (arr?.length) { + // Also check that the VM is actually paused by re-checking shortly. + await page.waitForTimeout(150); + const f2 = JSON.parse(await page.evaluate(() => + window.theInstance.invokeMethodAsync('DebugStackFrames'))); + const arr2 = Array.isArray(f2) ? f2 : f2?.frames; + if (arr2?.length && arr2[0].lineNumber === arr[0].lineNumber) { + return arr2; + } + } + } catch { /* parse error */ } + await page.waitForTimeout(100); + } + console.warn(` ! ${label} pause-wait timed out`); + return null; +} + +await waitForPause('bp hit', 5_000); + +async function snap(label) { + const frames = JSON.parse(await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugStackFrames'))); + const top = Array.isArray(frames) ? frames[0] : frames?.frames?.[0]; + const scopesJson = await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugScopes', 0)); + const scopes = JSON.parse(scopesJson); + const names = (scopes?.scopes ?? []).flatMap(s => + (s?.variables ?? []).map(v => `${s.scopeName}.${v.name}=${v.value ?? '?'}`)); + console.log(`\n[${label}]`); + console.log(` top frame: line=${top?.lineNumber} col=${top?.colNumber}`); + console.log(` vars: ${names.join(', ') || ''}`); + return { line: top?.lineNumber, names }; +} + +const at_bp = await snap('after BP hit (paused at x = 180)'); + +console.log('\n→ Step over (1st time)…'); +await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugStep', 'over')); +// Wait for "paused on step" status. +const t1 = Date.now(); +while (Date.now() - t1 < 5_000) { + const s = await page.evaluate(() => document.getElementById('debug-status')?.textContent ?? ''); + if (/paused on step/i.test(s)) break; + await page.waitForTimeout(100); +} +const after_step1 = await snap('after 1st step over'); + +console.log('\n→ Step over (2nd time)…'); +await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugStep', 'over')); +const t2 = Date.now(); +while (Date.now() - t2 < 5_000) { + const s = await page.evaluate(() => document.getElementById('debug-status')?.textContent ?? ''); + if (/paused on step/i.test(s)) break; + await page.waitForTimeout(100); +} +const after_step2 = await snap('after 2nd step over'); + +await browser.close(); + +console.log('\n— Summary —'); +console.log(`BP hit: line=${at_bp.line}, vars=${at_bp.names.length}`); +console.log(`Step 1: line=${after_step1.line}, vars=${after_step1.names.length}`); +console.log(`Step 2: line=${after_step2.line}, vars=${after_step2.names.length}`); + +// Top-level `x = 180` is surfaced under either Locals or Globals +// depending on how the compiler flagged the register; we don't care +// which scope wraps it, only whether the binding is visible. +const hasX = (names) => names.some(n => /^[A-Za-z]+\.x=/.test(n)); +if (hasX(after_step1.names)) { + console.log('✓ x appeared after 1st step (expected).'); +} else if (hasX(after_step2.names)) { + console.error('✗ BUG: x only appeared after the 2nd step over.'); + process.exit(1); +} else { + console.error('✗ BUG: x never appeared in either step.'); + process.exit(1); +} diff --git a/Playground/scripts/probe-step-ui.mjs b/Playground/scripts/probe-step-ui.mjs new file mode 100644 index 0000000..93ff82a --- /dev/null +++ b/Playground/scripts/probe-step-ui.mjs @@ -0,0 +1,188 @@ +// Reproduces the user's "x doesn't appear after step over" bug through +// the actual Playground UI flow: programmatically install a bp via +// Monaco-side state + click the Debug + Step buttons. Then read the +// variables panel DOM after each step. + +import { chromium } from 'playwright'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); + +const URL = process.env.URL || 'http://localhost:5320/'; + +const SOURCE = `set render size 1920, 1080 +set background color rgb(75, 44, 44) +sprite 1, 100, 200, 0 +color sprite 1, rgb(255, 0, 0) +size sprite 1, 200, 200 +order sprite 1, 1 +x = 180 +y = 100 +speed = 8 +do + sync +loop +`; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[PE]', e.message.slice(0, 300))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.evaluate(async (src) => { + const opfs = await navigator.storage.getDirectory(); + const ws = await opfs.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgstepui', { create: true }); + const cfg = JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgstepui', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'; + const cw = await (await dir.getFileHandle('fade.json', { create: true })).createWritable(); + await cw.write(cfg); await cw.close(); + const sw = await (await dir.getFileHandle('main.fbasic', { create: true })).createWritable(); + await sw.write(src); await sw.close(); + localStorage.setItem('fade.activeProject', 'mgstepui'); +}, SOURCE); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +// Install a bp on Monaco line 7 (the `x = 180` line). Wait for the +// editor model to be ready first. +console.log('→ Setting bp on Monaco line 7 (x = 180)…'); +await page.waitForFunction(() => { + try { return (window).__playgroundEditor != null; } catch { return false; } +}, { timeout: 10_000 }).catch(() => { /* fall back */ }); + +// The Playground doesn't expose the editor; reach in via the breakpoint +// store by simulating the gutter-click code path. +await page.evaluate(() => { + // Find Monaco model URI through the global monaco instance. + const editor = (window).monaco?.editor?.getEditors?.()?.[0]; + if (!editor) return; + const model = editor.getModel(); + if (!model) return; + // We can't reach Playground's breakpointsByUri set from outside, but + // syncBreakpointsToWorker is called whenever the gutter-click handler + // toggles a bp. So simulate that handler by dispatching a mousedown on + // the editor's gutter glyph margin at line 7. + const lineHeight = editor.getOption((window).monaco.editor.EditorOption.lineHeight); + const layoutInfo = editor.getLayoutInfo(); + const y = layoutInfo.glyphMarginTop + (7 - 1) * lineHeight + lineHeight / 2; + const x = layoutInfo.glyphMarginLeft + 4; + const target = document.querySelector('.monaco-editor'); + if (!target) return; + const rect = target.getBoundingClientRect(); + const evt = new MouseEvent('mousedown', { + clientX: rect.left + x, + clientY: rect.top + y, + bubbles: true, cancelable: true, view: window, + button: 0, + }); + target.dispatchEvent(evt); +}); +await page.waitForTimeout(500); + +console.log('→ Click Debug button (Playground startDebug flow)…'); +await page.click('#debug', { force: true }); + +// Wait until the debug session is paused on the bp. +const waitForPausedBp = async () => { + const t0 = Date.now(); + while (Date.now() - t0 < 15_000) { + const s = await page.evaluate(() => + document.getElementById('debug-status')?.textContent ?? ''); + if (/paused on breakpoint/i.test(s)) return true; + await page.waitForTimeout(150); + } + return false; +}; + +if (!await waitForPausedBp()) { + console.error('✗ never reached paused-on-breakpoint state.'); + await browser.close(); + process.exit(1); +} + +async function dumpVars(label) { + // Read the variables panel DOM to see exactly what the user would see. + const vars = await page.evaluate(() => { + const tree = document.getElementById('debug-vars-tree'); + if (!tree) return []; + const out = []; + let curScope = ''; + tree.querySelectorAll('.debug-scope-header, .debug-var').forEach(el => { + if (el.classList.contains('debug-scope-header')) { + curScope = el.textContent.replace(/^[▸▾]/, '').trim(); + } else { + const n = el.querySelector('.debug-var-name')?.textContent ?? ''; + const v = el.querySelector('.debug-var-value')?.textContent ?? ''; + out.push(`${curScope}.${n}=${v}`); + } + }); + return out; + }); + const line = await page.evaluate(() => { + // Find the editor decoration with class 'fade-current' — the + // highlighted current line. + const els = document.querySelectorAll('.fade-current'); + for (const el of els) { + const ln = el.closest('.view-line')?.getAttribute?.('style'); + if (ln) return ln; + } + // Fallback: read debug-frames-list's top entry. + const top = document.querySelector('#debug-frames-list .debug-frame .frame-loc'); + return top?.textContent ?? '?'; + }); + console.log(`\n[${label}]`); + console.log(` top frame loc: ${line}`); + console.log(` vars: ${vars.join(', ') || ''}`); + return { vars, line }; +} + +const at_bp = await dumpVars('after BP hit (paused at x = 180)'); + +console.log('\n→ Click Step Over button (1st time)…'); +await page.click('#debug-step-over', { force: true }); +// Wait for "paused on step" status. +const waitForPausedStep = async () => { + const t0 = Date.now(); + while (Date.now() - t0 < 8_000) { + const s = await page.evaluate(() => + document.getElementById('debug-status')?.textContent ?? ''); + if (/paused on step/i.test(s)) return true; + await page.waitForTimeout(100); + } + return false; +}; +await waitForPausedStep(); +// Add a small delay to let refreshDebugView (which awaited inside the +// ack handler) complete its DOM mutations. +await page.waitForTimeout(500); +const after_step1 = await dumpVars('after 1st step over (UI DOM read)'); + +console.log('\n→ Click Step Over button (2nd time)…'); +await page.click('#debug-step-over', { force: true }); +await waitForPausedStep(); +await page.waitForTimeout(500); +const after_step2 = await dumpVars('after 2nd step over (UI DOM read)'); + +await browser.close(); + +console.log('\n— Summary —'); +const hasXAfterStep1 = after_step1.vars.some(v => /\.x=/.test(v)); +const hasXAfterStep2 = after_step2.vars.some(v => /\.x=/.test(v)); +console.log(`x after step 1: ${hasXAfterStep1}`); +console.log(`x after step 2: ${hasXAfterStep2}`); +if (hasXAfterStep1) { + console.log('✓ x appears after first step (no UI bug).'); +} else if (hasXAfterStep2) { + console.error('✗ BUG REPRODUCED: x only appears after the 2nd step.'); + process.exit(1); +} else { + console.error('✗ x never appears in either step.'); + process.exit(1); +} diff --git a/Playground/scripts/probe-stmt-tokens.mjs b/Playground/scripts/probe-stmt-tokens.mjs new file mode 100644 index 0000000..ca13d22 --- /dev/null +++ b/Playground/scripts/probe-stmt-tokens.mjs @@ -0,0 +1,182 @@ +// Loads the user's exact source and dumps the statementLines that the +// debug session knows about. The breakpoint resolver snaps clicks to +// the nearest statement token, so seeing which lines DON'T have tokens +// tells us exactly where snap-to-nearest will go wrong. + +import { chromium } from 'playwright'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); + +const URL = process.env.URL || 'http://localhost:5320/'; + +// The user's source, line-numbered for reference. +const SOURCE = `set render size 1920, 1080 + +set background color rgb(75, 44, 44) +sprite 1, 100, 200, 0 +color sprite 1, rgb(255, 0, 0) +size sprite 1, 200, 200 +order sprite 1, 1 +x = 180 +y = 100 +speed = 8 + +boxLength = 50 +DIM backgroundBoxes(boxLength) as box +for n = 0 to boxLength - 1 + b = backgroundBoxes(n) + id = reserve sprite id(b.spriteId) + b.pos.x = rnd(render width()) + b.pos.y = rnd(render height()) + b.size.x = 10 + rnd(40) + if b.size.x > 30 + b.size.y -= 5 + endif + b.vel.x = -1 * b.size.x * .01 * b.size.x + b.size.y = 10 + sprite id, b.pos.x, b.pos.y, 0 + color sprite id, rgb(128 + rnd(64), 64 + rnd(32), 128) + size sprite id, b.size.x, b.size.y + + backgroundBoxes(n) = b +next + + +width = render width() - 100 +height = render height() - 100 + +do + sprite 1, x, y, 0 + + if x > width then x = width + if y > height then y = height + if x < 100 then x = 100 + if y < 100 then y = 100 + + if downkey() + y = y + speed + endif + if upkey() + y = y - speed + endif + if leftkey() + x = x - speed + endif + if rightKey() + x = x + speed + endif + + for n = 0 to boxLength - 1 + b = backgroundBoxes(n) + + b.pos.x += b.vel.x + b.pos.y += b.vel.y + + if (b.pos.x < -100) + b.pos.x = render width() + 100 + endif + + position sprite b.spriteId, b.pos.x, b.pos.y + + backgroundBoxes(n) = b + next + + + sync + + _L1: +loop + +\` TODO: resizing the window should adjust the letter-boxing +\` TODO: auto complete is appearing on comment string +\` TODO: these types should live in another file, but they aren't working. +type box + spriteId + pos as vec + vel as vec + size as vec +endtype + +type vec + x + y +endtype +`; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[PE]', e.message.slice(0, 300))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.evaluate(async (src) => { + const opfs = await navigator.storage.getDirectory(); + const ws = await opfs.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgprobe2', { create: true }); + const cfg = JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgprobe2', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'; + const cw = await (await dir.getFileHandle('fade.json', { create: true })).createWritable(); + await cw.write(cfg); await cw.close(); + const sw = await (await dir.getFileHandle('main.fbasic', { create: true })).createWritable(); + await sw.write(src); await sw.close(); + localStorage.setItem('fade.activeProject', 'mgprobe2'); +}, SOURCE); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +console.log('→ Run + Debug start to populate statementLines…'); +await page.click('#run'); +await page.waitForSelector('#theCanvas', { timeout: 60_000 }); +await page.waitForTimeout(3_000); + +await page.click('#debug', { force: true }); +await page.waitForTimeout(2_500); + +// DebugStart returns { ok, statementLines } as JSON. +const startResult = await page.evaluate(async () => { + const json = await window.theInstance.invokeMethodAsync('DebugStart'); + return JSON.parse(json); +}); + +await browser.close(); + +if (!startResult.ok) { + console.error('DebugStart failed:', startResult.error); + process.exit(1); +} + +const lines = (startResult.statementLines || []).map(n => n + 1).sort((a, b) => a - b); +const lineSet = new Set(lines); + +// Show every source line with a marker indicating whether it has a token. +const srcLines = SOURCE.split('\n'); +const lineCount = srcLines.length; +console.log('\nLine-by-line: ✓ = has statement token, · = no token\n'); +for (let i = 0; i < lineCount; i++) { + const oneBased = i + 1; + const has = lineSet.has(oneBased); + const tag = has ? '✓' : '·'; + const lineNum = String(oneBased).padStart(3); + console.log(`${tag} ${lineNum} ${srcLines[i]}`); +} + +// Specifically highlight the 4 keyboard-input if-bodies the user +// referenced — show whether each body line has its own token. +const flagLines = []; +for (let i = 0; i < srcLines.length; i++) { + if (/^\s*(if downkey|if upkey|if leftkey|if rightKey)\(/i.test(srcLines[i])) { + flagLines.push({ kind: 'cond', line: i + 1, text: srcLines[i].trim() }); + flagLines.push({ kind: 'body', line: i + 2, text: srcLines[i + 1]?.trim() ?? '' }); + } +} +console.log('\nKeyboard-input if blocks:'); +for (const f of flagLines) { + const has = lineSet.has(f.line); + console.log(` line ${f.line} ${has ? '✓' : '·'} (${f.kind}) ${f.text}`); +} diff --git a/Playground/scripts/probe-syntax-colors.mjs b/Playground/scripts/probe-syntax-colors.mjs new file mode 100644 index 0000000..f1d84ad --- /dev/null +++ b/Playground/scripts/probe-syntax-colors.mjs @@ -0,0 +1,59 @@ +// Sample the actual rendered color of the same token across themes. +// Tells us whether monaco.editor.setTheme really swaps syntax colors. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 240))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise(r => setTimeout(r, 1800)); + +const THEMES = ['dark', 'light', 'dbp', 'dracula', 'monokai', 'nord']; +const out = {}; + +for (const id of THEMES) { + await page.evaluate((id) => { + const cur = JSON.parse(localStorage.getItem('fade.settings.user.v1') || '{}'); + cur['ui.theme'] = id; + localStorage.setItem('fade.settings.user.v1', JSON.stringify(cur)); + }, id); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + // Wait for LSP push (semantic tokens arrive a beat after open). + await new Promise(r => setTimeout(r, 2500)); + + const sample = await page.evaluate(() => { + const lines = document.querySelectorAll('.view-line'); + if (!lines.length) return { error: 'no .view-line in DOM' }; + // Grab the first non-empty line's first few spans; report their text + computed color. + const samples = []; + for (const line of lines) { + const spans = line.querySelectorAll('span > span'); + for (const s of spans) { + const text = (s.textContent || '').trim(); + if (!text) continue; + samples.push({ text, color: getComputedStyle(s).color, classes: s.className }); + if (samples.length >= 8) break; + } + if (samples.length >= 8) break; + } + return samples; + }); + out[id] = sample; + console.log(`[${id}]`, JSON.stringify(sample)); +} + +// Cross-check: do the colors for the 'print' token actually differ across themes? +const colorsOfPrint = {}; +for (const id of THEMES) { + const m = (out[id] || []).find(s => s.text === 'print'); + if (m) colorsOfPrint[id] = m.color; +} +console.log('\nprint-token color per theme:', colorsOfPrint); +const unique = new Set(Object.values(colorsOfPrint)); +console.log('unique colors:', unique.size, '/', Object.keys(colorsOfPrint).length); +await browser.close(); diff --git a/Playground/scripts/probe-themes.mjs b/Playground/scripts/probe-themes.mjs new file mode 100644 index 0000000..2d9bcb3 --- /dev/null +++ b/Playground/scripts/probe-themes.mjs @@ -0,0 +1,133 @@ +// Sweeps every available theme, switching via settings and asserting the +// three layers (html data-theme, dockview class, Monaco class) line up. +// Also reads computed CSS on the previously-broken selectors (file-list +// active row, help heading, logs background) to confirm light themes no +// longer pin dark values. + +import { chromium } from 'playwright'; +import { writeFileSync, mkdirSync } from 'fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 240))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Reset settings so we start from a known place. +await page.evaluate(() => localStorage.removeItem('fade.settings.user.v1')); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise(r => setTimeout(r, 1500)); + +const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR; +if (SCREENSHOT_DIR) mkdirSync(SCREENSHOT_DIR, { recursive: true }); + +const THEMES = [ + { id: 'dark', monaco: 'vs-dark', dockview: 'dockview-theme-vs' }, + { id: 'light', monaco: 'vs', dockview: 'dockview-theme-light' }, + { id: 'dracula', monaco: 'vs-dark', dockview: 'dockview-theme-dracula' }, + { id: 'solarized-dark', monaco: 'vs-dark', dockview: 'dockview-theme-vs' }, + { id: 'monokai', monaco: 'vs-dark', dockview: 'dockview-theme-vs' }, + { id: 'nord', monaco: 'vs-dark', dockview: 'dockview-theme-vs' }, + { id: 'high-contrast', monaco: 'hc-black', dockview: 'dockview-theme-vs' }, + { id: 'dbp', monaco: 'vs', dockview: 'dockview-theme-light' }, +]; + +async function switchTheme(id) { + await page.evaluate((id) => { + const cur = JSON.parse(localStorage.getItem('fade.settings.user.v1') || '{}'); + cur['ui.theme'] = id; + localStorage.setItem('fade.settings.user.v1', JSON.stringify(cur)); + }, id); + // Trigger the settings reload path the easy way: a full reload picks + // up the new value through initSettings + the boot-time applyTheme. + // Faster than driving the settings panel UI. + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + await new Promise(r => setTimeout(r, 800)); +} + +let failed = false; + +for (const t of THEMES) { + await switchTheme(t.id); + const snap = await page.evaluate(() => ({ + dataTheme: document.documentElement.dataset.theme, + dockClass: document.querySelector('.dv-shell')?.className ?? '', + monacoEditor: document.querySelector('.monaco-editor')?.className ?? '', + // Computed-style probes for the previously-broken selectors. We + // don't have a file in the active row for a fresh project; pick a + // common test bed: file-list, help TOC active mock, logs background. + fileListBg: getComputedStyle(document.querySelector('#file-list li.active') ?? document.body).backgroundColor, + rootBg: getComputedStyle(document.body).backgroundColor, + rootFg: getComputedStyle(document.body).color, + })); + console.log(`[${t.id}]`, JSON.stringify(snap)); + + if (snap.dataTheme !== t.id) { + console.error(` FAIL: data-theme expected ${t.id}, got ${snap.dataTheme}`); + failed = true; + } + if (!snap.dockClass.includes(t.dockview)) { + console.error(` FAIL: dockview class missing ${t.dockview} — got "${snap.dockClass}"`); + failed = true; + } + // Monaco base class — vs-dark adds `.vs-dark`, light is `.vs`, HC is `.hc-black`. + if (t.monaco === 'vs') { + if (!snap.monacoEditor.match(/\bvs\b/) || snap.monacoEditor.includes('vs-dark')) { + console.error(` FAIL: Monaco didn't pick up the light variant`); + failed = true; + } + } else if (t.monaco === 'hc-black') { + if (!snap.monacoEditor.includes('hc-black')) { + console.error(` FAIL: Monaco didn't pick up high-contrast`); + failed = true; + } + } else { + if (!snap.monacoEditor.includes('vs-dark')) { + console.error(` FAIL: Monaco didn't pick up a dark variant`); + failed = true; + } + } + + if (SCREENSHOT_DIR) { + const fname = `${SCREENSHOT_DIR}/theme-${t.id}.png`; + await page.screenshot({ path: fname, fullPage: false }); + console.log(` saved ${fname}`); + } +} + +// Sanity: verify the previously-broken selectors now read theme-aware values +// in light mode. The file-list active row should NOT be #37373d on light. +await switchTheme('light'); +const lightSelectors = await page.evaluate(() => { + const dummyLi = document.createElement('li'); + dummyLi.className = 'active'; + document.getElementById('file-list')?.appendChild(dummyLi); + const liBg = getComputedStyle(dummyLi).backgroundColor; + dummyLi.remove(); + // help TOC active class + const dummyTocActive = document.createElement('div'); + dummyTocActive.className = 'help-toc-item active'; + document.body.appendChild(dummyTocActive); + const tocColor = getComputedStyle(dummyTocActive).color; + const tocBg = getComputedStyle(dummyTocActive).backgroundColor; + dummyTocActive.remove(); + return { liBg, tocColor, tocBg }; +}); +console.log('light selectors after fix:', JSON.stringify(lightSelectors)); +// rgb(55, 55, 61) was the old broken hardcoded value +if (lightSelectors.liBg.includes('55, 55, 61')) { + console.error('FAIL: file-list active row still uses the dark hardcoded color'); + failed = true; +} + +if (failed) { + console.error('\nSome themes failed verification.'); + await browser.close(); process.exit(1); +} + +console.log('\nOK: all themes applied cleanly across html data-theme + dockview + Monaco'); +await browser.close(); diff --git a/Playground/scripts/smoke-collab.mjs b/Playground/scripts/smoke-collab.mjs new file mode 100644 index 0000000..40b4d82 --- /dev/null +++ b/Playground/scripts/smoke-collab.mjs @@ -0,0 +1,141 @@ +// Smoke test for the Live Session feature: boot two pages in the same +// Playwright browser context (BroadcastChannel works between them), have +// one host + one join via the Mock transport, then verify text typed on +// one page propagates to the other via the Yjs CRDT. +// +// Requires the dev server already running on port 5311 (`npm run dev`). + +import { chromium } from 'playwright'; + +const URL = 'http://localhost:5311/'; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const failures = []; + + async function makePage(label) { + const p = await context.newPage(); + p.on('console', (m) => { + const t = m.type(); + if (t === 'error' || t === 'warning') { + console.log(`[${label}] console.${t}:`, m.text()); + } + }); + p.on('pageerror', (e) => { + console.log(`[${label}] PAGE ERROR:`, e.message); + failures.push(`${label}: ${e.message}`); + }); + await p.goto(URL, { waitUntil: 'load' }); + await p.waitForFunction( + () => window.monaco != null && window.__fadeDockview != null, + { timeout: 30000 }, + ); + return p; + } + + const host = await makePage('host'); + const guest = await makePage('guest'); + + // Both pages: open the first file from the workspace tree. + for (const [label, p] of [['host', host], ['guest', guest]]) { + try { + await p.waitForSelector('#file-list li.file-row', { timeout: 15000 }); + await p.click('#file-list li.file-row >> nth=0'); + await p.waitForTimeout(500); + } catch (e) { + console.log(`[${label}] failed to open first file:`, e.message); + } + } + + async function openLiveSession(p, label) { + const viewBtn = p.locator('button:has-text("View")').first(); + await viewBtn.click(); + await p.waitForTimeout(200); + await p.locator('button.view-menu-item:has-text("Live Session")').first().click(); + await p.waitForTimeout(500); + const headerVisible = await p.locator('.fade-live-header:has-text("Live Session")').isVisible(); + if (!headerVisible) failures.push(`${label}: Live Session panel header not visible`); + } + + await openLiveSession(host, 'host'); + await openLiveSession(guest, 'guest'); + + // Host: start a session. + await host.locator('.fade-live-btn:has-text("Host a session")').click(); + await host.waitForSelector('.fade-live-modal'); + await host.fill('.fade-live-modal-field input[type="text"]', 'Alice'); + const transportSelect = host.locator('.fade-live-modal-field select'); + if (await transportSelect.count() > 0) { + await transportSelect.selectOption({ label: /Mock/ }); + } + await host.locator('.fade-live-modal button[type="submit"]').click(); + await host.waitForSelector('.fade-live-code-box', { timeout: 8000 }); + const roomId = (await host.locator('.fade-live-code-box').first().textContent())?.trim(); + console.log('[host] roomId =', roomId); + if (!roomId) failures.push('host: failed to obtain roomId'); + + // Guest: join. + await guest.locator('.fade-live-btn:has-text("Join a session")').click(); + await guest.waitForSelector('.fade-live-modal'); + const inputs = guest.locator('.fade-live-modal-field input'); + await inputs.nth(0).fill('Bob'); + await inputs.nth(1).fill(roomId ?? ''); + const gSel = guest.locator('.fade-live-modal-field select'); + if (await gSel.count() > 0) { + await gSel.selectOption({ label: /Mock/ }); + } + await guest.locator('.fade-live-modal button[type="submit"]').click(); + await guest.waitForSelector('.fade-live-banner-guest', { timeout: 8000 }); + + await host.waitForTimeout(800); + + const hostPeers = await host.locator('.fade-live-peer-name').allTextContents(); + const guestPeers = await guest.locator('.fade-live-peer-name').allTextContents(); + console.log('[host] peer list:', hostPeers); + console.log('[guest] peer list:', guestPeers); + if (!hostPeers.some((n) => /Bob/.test(n))) failures.push('host did not see Bob in peer list'); + if (!guestPeers.some((n) => /Alice/.test(n))) failures.push('guest did not see Alice in peer list'); + + // Type on the host, verify it appears on the guest. + await host.evaluate(() => { + const ed = window.monaco.editor.getEditors()[0]; + const model = ed.getModel(); + if (!model) throw new Error('no model'); + model.setValue('hello from alice\n' + model.getValue()); + }); + await host.waitForTimeout(800); + + const guestText = await guest.evaluate(() => { + const ed = window.monaco.editor.getEditors()[0]; + return ed?.getModel()?.getValue() ?? null; + }); + console.log('[guest] editor first 60 chars:', (guestText ?? '').slice(0, 60)); + if (!guestText || !guestText.startsWith('hello from alice')) { + failures.push(`guest did not receive host edit. text was: ${(guestText ?? '').slice(0, 80)}`); + } + + // Reverse direction. + await guest.evaluate(() => { + const ed = window.monaco.editor.getEditors()[0]; + const model = ed.getModel(); + model.setValue('typed by bob\n' + model.getValue()); + }); + await guest.waitForTimeout(800); + const hostText = await host.evaluate(() => { + const ed = window.monaco.editor.getEditors()[0]; + return ed?.getModel()?.getValue() ?? null; + }); + if (!hostText || !hostText.startsWith('typed by bob')) { + failures.push(`host did not receive guest edit. text was: ${(hostText ?? '').slice(0, 80)}`); + } + + await browser.close(); + + if (failures.length) { + console.error('\nFAILURES:'); + for (const f of failures) console.error(' -', f); + process.exit(1); + } + console.log('\nALL CHECKS PASSED'); +})(); diff --git a/Playground/scripts/smoke-game-overlay.mjs b/Playground/scripts/smoke-game-overlay.mjs new file mode 100644 index 0000000..40bf010 --- /dev/null +++ b/Playground/scripts/smoke-game-overlay.mjs @@ -0,0 +1,102 @@ +// Smoke test that verifies the game-stream overlay isn't shadowing the +// real game surface when no live session is active. After Phase 2A +// landed the overlay used `display: flex` in its inline style — that +// shadowed the [hidden] attribute's UA display:none, so the overlay was +// permanently visible and covered both the web iframe and the monogame +// Blazor root with a black box. This probe boots the page, asserts the +// overlay element has `hidden` set, has zero rendered size, and that the +// canvas underneath (web iframe or monogame root) is visible. +// +// Requires `npm run dev` running on localhost:5311. + +import { chromium } from 'playwright'; + +const URL = 'http://localhost:5311/'; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const failures = []; + page.on('pageerror', (e) => failures.push('pageerror: ' + e.message)); + + // We deliberately don't wait for full bootstrap (the dev env has a + // pre-existing LSP-worker init failure that aborts bootstrap before + // monaco/dockview attach). The bug we're verifying is in the static + // index.html — the overlay is in the DOM as soon as the page parses. + await page.goto(URL, { waitUntil: 'domcontentloaded' }); + // Give CSS a beat to apply. + await page.waitForTimeout(500); + + const overlayInfo = await page.evaluate(() => { + const overlay = document.getElementById('game-stream-overlay'); + if (!overlay) return { exists: false }; + const rect = overlay.getBoundingClientRect(); + const cs = window.getComputedStyle(overlay); + return { + exists: true, + hidden: overlay.hidden, + display: cs.display, + width: rect.width, + height: rect.height, + zIndex: cs.zIndex, + }; + }); + console.log('overlay:', overlayInfo); + if (!overlayInfo.exists) failures.push('overlay element missing entirely'); + else { + if (!overlayInfo.hidden) failures.push('overlay.hidden is not true at idle'); + if (overlayInfo.display !== 'none') failures.push(`overlay computed display is "${overlayInfo.display}", expected "none"`); + if (overlayInfo.width !== 0 || overlayInfo.height !== 0) { + failures.push(`overlay is taking ${overlayInfo.width}x${overlayInfo.height} space; should be 0x0 when hidden`); + } + } + + // The monogame Blazor root or the web iframe should be visible + // underneath. They're siblings inside the game panel-cell. + const surfacesInfo = await page.evaluate(() => { + const mg = document.getElementById('mg-blazor-root'); + const webHost = document.getElementById('web-preview-host'); + const mgCs = mg ? window.getComputedStyle(mg) : null; + const webCs = webHost ? window.getComputedStyle(webHost) : null; + return { + mgPresent: !!mg, + mgDisplay: mgCs?.display ?? null, + webPresent: !!webHost, + webDisplay: webCs?.display ?? null, + }; + }); + console.log('surfaces:', surfacesInfo); + // At least one of the two surfaces should be rendered (display !== 'none'). + const anyVisible = + (surfacesInfo.mgPresent && surfacesInfo.mgDisplay !== 'none') || + (surfacesInfo.webPresent && surfacesInfo.webDisplay !== 'none'); + if (!anyVisible) failures.push('neither game surface is visible (mg + web both display:none)'); + + // Toggle the overlay visible and confirm display flips to flex. + // Mirrors what main.ts's showGameStreamOverlay() does when a remote + // peer starts running — flips hidden off, then sets the banner text. + // If the cascade is wrong, display would stay `none` and we'd never + // see the streamed frames. + const toggledInfo = await page.evaluate(() => { + const overlay = document.getElementById('game-stream-overlay'); + if (!overlay) return null; + overlay.hidden = false; + const cs = window.getComputedStyle(overlay); + return { display: cs.display, position: cs.position }; + }); + console.log('toggled-visible:', toggledInfo); + if (!toggledInfo) failures.push('overlay missing on toggle test'); + else if (toggledInfo.display !== 'flex') { + failures.push(`after setting hidden=false, display is "${toggledInfo.display}", expected "flex"`); + } + + await browser.close(); + + if (failures.length) { + console.error('\nFAILURES:'); + for (const f of failures) console.error(' -', f); + process.exit(1); + } + console.log('\nALL CHECKS PASSED — game overlay is correctly hidden at idle'); +})(); diff --git a/Playground/scripts/snap-sprite.mjs b/Playground/scripts/snap-sprite.mjs new file mode 100644 index 0000000..90d96e4 --- /dev/null +++ b/Playground/scripts/snap-sprite.mjs @@ -0,0 +1,117 @@ +// Visual smoke test for the user's source: +// set screen size 400, 400 +// set render size 200, 200 +// sprite 1, 100, 100, 0 +// size sprite 1, 100, 100 +// do +// sync +// loop +// We open a monogame project with that source, hit Run, wait, then +// screenshot the canvas. A non-uniform image proves the basic sprite +// pipeline reaches the canvas; uniform/black means the render path +// is still broken. + +import { chromium } from 'playwright'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { writeFileSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); + +const URL = process.env.URL || 'http://localhost:5314/'; +const SOURCE = `set screen size 400, 400 +set render size 200, 200 + +sprite 1, 100, 100, 0 +size sprite 1, 100, 100 + +do + sync +loop +`; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); +page.on('console', m => { + const t = m.type(); + if (t === 'error') console.log('[E]', m.text().slice(0, 200)); + else if (t === 'warning') { + const txt = m.text(); + if (!txt.includes('CSS') && !txt.includes('Linking')) console.log('[W]', txt.slice(0, 200)); + } +}); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.evaluate(async (src) => { + const opfs = await navigator.storage.getDirectory(); + const ws = await opfs.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgsprite', { create: true }); + const cfg = JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgsprite', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'; + const cw = await (await dir.getFileHandle('fade.json', { create: true })).createWritable(); + await cw.write(cfg); await cw.close(); + const sw = await (await dir.getFileHandle('main.fbasic', { create: true })).createWritable(); + await sw.write(src); await sw.close(); + localStorage.setItem('fade.activeProject', 'mgsprite'); +}, SOURCE); + +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +// Click Run. First click boots the runtime (~8 MB), subsequent clicks +// hot-reload via Game1.LoadProgram. +console.log('→ click Run (lazy WASM boot)…'); +await (await page.$('#run')).click(); +await page.waitForSelector('#theCanvas', { timeout: 60_000 }); +await page.waitForTimeout(4_000); + +// Read the canvas pixel spread + dump a screenshot. +const canvasHandle = await page.$('#theCanvas'); +const pngBytes = await canvasHandle.screenshot({ type: 'png' }); +writeFileSync('/tmp/mg-sprite.png', pngBytes); + +const spread = await page.evaluate(async (b64) => { + const bin = atob(b64); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + const blob = new Blob([u8], { type: 'image/png' }); + const bmp = await createImageBitmap(blob); + const probe = document.createElement('canvas'); + probe.width = 64; probe.height = 64; + const ctx = probe.getContext('2d'); + ctx.drawImage(bmp, 0, 0, 64, 64); + const px = ctx.getImageData(0, 0, 64, 64).data; + let minR = 255, maxR = 0, minG = 255, maxG = 0, minB = 255, maxB = 0; + let whiteCount = 0; + for (let i = 0; i < px.length; i += 4) { + const r = px[i], g = px[i+1], b = px[i+2]; + if (r < minR) minR = r; if (r > maxR) maxR = r; + if (g < minG) minG = g; if (g > maxG) maxG = g; + if (b < minB) minB = b; if (b > maxB) maxB = b; + if (r > 200 && g > 200 && b > 200) whiteCount++; + } + return { minR, maxR, minG, maxG, minB, maxB, whiteCount, totalPixels: (px.length / 4) }; +}, Buffer.from(pngBytes).toString('base64')); + +console.log('canvas spread:', JSON.stringify(spread)); +console.log('screenshot at: /tmp/mg-sprite.png'); + +await browser.close(); + +const hasSpread = (spread.maxR - spread.minR) + (spread.maxG - spread.minG) + (spread.maxB - spread.minB) > 30; +const hasWhite = spread.whiteCount > 0; + +if (!hasSpread || !hasWhite) { + console.error('\n✗ FAIL: no white sprite visible. Render path still missing pixels.'); + console.error(' spread:', hasSpread, ' any-white-pixels:', hasWhite); + process.exit(1); +} +console.log('\n✓ PASS: sprite (white square) is rendering on the canvas.'); +console.log(' white pixels:', spread.whiteCount, '/', spread.totalPixels); diff --git a/Playground/scripts/snap-user-source.mjs b/Playground/scripts/snap-user-source.mjs new file mode 100644 index 0000000..8aafff5 --- /dev/null +++ b/Playground/scripts/snap-user-source.mjs @@ -0,0 +1,122 @@ +// Reproduces the user's exact source and snapshots the canvas at high +// resolution. Diagnoses two issues at once: +// 1. set background color should produce grey, not black. +// 2. The 200x200 sprite should appear as a SQUARE in screen space +// (200x200 within a 1920x1080 mainBuffer letterboxed into the +// canvas; uniform scale, so square stays square). + +import { chromium } from 'playwright'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { writeFileSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); + +const URL = process.env.URL || 'http://localhost:5314/'; +const SOURCE = `set screen size 400, 400 +set render size 1920, 1080 + +set background color rgb(128, 128, 128) +sprite 1, 100, 100, 0 +color sprite 1, rgb(255, 0, 0) +size sprite 1, 200, 200 +x = 180 + +do + sprite 1, x, 100, 0 + + if x < 200 then x = x + 1 + + sync +loop +`; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +page.on('pageerror', e => console.log('[pageerror]', e.message.slice(0, 200))); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await page.evaluate(async (src) => { + const opfs = await navigator.storage.getDirectory(); + const ws = await opfs.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgmove', { create: true }); + const cfg = JSON.stringify({ + $schema: '/fade.schema.json', name: 'mgmove', type: 'monogame', + commandDlls: [], sources: ['main.fbasic'], + }, null, 2) + '\n'; + const cw = await (await dir.getFileHandle('fade.json', { create: true })).createWritable(); + await cw.write(cfg); await cw.close(); + const sw = await (await dir.getFileHandle('main.fbasic', { create: true })).createWritable(); + await sw.write(src); await sw.close(); + localStorage.setItem('fade.activeProject', 'mgmove'); +}, SOURCE); + +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /Ready/i.test(document.getElementById('status')?.textContent || ''), { timeout: 30_000 }); + +await (await page.$('#run')).click(); +await page.waitForSelector('#theCanvas', { timeout: 60_000 }); +await page.waitForTimeout(4_000); + +// Dump canvas + measure its CSS box vs drawing buffer. +const dims = await page.evaluate(() => { + const c = document.getElementById('theCanvas'); + const r = c.getBoundingClientRect(); + return { + cssBox: { w: Math.round(r.width), h: Math.round(r.height) }, + drawingBuffer: { w: c.width, h: c.height }, + }; +}); +console.log('canvas dims:', JSON.stringify(dims)); + +const canvasHandle = await page.$('#theCanvas'); +const pngBytes = await canvasHandle.screenshot({ type: 'png' }); +writeFileSync('/tmp/mg-user-source.png', pngBytes); + +// Pixel-spread + red-pixel count + bounding box of red pixels. +const probe = await page.evaluate(async (b64) => { + const bin = atob(b64); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + const blob = new Blob([u8], { type: 'image/png' }); + const bmp = await createImageBitmap(blob); + const probe = document.createElement('canvas'); + probe.width = bmp.width; probe.height = bmp.height; + const ctx = probe.getContext('2d'); + ctx.drawImage(bmp, 0, 0); + const px = ctx.getImageData(0, 0, bmp.width, bmp.height).data; + let minX = bmp.width, maxX = 0, minY = bmp.height, maxY = 0; + let redCount = 0, greyCount = 0, blackCount = 0; + for (let y = 0; y < bmp.height; y++) { + for (let x = 0; x < bmp.width; x++) { + const i = (y * bmp.width + x) * 4; + const r = px[i], g = px[i+1], b = px[i+2]; + const isRed = r > 200 && g < 70 && b < 70; + const isGrey = r > 100 && r < 160 && Math.abs(r - g) < 10 && Math.abs(r - b) < 10; + const isBlack = r < 20 && g < 20 && b < 20; + if (isRed) { + redCount++; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + } + if (isGrey) greyCount++; + if (isBlack) blackCount++; + } + } + return { + imageSize: { w: bmp.width, h: bmp.height }, + redCount, greyCount, blackCount, + redBounds: redCount > 0 + ? { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1, aspect: (maxX - minX + 1) / (maxY - minY + 1) } + : null, + }; +}, Buffer.from(pngBytes).toString('base64')); + +console.log('probe:', JSON.stringify(probe, null, 2)); +console.log('saved /tmp/mg-user-source.png'); + +await browser.close(); diff --git a/Playground/scripts/sync-public-docs.mjs b/Playground/scripts/sync-public-docs.mjs new file mode 100644 index 0000000..b931764 --- /dev/null +++ b/Playground/scripts/sync-public-docs.mjs @@ -0,0 +1,37 @@ +// Mirrors static doc files into Playground/public/docs/ so Vite can serve +// them at /docs/.md without us needing a long-lived copy in the repo. +// The Help tab's "Language" and "Playground" surfaces fetch from those +// paths on activation. +// +// Sources stay in their canonical homes; we just copy on every (re)build. +// Cheap — tens of KB at most. + +import { copyFile, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const playgroundDir = resolve(__dirname, '..'); +const repoRoot = resolve(playgroundDir, '..'); + +const targets = [ + { + src: resolve(repoRoot, 'FadeBasic', 'book', 'FadeBook', 'Language.md'), + dst: resolve(playgroundDir, 'public', 'docs', 'Language.md'), + }, + { + src: resolve(playgroundDir, 'docs', 'Playground.md'), + dst: resolve(playgroundDir, 'public', 'docs', 'Playground.md'), + }, +]; + +for (const t of targets) { + if (!existsSync(t.src)) { + console.warn(`[sync-public-docs] missing source: ${t.src}`); + continue; + } + await mkdir(dirname(t.dst), { recursive: true }); + await copyFile(t.src, t.dst); + console.log(`[sync-public-docs] ${t.src} → ${t.dst}`); +} diff --git a/Playground/scripts/test-binary-preview.mjs b/Playground/scripts/test-binary-preview.mjs new file mode 100644 index 0000000..a9e4350 --- /dev/null +++ b/Playground/scripts/test-binary-preview.mjs @@ -0,0 +1,333 @@ +// Smoke test for the binary-file preview pane. Synthesizes a minimal valid +// XNB (2×2 Texture2D, Color surface format, single mip) and a minimal valid +// SoundEffect XNB (PCM, 8 samples), writes them into OPFS, then clicks the +// rows in the file list and asserts the preview pane renders the right +// payload (canvas with correct dims for the texture; an