();
+ 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
+
+
+
+
+
+
+
+
+
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