From dab48371c684b54eed2bf0e4d7be975c3fbdf59b Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Tue, 28 Apr 2026 21:51:15 -0400 Subject: [PATCH 01/30] doc --- FadeBasic/TEST_DESIGN.md | 413 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 FadeBasic/TEST_DESIGN.md diff --git a/FadeBasic/TEST_DESIGN.md b/FadeBasic/TEST_DESIGN.md new file mode 100644 index 0000000..961bc11 --- /dev/null +++ b/FadeBasic/TEST_DESIGN.md @@ -0,0 +1,413 @@ +# Fade Test Design + +A design sketch for first-class testing in Fade. Companion to [TESTS.md](TESTS.md), which covers the `dotnet test` integration plumbing. This doc covers the **language-level** test model: what tests look like in `.fbasic` source. + +## The core insight + +Conventional test frameworks (xUnit, NUnit, MSTest) assume the host language has a clear separation between *declarations* (functions, types) and *execution* (a `Main`). Tests work by calling functions in isolation. Fade, like classic BASIC, has no such separation — the program *is* the imperative top-level body. There is no `Main` to skip, and most interesting behavior lives inside the main loop, not in factor-out-able functions. + +A test framework that grafts xUnit's model onto Fade ends up either: +- forcing users to refactor everything into testable functions (fights the language), or +- creating a separate "test scope" with awkward bridges to program state (the scope-access problem). + +The fresh paradigm: **a test is a script that drives the program between its natural pause points.** + +The program's labels (`:start`, `:sync`) become cue points. A test pauses execution at a label, optionally pokes state, advances to the next label, and asserts. These are ordinary language labels — the same kind used by `goto`/`gosub` — not a test-only construct, so adding tests to Fade requires no new label machinery and labels carry no runtime cost. Scope merges automatically because the test is *inside* the paused program — it's a debugger session expressed as code. + +## Headline syntax + +```fbasic +` --- program --- + +imageId = 1 +texture imageId, "fish.png" + +x = 0 +y = 0 +sprite 1, x, y, imageId + +:start +sync rate 60 +do + :step + x = x + 1 + set sprite position 1, x, y + if x > screen width() + x = 0 + endif + :sync + sync +loop + +` --- tests --- + +abstract test root + local fakeWidth = 10 + mock screen width + returns fakeWidth + endmock +endtest + +test wraps_at_right_edge from root + runto :start + assert x is 0 + + for n = 1 to fakeWidth + 1 + runto :sync + next + + assert x is 0 ` should have wrapped exactly once +endtest + +test moves_one_per_frame from root + runto :start + local before = x + runto :sync + assert x is before + 1 +endtest +``` + +## Design principles + +### 1. Test is a block that captures a token stream into the test manifest + +`test name ... endtest` is a top-level construct. Not `#test` decorating a function. This: +- Eliminates the "test scope vs function scope" tension. +- Lets the test body refer to program-scope identifiers directly. +- Removes the awkward "register the function" step. + +`test` implicitly opens a tokenize-flavored region whose tokens route to the **test manifest**, not back into program code. No `#tokenize` keyword required — `test` knows. In `dotnet run` builds, test blocks compile to nothing — there is no manifest. In `dotnet test` builds, each block contributes one entry. + +**Tests compose with `#macro`.** A `test` block can sit inside a `#macro` and consume its compile-time values via `[name]` substitution, exactly like a `#tokenize` region: + +```fbasic +#macro + for n = 1 to 5 + test "addCorrectly_" + [n] + assert add([n], [n]) is [n] * 2 + endtest + next +#endmacro +``` + +This is *not* nested macros. `test` is a tokenize-flavored region, not a macro itself, so it's legal inside `#macro`. What's still illegal: opening a new `#macro` block inside a test that's already inside `#macro`. If a test inside an explicit `#macro` needs compile-time setup, just put it in the surrounding macro before the `test`. + +**Top-level `test` blocks have a hoist rule.** When a test sits at top level (not inside an explicit `#macro`), the compiler synthesizes a `#macro` wrapper around it. If the author writes a `#macro ... #endmacro` block inside the test body, those statements are hoisted out of the test and into the synthesized wrapper: + +```fbasic +` source +test a + #macro + x = 23 + #endmacro + assert [x] is 23 +endtest + +` desugared +#macro + x = 23 + test a + assert [x] is 23 + endtest +#endmacro +``` + +This preserves the no-nested-macros rule (the inner `#macro` was notational; it never really nests) while letting authors write per-test compile-time setup locally. + +### 2. Fixtures are tests you continue from + +`test B from A` runs A's body, then continues from where A left off into B's body. No separate fixture concept. A test with only setup is a fixture in spirit; mark it `abstract test` to declare "do not run on its own, only continue from." + +The semantic emphasis is *continuation*, not classical inheritance. Child B doesn't inherit definitions and re-execute fresh — it picks up from A's exact ending state, including any program execution A drove (mocks installed, `runto`s already taken, program counter wherever A paused it). + +Composition rules: +- The parent's `local` declarations and test-functions are visible to the child. +- The parent's mock queues are present at the child's start (since the parent's body runs first and installs them). The child can append further entries, or `clear mock` to start fresh. +- Single-parent only for now. + +Two valid implementation strategies, with the same observable semantics: + +- **Replay.** Each child run starts fresh and re-executes its parent chain top-down before running the child body. Simple, robust, slow as the chain grows. +- **Snapshot.** After a parent finishes, take a snapshot of VM state (and any mockable C# host state). Child runs start from the snapshot. Faster for deep trees with shared expensive setup; harder to implement because the snapshot has to capture everything reachable — VM stack, heap, mock table, host-side bindings — and restore it perfectly. + +Replay is the right default. Snapshot is an optimization for later, gated on a perf measurement that says it matters. The contract should be defined in terms of observable behavior ("child sees parent's ending state") so either implementation is valid. + +### 3. Mocks are FIFO queues of behaviors + +```fbasic +mock screen width + returns fakeWidth +endmock +``` + +A `mock` block configures behavior for a Fade command at the C# boundary. The simple form above means *"every call to `screen width` returns `fakeWidth`, for the rest of the test."* + +**Mocks are queues, not single overrides.** The body of a `mock` block is an ordered list of behavior entries. Each call to the command consumes from the front of the queue (FIFO). Frequency words on each entry control how many calls that entry serves: + +```fbasic +mock screen width + returns 10 once + returns 20 once + returns 5 always +endmock +``` + +- Call 1 → `10` +- Call 2 → `20` +- Call 3 onwards → `5` + +Frequency words: `once`, `n times`, `always`. Default (no frequency word) is `always` — the entry stays in the queue forever and serves every call until the queue is reconfigured. Anything beyond an `always` entry is unreachable and produces a build warning. + +**Behavior entries:** + +- `returns ` — push the value of `` as the command's return. `` is any Fade expression evaluated in the test's scope; it can reference `local`s, mocks, captured globals (post-`runto`), test-functions. +- `forbid` — calling the command at this point fails the test with *"command `screen width` was forbidden by mock at line N."* Useful as a strict-mode terminator: *"after the first call returns 10, no more calls are allowed."* + +That's the v1 surface. Two behaviors plus three frequency words. + +**Exhausted queue → real implementation.** When a command is invoked and the queue is empty (either never set up or fully consumed), the real C# implementation runs. Mocks are an *override*, not a *requirement*. This composes with the rest of the design: math commands and other side-effect-free utilities just work without explicit mocking. Tests that need strict guards add `forbid` as a final entry. + +**Multi-block mocks append.** Re-declaring `mock ` in the same test adds entries to the existing queue rather than replacing it. To wipe and start over: + +```fbasic +clear mock screen width ` empty the queue for one command +clear mocks ` empty all queues +``` + +This is mostly useful between phases of a long test or when overriding a `from`-parent's mocks. + +**All overloads share one queue.** A `mock screen width` configures the queue for every overload of `screen width(...)`. Argument-based dispatch is deferred to a later phase. + +**Per-test isolation.** The mock table lives on the VM. Since each test gets a fresh VM instance, mock state is naturally isolated — no leakage between tests. + +**Mechanism.** When a command-invocation OPCODE fires, the dispatcher consults the mock table first: + +``` +on command_invoke(cmd_id, args): + queue = mockTable[cmd_id] + if queue.empty: + invoke_real_implementation(cmd_id, args) + else: + entry = queue.peek() + match entry: + returns expr → push value of expr; decrement-or-pop + forbid → fail test +``` + +Queue entries hold a remaining-uses counter; `once` is `1`, `n times` is `n`, `always` is `infinity`. Decrement-or-pop drops the entry when its counter hits zero (except `always`, which is never popped). + +### 4. `runto :label` drives the program + +The fundamental time-control primitive. Its name echoes `gosub` / `goto` so the label-targeted nature reads at a glance: "go [forward] until you hit this label." The test pauses at the label; the test body then executes assertions or mutations; another `runto` advances further. + +Two forms — a simple inline form and a block form for additional constraints: + +```fbasic +` simple form +runto :sync + +` block form — extensible +runto :sync + max cycles 1000 +endrunto +``` + +The block form opens the door to future conditions without breaking the simple case. `max cycles N` budgets the number of VM cycles before the test fails — a guard against runaway loops. (Counting VM cycles, not frames, lets the same budget mean the same thing whether the program is in a tight inner loop or rendering at 60 fps.) + +Mocks and `local` declarations can precede the first `runto`. Before the first `runto`, no program top-level code has executed yet — globals declared with `global X = ...` are present at their initial values, but names introduced only by main-body assignments (e.g., bare `x = 5` at top level) are not yet bound and referencing them is an error. This means a typical test reads as: setup mocks → first `runto` enters the program → script execution forward through labels → assert. + +**Targets are any label.** `runto :L` is valid for any label, top-level or inside a function. Stepping into a function and asserting about its state is a first-class use case — tests aren't limited to driving the main loop. + +**Resume is stack-agnostic.** When the program is paused (top-level or mid-function) and the test issues a `runto`, the VM resumes execution as-is from wherever the program was. The program's call stack is honored — functions return naturally, gosubs resolve naturally, the test doesn't reach into the stack to unwind anything. `RUNTO_YIELD` fires whenever the program's IP reaches the target, regardless of how deep the call stack is at that moment. The `max cycles` clause guards against runaway loops or programs that never reach the target. + +### 5. Scope merges at the pause point + +Inside a test block, after a `runto`, identifier resolution is **read-through, write-through** to the paused program's scope: + +- `x` reads the program's x. +- `x = 5` mutates the program's x. +- `local foo = 10` declares a test-only name. New names without `local` also become test-locals; `local` is the explicit, documented form. + +Mental model: it's exactly what you'd type into a debugger console at a breakpoint. The test should feel the same. + +**Strict semantic.** The visible scope after `runto :L` is exactly the set of names that would be in scope at line `:L` if the test code were spliced in there. This is computed statically via a `scope_at(:L)` map. + +For a top-level label, that's *globals + any name declared by main-body execution up to `:L`*. For a label inside a function, that's *the function's parameters + locals declared up to `:L`, plus globals visible at the function's callsites* — exactly what Fade's existing scope checker already computes for any line of any function. The test scope query just asks "what's in scope at this address?" and reuses the answer. + +The semantic is *as if* the test body were spliced into the program at each `runto` point. So this fails type-check: + +```fbasic +x = 12 +:label +x = x + 1 +:later +y = 12 + +test example + runto :label + assert y is x ` ERROR: y not in scope_at(:label) +endtest +``` + +Because spliced in: + +```fbasic +x = 12 +:label +assert y is x ` y is unknown here — only declared after :later +x = x + 1 +:later +y = 12 +``` + +After a second `runto :later`, the visible set updates to `scope_at(:later)`, and `y` becomes visible. Each distinct runto target carries its own scope. + +**Branch rule for declarations.** Following Fade's existing semantics, names introduced in any branch of a top-level `if/else` are considered declared at the merge point — even if a runtime branch could have skipped them. Their value defaults to zero/empty if the assigning branch didn't execute. Example: after `if condition then ta = 3 else tb = 4`, both `ta` and `tb` are declared and visible regardless of which branch ran. + +**Pre-runto function calls.** Calling a program function from a test before any `runto` reuses Fade's existing function-callsite analysis: the function's transitively-read names must all be declared at the callsite. If the program top-level body hasn't run yet, names declared only by main-body assignments aren't yet present, and the test gets the same parse-time error a regular Fade program would for calling a function ahead of its dependencies. No new machinery — the test's `visible` set is just another callsite snapshot fed to the existing check. + +### 6. Tests can declare their own functions + +Tests can define functions for code reuse, scoped just like `local` variables: + +```fbasic +abstract test root + local fakeWidth = 10 + mock screen width + returns fakeWidth + endmock + + function expect_in_bounds() + assert x >= 0 + assert x <= fakeWidth + endfunction +endtest + +test wraps_at_right_edge from root + runto :start + expect_in_bounds() ` carried forward from root + for n = 1 to fakeWidth + 1 + runto :sync + expect_in_bounds() + next +endtest +``` + +Rules: +- A function declared inside a test is visible only within that test and any test that continues `from` it. +- The `from`-chain carries functions forward, the same way it carries `local` variables and mocks. +- A test-scoped function can call program-scope functions normally, but cannot redeclare one (use a different name). +- Test functions can do everything regular Fade functions can — including `assert`, since they only execute inside a test context. + +### 7. Label namespaces are walled off + +Tests can declare their own labels (e.g., `:retry`) and `goto` / `gosub` them freely. They **cannot** `goto` or `gosub` into a program label — that's strictly the job of `runto`. + +```fbasic +test retries_three_times from root + local attempts = 0 + :retry ` test-local label — fine + runto :sync + attempts = attempts + 1 + if attempts < 3 then goto :retry ` jumping within the test — fine + + ` goto :start ` would be an error — :start is a program label + runto :start ` correct way to advance to a program label +endtest +``` + +Rules: +- `goto` / `gosub` inside a test resolve only against test-local labels. +- Targeting a program label with `goto` / `gosub` is a parse-time error — the resolver doesn't fall through. +- `runto` is the only construct that crosses from test scope into program scope. +- Symmetrically, program code can't `goto` or `gosub` into a test — but that's already true since test blocks compile to nothing in run builds. + +This namespace separation means tests can never accidentally jump into the middle of program execution. Every entry into program code is explicit and label-targeted. + +### 8. Compiler foothold + +The model maps cleanly onto the existing parser/checker architecture: + +- **Lex/macro pass.** `test ... endtest` is recognized as a tokenize-flavored region during macro expansion, alongside `#tokenize ... #endtokenize`. Tokens emitted from inside a `test` body are routed to the test manifest, not back into the program token stream. Two desugaring rules apply at this stage: + 1. A top-level `test` (not inside an explicit `#macro`) is wrapped in a synthesized `#macro` block. + 2. Inner `#macro ... #endmacro` blocks written inside a top-level `test` body are hoisted out of the test and into that synthesized wrapper, preserving the no-nested-macros invariant. +- **Parsing.** A new `TestNode` (parallel to `FunctionStatement`) joins `ProgramNode` as a top-level form. Each `TestNode` carries its own `labels`, `functions`, and statement body — same shape as a function. After macro expansion, parameterized tests have already been unrolled into N distinct `TestNode`s with `[name]` substitutions resolved. +- **Scope.** Each test gets its own `Scope`, much like a function does today. The test's scope holds test-local variables, test-local functions, and test-local labels. Identifier resolution after a `runto :L` consults a `scope_at(L)` snapshot — the set of names that would be in scope at line `:L` if you wrote regular Fade code there. For top-level labels, that's globals plus any main-body declaration up to `:L`. For function-internal labels, it's the function's parameters and locals declared up to `:L`, plus globals visible at the function's callsites. Both cases are answered by reusing the existing `ScopeErrorVisitor` result — it already computes what's in scope at every line; tests just query it for runto targets. +- **Checks added to `ScopeErrorVisitor`.** Three new validations on top of the existing `labelTable` / `functionTable` machinery: + 1. `runto :L` targets must exist as a label somewhere in the program — either in `program.labels` or in any `function.labels`. Both are valid. + 2. `goto` / `gosub` inside a test must resolve to a label declared inside that same test (or an upstream test in its `from`-chain). Falling through to program labels is an error. + 3. Identifiers referenced inside a test must resolve against (test-locals + mocks + test-functions + `scope_at(most-recent-runto)`). Before any `runto`, the runto component is empty — only globals declared via `global` are present. Function calls into the program reuse the existing function-callsite analysis with the test's current `visible` set as the callsite snapshot. +- **Runtime: two opcodes, one stack, one constructor parameter.** No context-switching machinery, no second instruction pointer, no breakpoint table. Address-as-data plus the VM's existing jump primitives — defer-flavored, not debugger-flavored. + - **`OpCodes.RUNTO`** (test-side, emitted once per `runto` statement). Pushes `(target_addr, resume_ip)` onto a new `runtoStack` on `VirtualMachine`, then sets `instructionIndex` to where the program is currently paused: the program's `__main` entry on first runto, the saved program IP on subsequent ones. + - **`OpCodes.RUNTO_YIELD`** (program-side, emitted at every label referenced by a `runto` somewhere in the test corpus). When `runtoStack.Peek().target_addr == instructionIndex`, pops the entry, stores the program's current IP into it, and sets `instructionIndex = resume_ip`. Otherwise falls through. In `dotnet run` builds, `RUNTO_YIELD` is omitted entirely — zero production cost. Labels that aren't runto targets carry no overhead in test builds either. + - **`runtoStack`** — a new stack on the VM, same family as `scope.deferredJumps`. Just addresses-on-a-stack. + - **`entryPointAddress` constructor parameter.** Each test's compiled body is a contiguous block in the program blob; its start address lives in the manifest. Running test `foo`: `new VirtualMachine(program, manifest.tests[foo].entryPointAddress)`. Default of `4` is preserved for `dotnet run`. +- **Unified address space.** Test and program bytecode coexist in one `byte[] program` blob. The VM doesn't distinguish contexts; it just executes addresses. `methodStack`, `scopeStack`, `heap` all work as-is — test functions call test functions, program functions call program functions, and the cross-boundary case is handled exclusively by `RUNTO` / `RUNTO_YIELD`. Read/write of program variables by the test goes directly to shared memory; a variable "exists" iff program execution has declared it, type-checked statically by `scope_at(L)` and enforced trivially by memory layout. +- **DAP/debugger flow is preserved.** Because tests run in a single VM with a single `instructionIndex`, attaching the existing debugger works as it does today. Breakpoints in test code and program code both fire normally — they're addresses in the same unified bytecode blob, and the debugger just observes the IP. No multi-target debug session, no new protocol work. The shared-VM model collapses what looked like a multi-process problem into a no-op. +- **Mock dispatch table.** The one place test runs mutate VM-adjacent state, and it's bounded — a single table the command dispatcher consults (per Section 3). Lives at the host boundary, not in the VM core. +- **Manifest emission.** Each `TestNode` emits one entry into a generated `__test_manifest`, including its bytecode `entryPointAddress`, name, optional `from` parent, and source location. Codegen for the test body is mostly the same as a function body, with `runto` lowering to the `RUNTO` opcode described above. + +### 9. C# host state resets via `[FadeTestReset]` + +Per-test VM isolation handles Fade-side state (variables, heap, stacks). It does *not* handle C# host-side state. Real Fade command implementations frequently carry state — static texture caches, connection pools, allocated GPU resources, logging buffers, singleton subsystems. That state survives the VM's death and leaks into the next test. + +For tests against trivial programs (no stateful commands), a fresh VM per test is enough. For real projects, it isn't. + +**The `[FadeTestReset]` attribute.** Command authors mark a static method that clears their state. The test runner auto-invokes every method tagged with this attribute before each test runs. + +```csharp +public partial class FadeCommands { + public static int x = 0; + + [FadeCommand("get and up")] + public static int GetAndUp() { return x++; } + + [FadeCommand("reset get and up")] // optional — makes it Fade-callable too + [FadeTestReset] // auto-invoked before every test + public static void ResetGetAndUp() { x = 0; } +} +``` + +The attribute pattern matches what command authors already know — `[FadeCommand]` is a tag on a static method; `[FadeTestReset]` is another tag on a static method. No new interface, no `IFadeResettable` to inherit from, no `OnTestStart` / `OnTestEnd` lifecycle protocol. Just a method with an attribute. + +**Optional dual role.** Tagging the same method with both `[FadeCommand("reset X")]` and `[FadeTestReset]` makes it Fade-callable *and* auto-invoked. The test author can call it explicitly from a Fade test for fine-grained control; the system auto-invokes it for everyone else. Defaults are automatic; opt-out is one line. + +**Invocation rules.** +- All `[FadeTestReset]` methods are invoked before each top-level test execution, in registration order. +- For a `from`-chain (e.g., `test child from root`), resets fire once at the start of the chain, then `root`'s body runs, then `child`'s body runs. Resets do not re-fire between parent and child within one execution — the chain is one logical scenario. +- If a reset throws, the test fails fast with `"reset for X failed: ..."` rather than running with stale state. + +**Detection.** At command-registration time, walk each `[FadeCommand]`-bearing class for mutable static fields. If a class has any AND no method on it carries `[FadeTestReset]`, emit a build warning: + +> *Command class `FadeCommands` has mutable static fields but no `[FadeTestReset]` method — tests using these commands may interfere with each other.* + +Detection isn't perfect (a `static Dictionary` looks "mutable" but the relevant state lives in the handles, not the dictionary; only the author knows which it is), but "any non-readonly static field" catches the overwhelmingly common case. Strict projects can promote the warning to an error via a project setting. + +**Sequential.** Tests run one at a time. Resets fire deterministically before each test; only one test touches host state at a time. Parallel execution is out of scope. + +## Open questions + +What's resolved: + +- **Runtime / coroutine implementation.** Two new opcodes (`RUNTO`, `RUNTO_YIELD`) plus one `runtoStack` plus an `entryPointAddress` constructor parameter. No second IP, no breakpoint table, no second VM. See Section 8. +- **Debugger model.** Single VM means the existing DAP flow continues to work without modification. No multi-target debug session needed. +- **Pre-runto function calls.** Reuses the existing function-callsite analysis. The test's `visible` set is just another callsite snapshot — error surfaces at the same place a regular Fade program would error. +- **Cross-file label resolution.** Non-issue. Files concat to one stream at lex time; labels resolve in the unified stream. +- **Parameterized test syntax.** Resolved by tests-as-tokenize-flavored-regions composing with `#macro` for-loops. No special parameterized-test grammar. +- **`runto` across stack frames.** Stack-agnostic. The VM resumes execution as-is from wherever the program was paused; the call stack is honored; `RUNTO_YIELD` fires whenever the IP reaches the target regardless of stack depth. `max cycles` guards against runaway cases. +- **Runto targets.** Any label in the program — top-level or function-internal. Tests can step into a function and assert about its state. `scope_at(:L)` adapts: top-level labels see globals + main-body declarations; function-internal labels see the function's locals plus globals visible at callsites. Both reuse the existing scope checker's per-line scope answer. +- **Mock model (v1).** FIFO queue of behavior entries; `returns ` and `forbid`; frequency words `once`, `n times`, `always` (default `always`); multi-block `mock` declarations append; `clear mock` / `clear mocks` reset; exhausted queue falls through to the real C# implementation; all overloads share a queue. See Section 3. +- **C# host state reset.** `[FadeTestReset]` attribute on a static method, auto-invoked before each test; optionally dual-tagged with `[FadeCommand]` to be Fade-callable too. Build-time warning when a `[FadeCommand]`-bearing class has mutable static fields with no `[FadeTestReset]` method. Sequential execution; parallelism out of scope. See Section 9. + +Still open: + +1. **Mock extensions beyond v1.** Section 3 lands the v1 surface: FIFO queue, `returns`/`forbid`, frequency words (`once`, `n times`, `always`), `clear mock`/`clear mocks`, exhausted-queue fall-through, all-overloads share a queue. Deferred to a later phase: argument matching (`mock screen width when w > 100`), per-overload disambiguation (`mock screen width(int)`), `body` blocks (Fade code computing the return), `passthrough` keyword (explicit fall-through entry), spy-style call recording for assertions. None of these block v1; they layer on cleanly when needed. +2. **Test discovery for IDE Test Explorer.** Manifest needs source locations so VS / Rider gutter buttons land correctly. For parameterized tests generated via `#macro` loops, locations should point at the originating `test` line, not the expanded output. Probably reuses Fade's existing macro source-mapping. +3. **Failure source-mapping for macro-generated tests.** When `assert` fails inside a macro-generated test, the failure must point at the originating `test` line (and ideally the iteration values via `[name]` substitution preserved in the message), not at the unfathomable expanded location. +4. **`from`-chain implementation.** Section 2 documents replay vs snapshot as observable-equivalent strategies. Replay is the chosen default. Snapshot is deferred until a perf measurement says it matters — but the contract should already be defined in terms of observable behavior so either implementation remains valid. +5. **`endrunto` block clauses beyond `max cycles`.** The block form is extensible by design, but no other clauses are spec'd. Candidates as needs emerge: `unless `, `while `, `record events to `, `forbid command X`. Keep deferred until a real test feels the gap. +6. **Runto failure error messages.** When `max cycles` is exceeded, the failure message should capture the program's call stack at the time of failure — *"`runto :sync` exhausted budget; program was inside `update_position()` at the time"* — so the user can see *why* the runto didn't complete. Small piece of runtime plumbing. + +Pending decisions, lower priority: + +- Whether the `assert` macro should support custom assertion words (`assert close`, `assert in_range`, `assert call_order`) at v1 or evolve them later. From 280dea8dc00953d535df8644daaa02027ba8c64b Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Tue, 28 Apr 2026 22:29:25 -0400 Subject: [PATCH 02/30] stage2 --- FadeBasic/FadeBasic/Ast/ProgramNode.cs | 6 + FadeBasic/FadeBasic/Ast/TestNode.cs | 36 +++ FadeBasic/FadeBasic/Errors.cs | 7 + FadeBasic/FadeBasic/Lexer.cs | 19 +- FadeBasic/FadeBasic/Parser.cs | 155 +++++++++++- FadeBasic/FadeBasic/Virtual/OpCodes.cs | 21 +- FadeBasic/FadeBasic/Virtual/VirtualMachine.cs | 64 ++++- FadeBasic/Tests/DebuggerTests.cs | 2 +- FadeBasic/Tests/DeferTests.cs | 4 +- FadeBasic/Tests/FunctionParserTests.cs | 12 +- FadeBasic/Tests/FunctionVmTests.cs | 130 +++++----- FadeBasic/Tests/JsonTests.cs | 8 +- FadeBasic/Tests/Jsonable2Tests.cs | 2 +- FadeBasic/Tests/ParserTests.cs | 8 +- FadeBasic/Tests/ParserTests_Erros.cs | 16 +- FadeBasic/Tests/ParserTests_Macro_Taint.cs | 16 +- FadeBasic/Tests/RuntoOpCodeTests.cs | 236 ++++++++++++++++++ FadeBasic/Tests/SourceMapTests.cs | 2 +- FadeBasic/Tests/TestBlockParserTests.cs | 222 ++++++++++++++++ FadeBasic/Tests/TestCommmands.cs | 10 +- FadeBasic/Tests/TokenMacroTests.cs | 8 +- FadeBasic/Tests/TokenVm.cs | 12 +- FadeBasic/Tests/TokenVm_GC.cs | 18 +- 23 files changed, 881 insertions(+), 133 deletions(-) create mode 100644 FadeBasic/FadeBasic/Ast/TestNode.cs create mode 100644 FadeBasic/Tests/RuntoOpCodeTests.cs create mode 100644 FadeBasic/Tests/TestBlockParserTests.cs diff --git a/FadeBasic/FadeBasic/Ast/ProgramNode.cs b/FadeBasic/FadeBasic/Ast/ProgramNode.cs index 76573d0..d691062 100644 --- a/FadeBasic/FadeBasic/Ast/ProgramNode.cs +++ b/FadeBasic/FadeBasic/Ast/ProgramNode.cs @@ -15,6 +15,7 @@ public ProgramNode(Token start) : base(start) public List typeDefinitions = new List(); public List functions = new List(); public List labels = new List(); + public List tests = new List(); protected override string GetString() { List allStatements = new List(); @@ -22,6 +23,7 @@ protected override string GetString() allStatements.AddRange(typeDefinitions); allStatements.AddRange(statements); allStatements.AddRange(functions); + allStatements.AddRange(tests); return $"{string.Join(",", allStatements.Select(x => x.ToString()))}"; } @@ -39,6 +41,10 @@ public override IEnumerable IterateChildNodes() { yield return type; } + foreach (var test in tests) + { + yield return test; + } } } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Ast/TestNode.cs b/FadeBasic/FadeBasic/Ast/TestNode.cs new file mode 100644 index 0000000..9050e04 --- /dev/null +++ b/FadeBasic/FadeBasic/Ast/TestNode.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; + +namespace FadeBasic.Ast +{ + public class TestNode : AstNode, IStatementNode, IHasTriviaNode + { + public string name; + public Token nameToken; + public bool isAbstract; + public string fromParent; + public Token fromParentToken; + public List statements = new List(); + public List labels = new List(); + public List functions = new List(); + + public TestNode() + { + } + + protected override string GetString() + { + var prefix = isAbstract ? "abstract test" : "test"; + var fromClause = fromParent != null ? $" from {fromParent}" : ""; + return $"{prefix} {name}{fromClause} ({string.Join(",", statements.Select(x => x.ToString()))})"; + } + + public override IEnumerable IterateChildNodes() + { + foreach (var statement in statements) yield return statement; + foreach (var function in functions) yield return function; + } + + public string Trivia { get; set; } + } +} diff --git a/FadeBasic/FadeBasic/Errors.cs b/FadeBasic/FadeBasic/Errors.cs index 4936959..0782ca6 100644 --- a/FadeBasic/FadeBasic/Errors.cs +++ b/FadeBasic/FadeBasic/Errors.cs @@ -210,6 +210,13 @@ public static class ErrorCodes public static readonly ErrorCode DeferStatementMissingEndDefer = "[0162] Defer statement is missing a closing EndDefer clause"; public static readonly ErrorCode CommandNotInRuntime = "[0163] This command is only available inside a macro"; public static readonly ErrorCode CommandNotInMacro = "[0163] This command is only available outside of a macro"; + public static readonly ErrorCode TestMissingName = "[0164] Test missing name"; + public static readonly ErrorCode TestMissingEndTest = "[0165] Test missing EndTest clause"; + public static readonly ErrorCode TestDefinedInsideFunction = "[0166] Tests cannot be defined inside functions"; + public static readonly ErrorCode TestDefinedInsideTest = "[0167] Tests cannot be defined inside other tests"; + public static readonly ErrorCode AbstractRequiresTest = "[0168] Abstract keyword must be followed by test"; + public static readonly ErrorCode TestFromMissingParent = "[0169] Test from clause must specify a parent test name"; + public static readonly ErrorCode TestNameAlreadyDeclared = "[0170] Test with this name is already declared"; // 200 series represents post-parse issues public static readonly ErrorCode InvalidReference = "[0200] Invalid reference"; diff --git a/FadeBasic/FadeBasic/Lexer.cs b/FadeBasic/FadeBasic/Lexer.cs index cc701d5..3354f88 100644 --- a/FadeBasic/FadeBasic/Lexer.cs +++ b/FadeBasic/FadeBasic/Lexer.cs @@ -65,7 +65,12 @@ public enum LexemType KeywordDefer, KeywordEndDefer, - + + KeywordTest, + KeywordEndTest, + KeywordAbstract, + KeywordFrom, + KeywordAs, KeywordTypeInteger, KeywordTypeByte, @@ -231,6 +236,11 @@ public class Lexer new Lexem(LexemType.KeywordEndDefer, new Regex("^enddefer")), new Lexem(LexemType.KeywordDefer, new Regex("^defer")), + + new Lexem(LexemType.KeywordEndTest, new Regex("^endtest\\b")), + new Lexem(LexemType.KeywordTest, new Regex("^test\\b")), + new Lexem(LexemType.KeywordAbstract, new Regex("^abstract\\b")), + new Lexem(LexemType.KeywordFrom, new Regex("^from\\b")), new Lexem(LexemType.KeywordGoto, new Regex("^goto")), new Lexem(LexemType.KeywordGoSub, new Regex("^gosub")), @@ -1711,6 +1721,13 @@ public class TokenStream } : _tokens[Index]; + public Token Peek2 => Index + 1 >= _maxIndex + ? new Token + { + lexem = new Lexem(LexemType.EOF, null) + } + : _tokens[Index + 1]; + public List PeekUntilEoS => PeekUntil(LexemType.EndStatement); public List PeekUntil(LexemType type) { diff --git a/FadeBasic/FadeBasic/Parser.cs b/FadeBasic/FadeBasic/Parser.cs index 9e5e91d..689c49b 100644 --- a/FadeBasic/FadeBasic/Parser.cs +++ b/FadeBasic/FadeBasic/Parser.cs @@ -955,6 +955,9 @@ public ProgramNode ParseProgram(ParseOptions options = null) case TypeDefinitionStatement typeStatement: program.typeDefinitions.Add(typeStatement); break; + case TestNode testNode: + program.tests.Add(testNode); + break; case LabelDeclarationNode labelStatement: program.labels.Add(new LabelDefinition { @@ -1617,6 +1620,10 @@ IStatementNode Inner() return ParseFunction(token); case LexemType.KeywordExitFunction: return ParseExitFunction(token); + case LexemType.KeywordTest: + return ParseTest(token, isAbstract: false); + case LexemType.KeywordAbstract: + return ParseAbstractTest(token); case LexemType.KeywordEnd: return new EndProgramStatement(token); case LexemType.KeywordExit: @@ -1637,10 +1644,10 @@ IStatementNode Inner() if (token.type == LexemType.VariableReal && token.Length == 1) { - // this is the special tokenize block case. + // this is the special tokenize block case. return ParseTokenization(token); } - + var reference = ParseVariableReference(token); var secondToken = _stream.Peek; @@ -2386,6 +2393,150 @@ private FunctionStatement ParseFunction(Token functionToken) }; } + private static bool IsNameToken(Token token) + { + return token.type == LexemType.VariableGeneral + || token.type == LexemType.VariableReal + || token.type == LexemType.VariableString; + } + + private IStatementNode ParseAbstractTest(Token abstractToken) + { + if (_stream.Peek.type != LexemType.KeywordTest) + { + var node = new TestNode + { + name = "_", + startToken = abstractToken, + endToken = abstractToken, + isAbstract = true + }; + node.Errors.Add(new ParseError(abstractToken, ErrorCodes.AbstractRequiresTest)); + return node; + } + var testToken = _stream.Advance(); // consume `test` + return ParseTest(testToken, isAbstract: true, abstractToken: abstractToken); + } + + private TestNode ParseTest(Token testToken, bool isAbstract, Token abstractToken = default) + { + var errors = new List(); + var startToken = isAbstract ? abstractToken : testToken; + + // parse the name + var nameToken = _stream.Peek; + if (!IsNameToken(nameToken)) + { + nameToken = _stream.CreatePatchToken(LexemType.VariableGeneral, "_")[0]; + errors.Add(new ParseError(testToken, ErrorCodes.TestMissingName)); + } + else + { + _stream.Advance(); + } + + // optional from clause + string fromParent = null; + Token fromParentToken = default; + if (_stream.Peek.type == LexemType.KeywordFrom) + { + _stream.Advance(); // consume `from` + var parentToken = _stream.Peek; + if (!IsNameToken(parentToken)) + { + errors.Add(new ParseError(parentToken, ErrorCodes.TestFromMissingParent)); + } + else + { + _stream.Advance(); + fromParent = parentToken.caseInsensitiveRaw; + fromParentToken = parentToken; + } + } + + // now parse the body until endtest + var statements = new List(); + var labels = new List(); + var functions = new List(); + var looking = true; + while (looking) + { + var nextToken = _stream.Peek; + switch (nextToken.type) + { + case LexemType.EOF: + errors.Add(new ParseError(testToken, ErrorCodes.TestMissingEndTest)); + looking = false; + break; + + case LexemType.EndStatement: + _stream.Advance(); + break; + + case LexemType.KeywordEndTest: + _stream.Advance(); + looking = false; + break; + + case LexemType.KeywordTest: + case LexemType.KeywordAbstract: + { + var illegalErr = new ParseError(nextToken, ErrorCodes.TestDefinedInsideTest); + errors.Add(illegalErr); + // recover: skip until matching endtest + var depth = 1; + _stream.Advance(); + while (depth > 0 && _stream.Peek.type != LexemType.EOF) + { + var t = _stream.Advance(); + if (t.type == LexemType.KeywordTest || t.type == LexemType.KeywordAbstract) depth++; + else if (t.type == LexemType.KeywordEndTest) depth--; + } + break; + } + + case LexemType.KeywordFunction: + { + var fnToken = _stream.Advance(); + var fn = ParseFunction(fnToken); + functions.Add(fn); + statements.Add(fn); + break; + } + + default: + { + var member = ParseStatement(statements); + if (member is LabelDeclarationNode lbl) + { + labels.Add(lbl); + statements.Add(member); + } + else + { + statements.Add(member); + } + break; + } + } + } + + return new TestNode + { + Errors = errors, + name = nameToken.caseInsensitiveRaw, + nameToken = nameToken, + isAbstract = isAbstract, + fromParent = fromParent, + fromParentToken = fromParentToken, + statements = statements, + labels = labels, + functions = functions, + startToken = startToken, + endToken = _stream.Current + }; + } + private SwitchStatement ParseSwitchStatement(Token switchToken) { var expression = ParseWikiExpression(); diff --git a/FadeBasic/FadeBasic/Virtual/OpCodes.cs b/FadeBasic/FadeBasic/Virtual/OpCodes.cs index 1c5ebcc..5e48ef1 100644 --- a/FadeBasic/FadeBasic/Virtual/OpCodes.cs +++ b/FadeBasic/FadeBasic/Virtual/OpCodes.cs @@ -383,8 +383,25 @@ public static class OpCodes /// /// pull a value off the defer stack, and push it into the main stack. - /// If the defer stack is empty, a 0 is used. + /// If the defer stack is empty, a 0 is used. /// - public const byte POP_DEFER = 63; + public const byte POP_DEFER = 63; + + /// + /// Begin a runto: pops a target address off the stack, pushes a runto frame + /// (target_addr, test_resume_ip), and sets instructionIndex to the saved + /// programResumeIP. Used at the start of every `runto :L` statement in test code. + /// + public const byte RUNTO = 64; + + /// + /// Yield-back marker: the compiler emits this immediately after every label + /// that is referenced by a `runto` somewhere in the test corpus. When executed, + /// checks whether the top of runtoStack targets the current address. If yes, + /// stores the program IP in programResumeIP and jumps to test_resume_ip. + /// Otherwise falls through. In `dotnet run` builds (no tests), this opcode is + /// not emitted. + /// + public const byte RUNTO_YIELD = 65; } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs index 900e63c..db625a3 100644 --- a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs +++ b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs @@ -129,10 +129,28 @@ public class VirtualMachine public List tokenReplacements; + /// + /// Stack of pending runto frames. Each frame records the target program + /// address the test asked to advance to, and the test-side instruction + /// the VM should resume at when that target is hit. + /// + public FastStack runtoStack = new FastStack(4); + + /// + /// Where the program should resume from on the next `runto`. On the very + /// first `runto`, this points at the program's main entry (instructionIndex + /// after interned-data setup, i.e. 4). On subsequent runtos, this is the + /// saved IP from the most recent RUNTO_YIELD. + /// + public int programResumeIP; + public VirtualMachine(IEnumerable program) : this(program.ToArray()) { } - public VirtualMachine(byte[] program) + public VirtualMachine(byte[] program) : this(program, 4) + { + } + public VirtualMachine(byte[] program, int entryPointAddress) { this.program = program; shouldThrowRuntimeException = true; @@ -140,11 +158,12 @@ public VirtualMachine(byte[] program) scopeStack = new FastStack(16); methodStack = new FastStack(16); heap = new VmHeap(128); - - instructionIndex = 4; + + instructionIndex = entryPointAddress; + programResumeIP = 4; internedDataInstructionIndex = BitConverter.ToInt32(program, 0); - + ReadInternedData(); globalScope = scope = new VirtualScope(internedData.maxRegisterAddress); scopeStack.Push(globalScope); @@ -859,6 +878,37 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp break; case OpCodes.BREAKPOINT: break; + case OpCodes.RUNTO: + // Pop the target address off the data stack. + VmUtil.ReadAsInt(ref stack, out var runtoTarget); + // The test-resume IP is the very next instruction after this RUNTO. + // (instructionIndex has already been incremented past the RUNTO opcode.) + runtoStack.Push(new RuntoFrame + { + targetAddr = runtoTarget, + testResumeIp = instructionIndex + }); + // Switch execution to wherever the program is currently paused. + instructionIndex = programResumeIP; + break; + case OpCodes.RUNTO_YIELD: + // The compiler emits RUNTO_YIELD after every label that's a runto target. + // We're exactly one instruction past the label here. If the runtoStack top + // matches our address, yield back to the test. Otherwise fall through. + // + // The "match" is: the target address that the test asked for == the address + // immediately AFTER the RUNTO_YIELD opcode (i.e., the body of the program + // resuming at the next real instruction). The compiler records the target + // as that post-yield address. + if (runtoStack.Count > 0 && runtoStack.buffer[runtoStack.ptr - 1].targetAddr == instructionIndex) + { + var frame = runtoStack.Pop(); + // Save where the program is now so the next runto can resume from here. + programResumeIP = instructionIndex; + instructionIndex = frame.testResumeIp; + } + // else fall through; this label wasn't the targeted one. + break; default: throw new Exception("Unknown op code: " + ins); } @@ -883,6 +933,12 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp public int test = 0; + public struct RuntoFrame + { + public int targetAddr; + public int testResumeIp; + } + void TriggerRuntimeError(VirtualRuntimeError error) { this.error = error; diff --git a/FadeBasic/Tests/DebuggerTests.cs b/FadeBasic/Tests/DebuggerTests.cs index cfc9075..455f83f 100644 --- a/FadeBasic/Tests/DebuggerTests.cs +++ b/FadeBasic/Tests/DebuggerTests.cs @@ -527,7 +527,7 @@ wait ms 500 } [Test] - public async Task DebugServerTest() + public async Task DebugServerDemo() { var port = LaunchUtil.FreeTcpPort(); var src = @$" diff --git a/FadeBasic/Tests/DeferTests.cs b/FadeBasic/Tests/DeferTests.cs index 602d702..ff9224d 100644 --- a/FadeBasic/Tests/DeferTests.cs +++ b/FadeBasic/Tests/DeferTests.cs @@ -51,7 +51,7 @@ public void GosubInFunction() var src = @" ghost: a = 1 -function test() +function demo() static print ""a"" @@ -64,7 +64,7 @@ static print ""b"" return endfunction - test() + demo() "; Setup(src, out var compiler, out var prog); diff --git a/FadeBasic/Tests/FunctionParserTests.cs b/FadeBasic/Tests/FunctionParserTests.cs index 525f519..f5fa74f 100644 --- a/FadeBasic/Tests/FunctionParserTests.cs +++ b/FadeBasic/Tests/FunctionParserTests.cs @@ -8,7 +8,7 @@ public partial class ParserTests public void Invoke_Simple() { var input = @" -x = Test() +x = Demo() "; var parser = MakeParser(input); var prog = parser.ParseProgram(); @@ -17,7 +17,7 @@ public void Invoke_Simple() var code = prog.ToString(); Console.WriteLine(code); Assert.That(code, Is.EqualTo(@"( -(= (ref x),(ref test[])) +(= (ref x),(ref demo[])) )".ReplaceLineEndings(""))); } @@ -26,7 +26,7 @@ public void Invoke_Simple() public void Invoke_WithArg() { var input = @" -x = Test(1) +x = Demo(1) "; var parser = MakeParser(input); var prog = parser.ParseProgram(); @@ -35,7 +35,7 @@ public void Invoke_WithArg() var code = prog.ToString(); Console.WriteLine(code); Assert.That(code, Is.EqualTo(@"( -(= (ref x),(ref test[(1)])) +(= (ref x),(ref demo[(1)])) )".ReplaceLineEndings(""))); } @@ -44,7 +44,7 @@ public void Invoke_WithArg() public void Invoke_Statement() { var input = @" -Test(1) +Demo(1) "; var parser = MakeParser(input); var prog = parser.ParseProgram(); @@ -53,7 +53,7 @@ public void Invoke_Statement() var code = prog.ToString(); Console.WriteLine(code); Assert.That(code, Is.EqualTo(@"( -(expr (ref test[(1)])) +(expr (ref demo[(1)])) )".ReplaceLineEndings(""))); } diff --git a/FadeBasic/Tests/FunctionVmTests.cs b/FadeBasic/Tests/FunctionVmTests.cs index ebc41e1..29c9ef6 100644 --- a/FadeBasic/Tests/FunctionVmTests.cs +++ b/FadeBasic/Tests/FunctionVmTests.cs @@ -11,10 +11,10 @@ public partial class TokenVm public void Function_NoReturn() { var src = @" -x = Test() +x = Demo() y = x END -Function Test() +Function Demo() EndFunction @@ -35,10 +35,10 @@ public void Function_GotoHell() // TODO: how do we stop people from jumping INTO a function? // probably by adding a restrictino that you cannot goto between function scopes ? var src = @" -x = Test() +x = Demo() goto truck ` this line causes execution to jump into a function, which is bad bad bad ` the important part is that no END expression exists, but the compiler should auto-end -Function Test() +Function Demo() a = 1 + 2 truck: EndFunction a @@ -53,9 +53,9 @@ Function Death() public void Function_AutoEnd() { var src = @" -x = Test() +x = Demo() ` the important part is that no END expression exists, but the compiler should auto-end -Function Test() +Function Demo() a = 1 + 2 EndFunction a "; @@ -72,10 +72,10 @@ EndFunction a public void Function_Simple() { var src = @" -x = Test() +x = Demo() END -Function Test() +Function Demo() a = 1 + 2 EndFunction a "; @@ -87,8 +87,8 @@ EndFunction a Assert.That(vm.dataRegisters[0], Is.EqualTo(3)); Assert.That(vm.typeRegisters[0], Is.EqualTo(TypeCodes.INT)); - Assert.That(vm.internedData.functions["test"].typeId, Is.EqualTo(0)); - Assert.That(vm.internedData.functions["test"].typeCode, Is.EqualTo(TypeCodes.INT)); + Assert.That(vm.internedData.functions["demo"].typeId, Is.EqualTo(0)); + Assert.That(vm.internedData.functions["demo"].typeCode, Is.EqualTo(TypeCodes.INT)); } @@ -98,10 +98,10 @@ public void Function_Global() var src = @" global y as integer y = 1 -x = Test() +x = Demo() END -Function Test() +Function Demo() a = y EndFunction a "; @@ -126,10 +126,10 @@ TYPE egg GLOBAL albert AS egg ` declare as global, so it can be used in function albert.x = 42 -z = Test() ` put the result onto a variable so we can validate it +z = Demo() ` put the result onto a variable so we can validate it END -Function Test() +Function Demo() EndFunction albert.x ` just access the global value "; Setup(src, out _, out var prog); @@ -153,10 +153,10 @@ TYPE egg GLOBAL albert AS egg ` declare as global, so it can be used in function albert.x = 42 z = 1 -z = Test() ` put the result onto a variable so we can validate it +z = Demo() ` put the result onto a variable so we can validate it END -Function Test() +Function Demo() EndFunction albert.x ` just access the global value "; Setup(src, out _, out var prog); @@ -174,10 +174,10 @@ public void Function_Local() var src = @" local y as integer y = 1 -x = Test() +x = Demo() END -Function Test() +Function Demo() a = y EndFunction a "; @@ -196,10 +196,10 @@ public void Function_ExplicitTypedArg() { var src = @" x as byte -x = Test(2) +x = Demo(2) END -Function Test(a as byte) +Function Demo(a as byte) EndFunction a * 2 "; Setup(src, out _, out var prog); @@ -216,10 +216,10 @@ EndFunction a * 2 public void Function_String() { var src = @" -x$ = Test(""world"") +x$ = Demo(""world"") END -Function Test(a as string) +Function Demo(a as string) EndFunction a + ""hello"" "; Setup(src, out _, out var prog); @@ -240,9 +240,9 @@ public void Function_Return_String() { var src = @" x$ = """" -x$ = Test() +x$ = Demo() END -Function Test() +Function Demo() a$ = ""hello"" EndFunction a$ "; @@ -258,8 +258,8 @@ EndFunction a$ Assert.That(vm.typeRegisters[0], Is.EqualTo(TypeCodes.STRING)); - Assert.That(vm.internedData.functions["test"].typeId, Is.EqualTo(0)); - Assert.That(vm.internedData.functions["test"].typeCode, Is.EqualTo(TypeCodes.STRING)); + Assert.That(vm.internedData.functions["demo"].typeId, Is.EqualTo(0)); + Assert.That(vm.internedData.functions["demo"].typeCode, Is.EqualTo(TypeCodes.STRING)); } @@ -274,11 +274,11 @@ TYPE egg e1 as egg e1.x = 1 -e2 = Test(e1) +e2 = Demo(e1) e2.x = e2.x * 2 END -Function Test(e as egg) +Function Demo(e as egg) e.x = e.x + 1 e.x = e.x + 1 EndFunction e @@ -339,10 +339,10 @@ dim cards(3) as integer cards(1) = 200 cards(2) = 300 -x = Test() +x = Demo() END -Function Test() +Function Demo() g = cards(0) + cards(1) + cards(2) EndFunction g "; @@ -374,10 +374,10 @@ dim cards(3) as cardType cards(2).suit = 5 cards(2).value = 8 -x = Test(2) +x = Demo(2) END -Function Test(index) +Function Demo(index) ct as cardType ct = cards(index) `IF ct.suit = 5 then returnValue$ = ""of pie"" @@ -415,10 +415,10 @@ dim cards(3) as cardType cards(2).suit = 5 cards(2).value = 8 -x$ = Test(2) +x$ = Demo(2) print x$ END -Function Test(index) +Function Demo(index) ct as cardType ct = cards(index) IF ct.suit = 5 then returnValue$ = ""of pie"" @@ -461,10 +461,10 @@ e as egg e.x = 32 e.y = 66 -derp = Test(e) +derp = Demo(e) END -Function Test(a as egg) +Function Demo(a as egg) EndFunction a.x + a.y "; Setup(src, out _, out var prog); @@ -500,10 +500,10 @@ TYPE egg e as egg e.x = 32 e.y = 66 -Test(e) +Demo(e) END -Function Test(a as egg) +Function Demo(a as egg) a.x = a.x * 2 a.y = a.y - 10 EndFunction @@ -532,10 +532,10 @@ Function Test(a as egg) public void Function_ArgOrder_1() { var src = @" -x = Test(1, 2) +x = Demo(1, 2) END -Function Test(a, b) +Function Demo(a, b) EndFunction a "; Setup(src, out _, out var prog); @@ -552,10 +552,10 @@ EndFunction a public void Function_ArgOrder_2() { var src = @" -x = Test(1, 2) +x = Demo(1, 2) END -Function Test(a, b) +Function Demo(a, b) EndFunction b "; Setup(src, out var compiler, out var prog); @@ -572,10 +572,10 @@ EndFunction b public void Function_Args() { var src = @" -x = Test(1, 2) +x = Demo(1, 2) END -Function Test(a, b) +Function Demo(a, b) EndFunction a + b "; Setup(src, out _, out var prog); @@ -589,11 +589,11 @@ EndFunction a + b // parameters are in reverse order of index - Assert.That(vm.internedData.functions["test"].parameters[1].index, Is.EqualTo(0)); - Assert.That(vm.internedData.functions["test"].parameters[1].name, Is.EqualTo("a")); + Assert.That(vm.internedData.functions["demo"].parameters[1].index, Is.EqualTo(0)); + Assert.That(vm.internedData.functions["demo"].parameters[1].name, Is.EqualTo("a")); - Assert.That(vm.internedData.functions["test"].parameters[0].index, Is.EqualTo(1)); - Assert.That(vm.internedData.functions["test"].parameters[0].name, Is.EqualTo("b")); + Assert.That(vm.internedData.functions["demo"].parameters[0].index, Is.EqualTo(1)); + Assert.That(vm.internedData.functions["demo"].parameters[0].name, Is.EqualTo("b")); } @@ -601,10 +601,10 @@ EndFunction a + b public void Function_Args_Cast() { var src = @" -x = Test(1.2) +x = Demo(1.2) END -Function Test(a) +Function Demo(a) EndFunction a + 1 "; Setup(src, out _, out var prog); @@ -622,10 +622,10 @@ EndFunction a + 1 public void Function_Args_TypeCast() { var src = @" -x = Test(1.2) +x = Demo(1.2) END -Function Test(a#) +Function Demo(a#) EndFunction a# + 1 "; Setup(src, out _, out var prog); @@ -643,10 +643,10 @@ EndFunction a# + 1 public void Function_Args_TypeCast_IntToFloat() { var src = @" -x# = Test(1) +x# = Demo(1) END -Function Test(a#) +Function Demo(a#) EndFunction a# + 1 "; Setup(src, out _, out var prog); @@ -667,10 +667,10 @@ EndFunction a# + 1 public void Function_Args_TypeCast_OrderFlip() { var src = @" -x = Test(5.2) +x = Demo(5.2) END -Function Test(a#) +Function Demo(a#) EndFunction 1 + a# "; Setup(src, out _, out var prog); @@ -688,10 +688,10 @@ EndFunction 1 + a# public void Function_Args_TypeCast_NoCast() { var src = @" -x# = Test(1.2) +x# = Demo(1.2) END -Function Test(a#) +Function Demo(a#) EndFunction a# + 1 "; Setup(src, out _, out var prog); @@ -712,10 +712,10 @@ public void Function_Scoping() { var src = @" y = 1 -Test() +Demo() END -Function Test() +Function Demo() y = 2 EndFunction "; @@ -733,12 +733,12 @@ Function Test() public void Function_Recursion() { var src = @" -x = Test(1) +x = Demo(1) END -Function Test(a) +Function Demo(a) IF a < 10 - a = Test(a + 1) + a = Demo(a + 1) ENDIF EndFunction a "; @@ -758,9 +758,9 @@ EndFunction a public void Function_UnusedReturnValue() { var src = @" -Test(1) +Demo(1) -Function Test(a) +Function Demo(a) ` the value a will be put onto the stack due to the return, but nothing takes it off? EndFunction a "; diff --git a/FadeBasic/Tests/JsonTests.cs b/FadeBasic/Tests/JsonTests.cs index 3d8be76..103baf9 100644 --- a/FadeBasic/Tests/JsonTests.cs +++ b/FadeBasic/Tests/JsonTests.cs @@ -82,7 +82,7 @@ public void ProcessJson(IJsonOperation op) } [Test] - public void DebugScopeTest() + public void DebugScopeDemo() { var msg = new ScopesMessage { @@ -112,7 +112,7 @@ public void DebugScopeTest() } [Test] - public void StringIntTest() + public void StringIntDemo() { var x = new StringInt { @@ -142,7 +142,7 @@ public void DoubleDictTest_Empty() } [Test] - public void DictStringIntTest() + public void DictStringIntDemo() { var x = new DictStringInt { @@ -179,7 +179,7 @@ public void Dict() } [Test] - public void ByteArray_Test() + public void ByteArray_Demo() { var x = new ByteArray { diff --git a/FadeBasic/Tests/Jsonable2Tests.cs b/FadeBasic/Tests/Jsonable2Tests.cs index 018de03..34d51ea 100644 --- a/FadeBasic/Tests/Jsonable2Tests.cs +++ b/FadeBasic/Tests/Jsonable2Tests.cs @@ -5,7 +5,7 @@ namespace Tests; public class Jsonable2Tests { [Test] - public void Test() + public void Demo() { var json = "{\"value\":\" \\\\\"}"; var data = Jsonable2.Parse(json); diff --git a/FadeBasic/Tests/ParserTests.cs b/FadeBasic/Tests/ParserTests.cs index 2e68a8c..d7b8cc0 100644 --- a/FadeBasic/Tests/ParserTests.cs +++ b/FadeBasic/Tests/ParserTests.cs @@ -383,14 +383,14 @@ public void MultiStatement() [Test] public void CallHostStatement() { - var input = @"callTest"; + var input = @"callDemo"; var tokenStream = new TokenStream(_lexer.Tokenize(input, TestCommands.CommandsForTesting)); var parser = new Parser(tokenStream, TestCommands.CommandsForTesting); var prog = parser.ParseProgram(); Assert.That(prog.statements.Count, Is.EqualTo(1)); var code = prog.ToString(); - Assert.That(code, Is.EqualTo("((call callTest))")); + Assert.That(code, Is.EqualTo("((call callDemo))")); } @@ -713,7 +713,7 @@ public void DeclareFromSymbol_Variable_Lhs() [Test] - public void AnasUnfunTest() + public void AnasUnfunDemo() { var input = @" x = 1 + 2 > 3 @@ -761,7 +761,7 @@ type egg } [Test] - public void Initializers_Test() + public void Initializers_Demo() { var input = @" type egg diff --git a/FadeBasic/Tests/ParserTests_Erros.cs b/FadeBasic/Tests/ParserTests_Erros.cs index 45ec2a8..98020c5 100644 --- a/FadeBasic/Tests/ParserTests_Erros.cs +++ b/FadeBasic/Tests/ParserTests_Erros.cs @@ -1555,8 +1555,8 @@ public void ParseError_Function_MissingName() public void ParseError_Function_CallBeforeDefined_Works() { var input = @" -x = test(1) -function test(a) +x = demo(1) +function demo(a) endfunction a "; var parser = MakeParser(input); @@ -1568,9 +1568,9 @@ endfunction a public void ParseError_Function_DefinedTwice() { var input = @" -function test() +function demo() endfunction -function test(a) +function demo(a) endfunction "; var parser = MakeParser(input); @@ -2126,8 +2126,8 @@ x as chicken TYPE chicken y ENDTYPE -test as egg -y = test.x +t1 as egg +y = t1.x z = y.y "; var parser = MakeParser(input); @@ -2732,7 +2732,7 @@ public void ParseError_Macro_ReturnWorks() { var input = @" #macro - n = macro return test() + n = macro return demo() #endmacro x = [n] "; @@ -3029,7 +3029,7 @@ public void ParseError_Macro_Subst_InvalidRef() public void ParseError_Macro_CommandAppearsOutsideOfMacro() { var input = @" -x = macro return test() +x = macro return demo() "; var parser = MakeParser(input); var prog = parser.ParseProgram(); diff --git a/FadeBasic/Tests/ParserTests_Macro_Taint.cs b/FadeBasic/Tests/ParserTests_Macro_Taint.cs index f13e7ae..aba470b 100644 --- a/FadeBasic/Tests/ParserTests_Macro_Taint.cs +++ b/FadeBasic/Tests/ParserTests_Macro_Taint.cs @@ -12,7 +12,7 @@ public void Haunted_ValidTokenization() { var input = @" #macro - x = macro return test() + x = macro return demo() # y = [x] #endmacro "; @@ -394,7 +394,7 @@ public void Haunted_Error_None() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x # a = 1 #endmacro @@ -407,7 +407,7 @@ public void Haunted_Error_None_CanTokenize() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x # a = [x] #endmacro @@ -420,7 +420,7 @@ public void Haunted_Error_InvalidAssignment() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y$ = ""a"" + str$(x) # [y$] = 1 #endmacro @@ -436,7 +436,7 @@ public void Haunted_Error_Concat() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x # a[x] = 1 #endmacro @@ -451,7 +451,7 @@ public void Haunted_Error_InsideNestedIf2() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x x2 = 3 if y @@ -471,7 +471,7 @@ public void Haunted_Error_InsideNestedIf() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x x2 = 3 if x2 @@ -491,7 +491,7 @@ public void Haunted_Error_InsideIf() { HauntedErrorCheck(@" #macro - x = macro return test() + x = macro return demo() y = x if y # a = 1 diff --git a/FadeBasic/Tests/RuntoOpCodeTests.cs b/FadeBasic/Tests/RuntoOpCodeTests.cs new file mode 100644 index 0000000..fca1387 --- /dev/null +++ b/FadeBasic/Tests/RuntoOpCodeTests.cs @@ -0,0 +1,236 @@ +using System; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class RuntoOpCodeTests +{ + /// + /// Produces a minimal valid VM program with a stub interned-data section, + /// then lets the caller specify the code bytes that live between the + /// 4-byte header and the interned-data section. + /// + private byte[] BuildProgram(byte[] code) + { + var src = "x = 0\n"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + var compiled = compiler.Program.ToArray(); + + var origInterned = BitConverter.ToInt32(compiled, 0); + var internedTail = compiled.AsSpan(origInterned, compiled.Length - origInterned).ToArray(); + + var newInternedStart = 4 + code.Length; + var program = new byte[4 + code.Length + internedTail.Length]; + var headerBytes = BitConverter.GetBytes(newInternedStart); + Array.Copy(headerBytes, 0, program, 0, 4); + Array.Copy(code, 0, program, 4, code.Length); + Array.Copy(internedTail, 0, program, newInternedStart, internedTail.Length); + return program; + } + + private static void EmitPushInt(List code, int value) + { + code.Add(OpCodes.PUSH); + code.Add(TypeCodes.INT); + code.AddRange(BitConverter.GetBytes(value)); + } + + /// + /// Halts execution by jumping past program.Length. Mirrors how the compiler + /// handles an `end` statement (CompileEnd in Compiler.cs). + /// + private static void EmitHalt(List code) + { + EmitPushInt(code, int.MaxValue); + code.Add(OpCodes.JUMP); + } + + [Test] + public void Runto_HitsTarget_YieldsBack() + { + // Layout (byte addresses, code starts at 4): + // 4: NOOP program "label" + // 5: RUNTO_YIELD target_addr if matched = 6 + // 6: NOOP post-yield program resume + // 7: EXPLODE sentinel: must not reach + // 8: PUSH 6 test entry: target = 6 + // 14: RUNTO test_resume_ip = 15 + // 15: NOOP test resumes here after yield + // 16: PUSH int.MaxValue + // 22: JUMP halt + var code = new List(); + code.Add(OpCodes.NOOP); // 4 + code.Add(OpCodes.RUNTO_YIELD); // 5 + code.Add(OpCodes.NOOP); // 6 + code.Add(OpCodes.EXPLODE); // 7 + EmitPushInt(code, 6); // 8-13 + code.Add(OpCodes.RUNTO); // 14 + code.Add(OpCodes.NOOP); // 15 + EmitHalt(code); // 16-22 + + var program = BuildProgram(code.ToArray()); + var vm = new VirtualMachine(program, entryPointAddress: 8); + + vm.Execute().MoveNext(); + + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + Assert.That(vm.programResumeIP, Is.EqualTo(6)); + } + + [Test] + public void Runto_PassThroughNonMatchingLabel_NoYield() + { + // First yield at addr 5 has post-ip = 6 (doesn't match target 8). + // Second yield at addr 7 has post-ip = 8 (matches target 8). Yields. + var code = new List(); + code.Add(OpCodes.NOOP); // 4 + code.Add(OpCodes.RUNTO_YIELD); // 5 + code.Add(OpCodes.NOOP); // 6 + code.Add(OpCodes.RUNTO_YIELD); // 7 + code.Add(OpCodes.NOOP); // 8 + code.Add(OpCodes.EXPLODE); // 9 + EmitPushInt(code, 8); // 10-15 + code.Add(OpCodes.RUNTO); // 16 + code.Add(OpCodes.NOOP); // 17 + EmitHalt(code); // 18-24 + + var program = BuildProgram(code.ToArray()); + var vm = new VirtualMachine(program, entryPointAddress: 10); + + vm.Execute().MoveNext(); + + Assert.That(vm.programResumeIP, Is.EqualTo(8)); + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } + + [Test] + public void Runto_EmptyRuntoStack_NoYield() + { + // RUNTO_YIELD with empty runtoStack falls through. No exception. + var code = new List(); + code.Add(OpCodes.RUNTO_YIELD); // 4 + code.Add(OpCodes.NOOP); // 5 + EmitHalt(code); // 6-12 + + var program = BuildProgram(code.ToArray()); + var vm = new VirtualMachine(program); + + vm.Execute().MoveNext(); + + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } + + [Test] + public void Runto_DefaultEntryPoint_PreservesRunBehavior() + { + // A normally-compiled program runs unchanged with the new constructor. + var src = "x = 42\n"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + + var vm = new VirtualMachine(compiler.Program); + vm.Execute().MoveNext(); + + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + Assert.That(vm.programResumeIP, Is.EqualTo(4)); + } + + [Test] + public void Runto_CustomEntryPoint_StartsThere() + { + // EXPLODE at addr 4 (default entry); custom entry at 5 just halts. + var code = new List(); + code.Add(OpCodes.EXPLODE); // 4 + EmitHalt(code); // 5-11 + + var program = BuildProgram(code.ToArray()); + var vm = new VirtualMachine(program, entryPointAddress: 5); + + Assert.DoesNotThrow(() => vm.Execute().MoveNext()); + } + + [Test] + public void Runto_PushesFrameOntoRuntoStack() + { + // Target an address that has no RUNTO_YIELD. The frame should remain on + // the stack after execution stops via halt at the end of the program area. + // We need the program area to halt cleanly (no RUNTO_YIELD), so we put a + // halt at addr 4 — but that'd terminate before the test runs. Instead, + // the program area has a halt that fires when reached after RUNTO. + // + // Layout: + // 4: PUSH int.MaxValue program halt + // 10: JUMP + // 11: PUSH 100 test entry: target = 100 (never matched) + // 17: RUNTO test_resume_ip = 18 + // 18: + var code = new List(); + EmitHalt(code); // 4-10 (program halt) + EmitPushInt(code, 100); // 11-16 + code.Add(OpCodes.RUNTO); // 17 + code.Add(OpCodes.NOOP); // 18 (unreachable but here for clarity) + EmitHalt(code); // 19-25 + + var program = BuildProgram(code.ToArray()); + var vm = new VirtualMachine(program, entryPointAddress: 11); + + vm.Execute().MoveNext(); + + Assert.That(vm.runtoStack.Count, Is.EqualTo(1), "frame should remain on stack (target never hit)"); + var frame = vm.runtoStack.buffer[vm.runtoStack.ptr - 1]; + Assert.That(frame.targetAddr, Is.EqualTo(100)); + Assert.That(frame.testResumeIp, Is.EqualTo(18)); + } + + [Test] + public void Runto_MultipleYields_ProgramResumesFromSavedIP() + { + // Two runtos against two yields. Second runto must resume from the IP + // saved during the first yield, not from __main entry. + // + // 4: NOOP label A + // 5: RUNTO_YIELD target if matched = 6 + // 6: NOOP label B + // 7: RUNTO_YIELD target if matched = 8 + // 8: NOOP program-end resume + // 9: EXPLODE sentinel + // 10: PUSH 6 test: first runto target + // 16: RUNTO test_resume_ip = 17 + // 17: PUSH 8 test: second runto target + // 23: RUNTO test_resume_ip = 24 + // 24: NOOP + // 25: PUSH int.MaxValue + // 31: JUMP + var code = new List(); + code.Add(OpCodes.NOOP); // 4 + code.Add(OpCodes.RUNTO_YIELD); // 5 + code.Add(OpCodes.NOOP); // 6 + code.Add(OpCodes.RUNTO_YIELD); // 7 + code.Add(OpCodes.NOOP); // 8 + code.Add(OpCodes.EXPLODE); // 9 + EmitPushInt(code, 6); // 10-15 + code.Add(OpCodes.RUNTO); // 16 + EmitPushInt(code, 8); // 17-22 + code.Add(OpCodes.RUNTO); // 23 + code.Add(OpCodes.NOOP); // 24 + EmitHalt(code); // 25-31 + + var program = BuildProgram(code.ToArray()); + var vm = new VirtualMachine(program, entryPointAddress: 10); + + vm.Execute().MoveNext(); + + Assert.That(vm.programResumeIP, Is.EqualTo(8)); + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } +} diff --git a/FadeBasic/Tests/SourceMapTests.cs b/FadeBasic/Tests/SourceMapTests.cs index 2db8d65..5938919 100644 --- a/FadeBasic/Tests/SourceMapTests.cs +++ b/FadeBasic/Tests/SourceMapTests.cs @@ -9,7 +9,7 @@ public class SourceMapTests { [Test] - public void Test() + public void Demo() { var file = @"print ""hello"" x = 3 diff --git a/FadeBasic/Tests/TestBlockParserTests.cs b/FadeBasic/Tests/TestBlockParserTests.cs new file mode 100644 index 0000000..e5fa5ad --- /dev/null +++ b/FadeBasic/Tests/TestBlockParserTests.cs @@ -0,0 +1,222 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestBlockParserTests +{ + private Lexer _lexer; + private CommandCollection _commands; + + [SetUp] + public void Setup() + { + _lexer = new Lexer(); + _commands = TestCommands.CommandsForTesting; + } + + private ProgramNode Parse(string src, out List errors) + { + var lex = _lexer.TokenizeWithErrors(src, _commands); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, _commands); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + errors = prog.GetAllErrors(); + return prog; + } + + private ProgramNode ParseClean(string src) + { + var prog = Parse(src, out var errs); + Assert.That(errs.Count, Is.EqualTo(0), + "expected no parse errors, got: " + string.Join("\n", errs.Select(e => e.Display))); + return prog; + } + + [Test] + public void Test_EmptyBlock_Parses() + { + var src = @" +test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].name, Is.EqualTo("foo")); + Assert.That(prog.tests[0].isAbstract, Is.False); + Assert.That(prog.tests[0].fromParent, Is.Null); + Assert.That(prog.tests[0].statements.Count, Is.EqualTo(0)); + } + + [Test] + public void Test_AbstractBlock_Parses() + { + var src = @" +abstract test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].isAbstract, Is.True); + Assert.That(prog.tests[0].name, Is.EqualTo("foo")); + } + + [Test] + public void Test_FromParent_Parses() + { + var src = @" +test child from root +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].name, Is.EqualTo("child")); + Assert.That(prog.tests[0].fromParent, Is.EqualTo("root")); + } + + [Test] + public void Test_AbstractFromParent_Parses() + { + var src = @" +abstract test base from grand +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].isAbstract, Is.True); + Assert.That(prog.tests[0].fromParent, Is.EqualTo("grand")); + } + + [Test] + public void Test_MissingEndtest_Errors() + { + var src = @" +test foo +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestMissingEndTest)), Is.True, + "expected TestMissingEndTest error"); + } + + [Test] + public void Test_MissingName_Errors() + { + var src = @" +test +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestMissingName)), Is.True, + "expected TestMissingName error"); + } + + [Test] + public void Test_AbstractWithoutTest_Errors() + { + var src = @" +abstract foo +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.AbstractRequiresTest)), Is.True, + "expected AbstractRequiresTest error"); + } + + [Test] + public void Test_NestedInsideTest_Errors() + { + var src = @" +test outer + test inner + endtest +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestDefinedInsideTest)), Is.True, + "expected TestDefinedInsideTest error"); + } + + [Test] + public void Test_MultipleBlocks_AllParsed() + { + var src = @" +test alpha +endtest + +test beta +endtest + +test gamma +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(3)); + Assert.That(prog.tests[0].name, Is.EqualTo("alpha")); + Assert.That(prog.tests[1].name, Is.EqualTo("beta")); + Assert.That(prog.tests[2].name, Is.EqualTo("gamma")); + } + + [Test] + public void Test_BlockContainsStatements() + { + var src = @" +test foo + x = 5 + y = 10 +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].statements.Count, Is.EqualTo(2)); + } + + [Test] + public void Test_TestNodeNotInProgramFunctions() + { + var src = @" +test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.functions.Count, Is.EqualTo(0)); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + } + + [Test] + public void Test_ProgramAndTest_BothParsed() + { + var src = @" +x = 5 + +test foo + y = 10 +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.statements.Count, Is.GreaterThan(0)); + } + + [Test] + public void Test_ToString_ShowsTest() + { + var src = @" +test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.ToString(), Does.Contain("test foo")); + } + + [Test] + public void Test_Abstract_ToString_ShowsAbstract() + { + var src = @" +abstract test foo +endtest +"; + var prog = ParseClean(src); + Assert.That(prog.ToString(), Does.Contain("abstract test foo")); + } +} diff --git a/FadeBasic/Tests/TestCommmands.cs b/FadeBasic/Tests/TestCommmands.cs index 33aa81b..07cec66 100644 --- a/FadeBasic/Tests/TestCommmands.cs +++ b/FadeBasic/Tests/TestCommmands.cs @@ -11,17 +11,17 @@ public partial class TestCommands { public static readonly CommandCollection CommandsForTesting = new CommandCollection(new TestCommands()); - [FadeBasicCommand("macroFuncTest", FadeBasicCommandUsage.Macro)] + [FadeBasicCommand("macroFuncDemo", FadeBasicCommandUsage.Macro)] public static void Example(int x, ref int id) { id = x * 2; } - [FadeBasicCommand("macro return test", FadeBasicCommandUsage.Macro)] + [FadeBasicCommand("macro return demo", FadeBasicCommandUsage.Macro)] public static int Example() { return 42; } - [FadeBasicCommand("macroReturnTest", FadeBasicCommandUsage.Macro)] + [FadeBasicCommand("macroReturnDemo", FadeBasicCommandUsage.Macro)] public static int Example2() { return 42; @@ -129,10 +129,10 @@ public static void WiatMs(int amount) } // - [FadeBasicCommand("callTest")] + [FadeBasicCommand("callDemo")] public static void CallTest() { - + } [FadeBasicCommand("add")] public static int AddTest(int a, int b) diff --git a/FadeBasic/Tests/TokenMacroTests.cs b/FadeBasic/Tests/TokenMacroTests.cs index 139e6f2..989bd27 100644 --- a/FadeBasic/Tests/TokenMacroTests.cs +++ b/FadeBasic/Tests/TokenMacroTests.cs @@ -780,7 +780,7 @@ function decl(x) ", @" #macro - x = macro return test() + x = macro return demo() # a = 1 #endmacro ")] @@ -789,7 +789,7 @@ function decl(x) ", @" #macro - x = macroReturnTest() + x = macroReturnDemo() # a = 1 #endmacro ")] @@ -873,7 +873,7 @@ public void Macro_Commands() var input = @" #macro - macroFuncTest 6, myImage + macroFuncDemo 6, myImage #endmacro a = [myImage] "; @@ -892,7 +892,7 @@ public void Macro_Command_2_ParseIssue() var input = @" #macro - x = macro return test() + x = macro return demo() # a = 1 #endmacro "; diff --git a/FadeBasic/Tests/TokenVm.cs b/FadeBasic/Tests/TokenVm.cs index a0c620d..bfce192 100644 --- a/FadeBasic/Tests/TokenVm.cs +++ b/FadeBasic/Tests/TokenVm.cs @@ -2782,7 +2782,7 @@ public void Macro_CommandCall() { var src = @" #macro -x = macro return test() +x = macro return demo() #endmacro n = [x] "; @@ -3643,17 +3643,17 @@ e AS egg albert AS chicken albert.e.color = 3 albert.n = 4 -test = albert.e.color * albert.n +result = albert.e.color * albert.n "; Setup(src, out var compiler, out var prog); var vm = new VirtualMachine(prog); vm.hostMethods = compiler.methodTable; vm.Execute2(); - - + + Assert.That(vm.heap.Cursor, Is.EqualTo(8.ToPtr())); Assert.That(vm.typeRegisters[0], Is.EqualTo(TypeCodes.STRUCT)); - + Assert.That(vm.typeRegisters[1], Is.EqualTo(TypeCodes.INT)); Assert.That(vm.dataRegisters[1], Is.EqualTo(12)); } @@ -3870,7 +3870,7 @@ public void CallHost() { var x = new TestCommands(); - var src = "callTest"; + var src = "callDemo"; Setup(src, out var compiler, out var prog); var vm = new VirtualMachine(prog); vm.hostMethods = compiler.methodTable; diff --git a/FadeBasic/Tests/TokenVm_GC.cs b/FadeBasic/Tests/TokenVm_GC.cs index c5a9763..88f8a8c 100644 --- a/FadeBasic/Tests/TokenVm_GC.cs +++ b/FadeBasic/Tests/TokenVm_GC.cs @@ -35,19 +35,19 @@ v as vec v3 = v2 ", 3)] [TestCase(@" -x$ = test() -function test() +x$ = demo() +function demo() endfunction ""igloo"" ", 1)] [TestCase(@" -x$ = test(1) -x$ = test(2) -function test(n) +x$ = demo(1) +x$ = demo(2) +function demo(n) endfunction str$(n) ", 1)] [TestCase(@" -test() -function test() +demo() +function demo() z$ = ""toast"" endfunction ", 1)] @@ -56,8 +56,8 @@ type vec x y endtype -v = test() ` 1 allocation to assign -function test() +v = demo() ` 1 allocation to assign +function demo() v2 as vec v3 as vec endfunction v2 From bc4a867abccc68139feb732982546e0e7249c14b Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Thu, 7 May 2026 06:24:15 -0400 Subject: [PATCH 03/30] working on tests before big human rewrite --- .../ApplicationSupport.csproj | 2 +- .../Launch/LaunchableGenerator.cs | 85 ++- FadeBasic/CHANGELOG.md | 36 ++ FadeBasic/DAP/FadeDebugAdapter.cs | 84 ++- .../FadeBasic.TestAdapter.csproj | 42 ++ .../FadeTestCaseProperties.cs | 52 ++ .../FadeTestConstants.cs | 19 + .../FadeTestDiscoverer.cs | 144 +++++ .../FadeTestExecutorAdapter.cs | 331 +++++++++++ .../FadeTestLaunchableLoader.cs | 291 ++++++++++ .../FadeBasic.Testing.csproj | 143 +++++ .../FadeBasic.Testing/FadeBasic.Testing.props | 115 ++++ .../FadeBasic.Testing.targets | 87 +++ .../FadeTestApplicationBuilder.cs | 327 +++++++++++ .../FadeBasic.Testing/FadeTestFramework.cs | 374 +++++++++++++ FadeBasic/FadeBasic.sln | 13 + FadeBasic/FadeBasic/Ast/FunctionStatement.cs | 3 + FadeBasic/FadeBasic/Ast/StatementNode.cs | 157 ++++++ .../Ast/Visitors/InitializerSugarVisitor.cs | 7 + .../Ast/Visitors/ScopeErrorVisitor.cs | 128 ++++- .../Visitors/TestScopeStrictnessVisitor.cs | 351 ++++++++++++ FadeBasic/FadeBasic/Errors.cs | 19 + FadeBasic/FadeBasic/FadeBasic.csproj | 8 +- FadeBasic/FadeBasic/Launch/DebugSession.cs | 168 ++++-- FadeBasic/FadeBasic/Launch/ITestLaunchable.cs | 17 + FadeBasic/FadeBasic/Launch/LaunchUtil.cs | 59 ++ FadeBasic/FadeBasic/Launch/Launcher.cs | 155 +++++- FadeBasic/FadeBasic/Lexer.cs | 52 +- FadeBasic/FadeBasic/Lsp/LSPUtil.cs | 16 + FadeBasic/FadeBasic/Parser.cs | 510 ++++++++++++++++- FadeBasic/FadeBasic/Sdk/Fade.cs | 9 +- FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs | 159 ++++++ .../Sdk/Testing/DefaultFadeTestHost.cs | 26 + .../Sdk/Testing/FadeManagedIdentifier.cs | 29 + .../Sdk/Testing/FadeTestHostAttribute.cs | 16 + .../Sdk/Testing/FadeTestHostResolver.cs | 115 ++++ .../FadeBasic/Sdk/Testing/IFadeTestHost.cs | 97 ++++ FadeBasic/FadeBasic/TokenFormatter.cs | 6 + FadeBasic/FadeBasic/Virtual/Compiler.cs | 329 ++++++++++- FadeBasic/FadeBasic/Virtual/OpCodes.cs | 44 ++ FadeBasic/FadeBasic/Virtual/VirtualMachine.cs | 146 ++++- .../FadeBuildTasks/FadeBasic.Build.props | 20 +- .../FadeBuildTasks/FadeBasic.Build.targets | 49 +- FadeBasic/FadeBuildTasks/FadeProjectTask.cs | 7 +- .../LSP/Handlers/FindReferencesHandler.cs | 2 +- .../LSP/Handlers/GotoDefinitionHandler.cs | 2 + FadeBasic/Tests/AssertMacroTests.cs | 158 ++++++ FadeBasic/Tests/DapIntegrationTests.cs | 382 +++++++++++++ FadeBasic/Tests/DotnetTestIntegrationDemo.cs | 90 +++ FadeBasic/Tests/FadeTestAdapterTests.cs | 524 ++++++++++++++++++ FadeBasic/Tests/FadeTestRunnerTests.cs | 179 ++++++ FadeBasic/Tests/FadeTestingAdapterTests.cs | 127 +++++ FadeBasic/Tests/FormatTests.cs | 27 + FadeBasic/Tests/LauncherTestArgsTests.cs | 182 ++++++ FadeBasic/Tests/MockExecutionTests.cs | 214 +++++++ FadeBasic/Tests/MockParserTests.cs | 334 +++++++++++ FadeBasic/Tests/MusicFbasicReproTests.cs | 52 ++ FadeBasic/Tests/ParserTests.cs | 16 + FadeBasic/Tests/RuntoCompilerTests.cs | 275 +++++++++ FadeBasic/Tests/RuntoNavigationTests.cs | 91 +++ FadeBasic/Tests/SourceMapTests.cs | 72 ++- FadeBasic/Tests/TestCommmands.cs | 7 +- FadeBasic/Tests/TestExecutionTests.cs | 203 +++++++ FadeBasic/Tests/TestFunctionTests.cs | 256 +++++++++ FadeBasic/Tests/TestManifestPackingTests.cs | 128 +++++ FadeBasic/Tests/TestScopeStrictnessTests.cs | 521 +++++++++++++++++ FadeBasic/Tests/TestScopeTests.cs | 160 ++++++ FadeBasic/Tests/Tests.csproj | 6 +- FadeBasic/build.sln | 13 + FadeBasic/install.sh | 17 +- 70 files changed, 8715 insertions(+), 170 deletions(-) create mode 100644 FadeBasic/FadeBasic.TestAdapter/FadeBasic.TestAdapter.csproj create mode 100644 FadeBasic/FadeBasic.TestAdapter/FadeTestCaseProperties.cs create mode 100644 FadeBasic/FadeBasic.TestAdapter/FadeTestConstants.cs create mode 100644 FadeBasic/FadeBasic.TestAdapter/FadeTestDiscoverer.cs create mode 100644 FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs create mode 100644 FadeBasic/FadeBasic.TestAdapter/FadeTestLaunchableLoader.cs create mode 100644 FadeBasic/FadeBasic.Testing/FadeBasic.Testing.csproj create mode 100644 FadeBasic/FadeBasic.Testing/FadeBasic.Testing.props create mode 100644 FadeBasic/FadeBasic.Testing/FadeBasic.Testing.targets create mode 100644 FadeBasic/FadeBasic.Testing/FadeTestApplicationBuilder.cs create mode 100644 FadeBasic/FadeBasic.Testing/FadeTestFramework.cs create mode 100644 FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs create mode 100644 FadeBasic/FadeBasic/Launch/ITestLaunchable.cs create mode 100644 FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs create mode 100644 FadeBasic/FadeBasic/Sdk/Testing/DefaultFadeTestHost.cs create mode 100644 FadeBasic/FadeBasic/Sdk/Testing/FadeManagedIdentifier.cs create mode 100644 FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostAttribute.cs create mode 100644 FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostResolver.cs create mode 100644 FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs create mode 100644 FadeBasic/Tests/AssertMacroTests.cs create mode 100644 FadeBasic/Tests/DapIntegrationTests.cs create mode 100644 FadeBasic/Tests/DotnetTestIntegrationDemo.cs create mode 100644 FadeBasic/Tests/FadeTestAdapterTests.cs create mode 100644 FadeBasic/Tests/FadeTestRunnerTests.cs create mode 100644 FadeBasic/Tests/FadeTestingAdapterTests.cs create mode 100644 FadeBasic/Tests/LauncherTestArgsTests.cs create mode 100644 FadeBasic/Tests/MockExecutionTests.cs create mode 100644 FadeBasic/Tests/MockParserTests.cs create mode 100644 FadeBasic/Tests/MusicFbasicReproTests.cs create mode 100644 FadeBasic/Tests/RuntoCompilerTests.cs create mode 100644 FadeBasic/Tests/RuntoNavigationTests.cs create mode 100644 FadeBasic/Tests/TestExecutionTests.cs create mode 100644 FadeBasic/Tests/TestFunctionTests.cs create mode 100644 FadeBasic/Tests/TestManifestPackingTests.cs create mode 100644 FadeBasic/Tests/TestScopeStrictnessTests.cs create mode 100644 FadeBasic/Tests/TestScopeTests.cs diff --git a/FadeBasic/ApplicationSupport/ApplicationSupport.csproj b/FadeBasic/ApplicationSupport/ApplicationSupport.csproj index 73b83df..fc70a2c 100644 --- a/FadeBasic/ApplicationSupport/ApplicationSupport.csproj +++ b/FadeBasic/ApplicationSupport/ApplicationSupport.csproj @@ -16,7 +16,7 @@ - + diff --git a/FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs b/FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs index 15d8fea..8adcf90 100644 --- a/FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs +++ b/FadeBasic/ApplicationSupport/Launch/LaunchableGenerator.cs @@ -15,29 +15,59 @@ public class LaunchableGenerator public const string TAG_MAIN = "__MAIN__"; public const string TAG_ENCODED_BYTECODE = "__ENCODED_BYTE_CODE__"; public const string TAG_ENCODED_DEBUGDATA = "__ENCODED_DEBUG_DATA__"; + public const string TAG_ENCODED_TESTMANIFEST = "__ENCODED_TEST_MANIFEST__"; public const string TAG_COMMAND_ARRAY = "__COMMAND_ARR__"; public const string TEMPLATE_BYTECODE_TAB = " "; public const string TEMPLATE_ENCODED_BYTE_VAR = "encodedByteCode"; public const string TEMPLATE_ENCODED_DEBUGDATA_VAR = "encodedDebugData"; + public const string TEMPLATE_ENCODED_TESTMANIFEST_VAR = "encodedTestManifest"; public const string TEMPLATE_BYTECODE_VAR = "_byteCode"; public const string TEMPLATE_DEBUGDATA_VAR = "_debugData"; + public const string TEMPLATE_TESTMANIFEST_VAR = "_testManifest"; + // Default Main when FadeEnableTesting is off. Forwards args into the + // existing test-aware Launcher dispatcher (handles --fade-test=name etc.). public static readonly string MainTemplate = $@" - public static void Main(string[] args) + public static int Main(string[] args) {{ - Launcher.Run<{TAG_CLASSNAME}>(); + return Launcher.Main<{TAG_CLASSNAME}>(args); }} "; - public static readonly string ClassTemplate = + + // Main when FadeEnableTesting is on. Routes Microsoft.Testing.Platform + // invocations (dotnet test, --list-tests, --filter, --server, ...) through + // FadeBasic.Testing.FadeTestApplicationBuilder; everything else still goes + // to the existing Launcher path so `dotnet run` and --fade-test keep working. + // + // Custom IFadeTestHost is picked up by attribute-based discovery: tag the + // class [FadeBasic.Testing.FadeTestHost] and FadeTestApplicationBuilder + // resolves it at startup. If none is found, DefaultFadeTestHost is used. + public static readonly string MainTemplateWithTesting = +$@" + public static int Main(string[] args) + {{ + if (global::FadeBasic.Testing.FadeTestApplicationBuilder.IsTestInvocation(args)) + {{ + var instance = new {TAG_CLASSNAME}(); + return global::FadeBasic.Testing.FadeTestApplicationBuilder + .RunAsync(instance, args) + .GetAwaiter().GetResult(); + }} + return Launcher.Main<{TAG_CLASSNAME}>(args); + }} +"; + + public static readonly string ClassTemplate = $@"// This is a generated file. Do not edit directly. using {nameof(System)}; +using {nameof(System)}.{nameof(System.Collections)}.{nameof(System.Collections.Generic)}; using {nameof(FadeBasic)}; using {nameof(FadeBasic)}.{nameof(FadeBasic.Launch)}; using {nameof(FadeBasic)}.{nameof(FadeBasic.Virtual)}; -public class {TAG_CLASSNAME} : {nameof(ILaunchable)} +public partial class {TAG_CLASSNAME} : {nameof(ITestLaunchable)} {{ {TAG_MAIN} @@ -49,6 +79,8 @@ public class {TAG_CLASSNAME} : {nameof(ILaunchable)} public DebugData DebugData => {TEMPLATE_DEBUGDATA_VAR}; + public IReadOnlyList TestManifest => {TEMPLATE_TESTMANIFEST_VAR}; + #region method table private static readonly CommandCollection _collection = new CommandCollection( {TAG_COMMAND_ARRAY} @@ -64,21 +96,35 @@ public class {TAG_CLASSNAME} : {nameof(ILaunchable)} protected byte[] {TEMPLATE_BYTECODE_VAR} = {nameof(LaunchUtil)}.{nameof(LaunchUtil.Unpack64)}({TEMPLATE_ENCODED_BYTE_VAR}); protected const string {TEMPLATE_ENCODED_BYTE_VAR} = {TAG_ENCODED_BYTECODE}; #endregion + + #region testManifest + protected IReadOnlyList {TEMPLATE_TESTMANIFEST_VAR} = {nameof(LaunchUtil)}.{nameof(LaunchUtil.UnpackTestManifest)}({TEMPLATE_ENCODED_TESTMANIFEST_VAR}); + protected const string {TEMPLATE_ENCODED_TESTMANIFEST_VAR} = {TAG_ENCODED_TESTMANIFEST}; + #endregion }} "; - public static void GenerateLaunchable(string className, - string filePath, - CodeUnit unit, - CommandCollection collection, - List commandClasses, + public static void GenerateLaunchable(string className, + string filePath, + CodeUnit unit, + CommandCollection collection, + List commandClasses, bool includeMain=true, - bool generateDebug=false) + bool generateDebug=false, + bool enableTesting=false) { var compiler = unit.program.Compile(collection, new CompilerOptions { GenerateDebugData = generateDebug }); + + // Stamp originating .fbasic file paths onto each test manifest entry + // before we pack it into the generated launchable. Multi-file projects + // need this so IDE Test Explorer (Stage 11H VSTest adapter) can + // source-link each test to the right file. CodeUnit always carries a + // SourceMap when it comes from the build-task / SDK pipelines. + FadeBasic.Launch.LaunchUtil.ApplySourceMap(compiler.TestManifest, unit.sourceMap); + var byteCode = compiler.Program.ToArray(); var src = ClassTemplate; @@ -86,15 +132,25 @@ public static void GenerateLaunchable(string className, string byteCodeReplacement = "\"" + byteCodeStr + "\""; var commandArray = GetCommandTable(commandClasses); - + var debugDataStr = generateDebug ? LaunchUtil.PackDebugData(compiler.DebugData) : ""; string debugDataReplacement = "\"" + debugDataStr + "\""; - - var main = includeMain ? MainTemplate : ""; - src = src.Replace(TAG_MAIN, main); + + // Always pack the test manifest. Empty when the source has no tests. + var testManifestStr = LaunchUtil.PackTestManifest(compiler.TestManifest); + string testManifestReplacement = "\"" + testManifestStr + "\""; + + string mainBlock = ""; + if (includeMain) + { + mainBlock = enableTesting ? MainTemplateWithTesting : MainTemplate; + } + + src = src.Replace(TAG_MAIN, mainBlock); src = src.Replace(TAG_COMMAND_ARRAY, commandArray); src = src.Replace(TAG_ENCODED_BYTECODE, byteCodeReplacement); src = src.Replace(TAG_ENCODED_DEBUGDATA, debugDataReplacement); + src = src.Replace(TAG_ENCODED_TESTMANIFEST, testManifestReplacement); src = src.Replace(TAG_CLASSNAME, className); var dir = Path.GetDirectoryName(filePath); @@ -111,6 +167,7 @@ static string GetCommandTable(List commandClasses) } return string.Join(", ", instantiates); } + static string GetCommandTable(ProjectContext context) { // IMethod collection = new CommandCollection() diff --git a/FadeBasic/CHANGELOG.md b/FadeBasic/CHANGELOG.md index 4b3ce52..f6db7b1 100644 --- a/FadeBasic/CHANGELOG.md +++ b/FadeBasic/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- `FadeBasic.Testing` adapter — first-party Microsoft.Testing.Platform `ITestFramework` + that surfaces every Fade `test ... endtest` block to `dotnet test` and IDE Test + Explorer (VS / Rider via VSTestBridge). Replaces the NUnit-fixture path. +- `IFadeTestHost` extension point — downstream consumers (MonoGame, Avalonia, + custom hosts) implement this and tag the class with + `[FadeBasic.Testing.FadeTestHost]` to take over per-session / per-test setup + (e.g., rebuild a `Game1`, reset GPU state). +- The opt-in is **a single PackageReference**: adding `FadeBasic.Testing` + pulls in MTP + the VSTestBridge + the MTP MSBuild integration, defaults + `FadeEnableTesting=true` via the package's own props, sets every required + MTP knob (`IsTestProject`, runner flags, auto-Main suppression), and + auto-writes a `global.json` next to the csproj so `dotnet test` opts into + the new runner. To opt back out, set `false`. +- Diagnostic codes: + - `FADE0001` — warns when the deprecated `FadeGenerateNUnitFixture` flag is set. + - `FADE0002` — fires when `FadeEnableTesting=true` but `FadeBasic.Testing` is + not in the consumer's `PackageReference` list (NuGet can't resolve a + package-provided conditional `PackageReference` during restore, so this + has to be authored explicitly). + - `FADE0003` — fires when an existing `global.json` does not opt into MTP. + +### Changed +- All testing-related MSBuild config moved out of `FadeBasic.Build` into + `FadeBasic.Testing/build/FadeBasic.Testing.props/targets`. The Build + package stays focused on Fade source compilation; the Testing package + owns its own MSBuild surface. + +### Removed +- `FadeGenerateNUnitFixture` MSBuild property — emits FADE0001 instead. + The auto-imported `Microsoft.NET.Test.Sdk` / `NUnit` / `NUnit3TestAdapter` + package references are gone; the new path is dependency-free apart from MTP. +- `LaunchableGenerator.NUnitFixtureTemplate` and the corresponding + `IsTestProject` / `GenerateProgramFile` workarounds in `FadeBasic.Build.targets`. + ## [0.0.64] - 2026-04-28 ### Added - Rider IDE Plugin Support diff --git a/FadeBasic/DAP/FadeDebugAdapter.cs b/FadeBasic/DAP/FadeDebugAdapter.cs index dcfe2ab..fd45358 100644 --- a/FadeBasic/DAP/FadeDebugAdapter.cs +++ b/FadeBasic/DAP/FadeDebugAdapter.cs @@ -87,38 +87,11 @@ protected override AttachResponse HandleAttachRequest(AttachArguments arguments) _session = new RemoteDebugSession(port, _logger.Log); - _session.HitBreakpointCallback = () => - { - Protocol.SendEvent(new StoppedEvent - { - Reason = StoppedEvent.ReasonValue.Breakpoint, - Description = "Hit a breakpoint", - ThreadId = 1, - AllThreadsStopped = true, - HitBreakpointIds = new List(){0} - }); - }; + WireSessionEvents(); - _session.Exited = () => - { - Protocol.SendEvent(new ExitedEvent()); - Protocol.SendEvent(new TerminatedEvent()); - }; - - _session.RuntimeException = (error) => - { - _logger.Log($"Received runtime exception message=[{error}]"); - Protocol.SendEvent(new StoppedEvent(StoppedEvent.ReasonValue.Exception) - { - Text = "Fatal Exception", - Description = error, - AllThreadsStopped = true, - }); - }; - - // as soon as this event is sent- debugger info will appear. + // as soon as this event is sent- debugger info will appear. Protocol.SendEvent(new InitializedEvent()); - + _logger.Log("Attaching to debug application"); _session.Connect(); @@ -182,9 +155,17 @@ protected override LaunchResponse HandleLaunchRequest(LaunchArguments arguments) // at this point, we can actually kick off the process. var path = Path.GetDirectoryName(_fileName); var port = LaunchUtil.FreeTcpPort(); + + // public const string ENV_DEBUG_DOTNET_COMMAND = "FADE_BASIC_DEBUG_DOTNET_COMMAND"; + + var dotnetCommand = Environment.GetEnvironmentVariable("FADE_BASIC_DEBUG_DOTNET_COMMAND"); + if (string.IsNullOrEmpty(dotnetCommand)) + { + dotnetCommand = "run"; + } var startReq = new RunInTerminalRequest(path, new List { - DAPEnv.DotnetPath, "run", "--project", _fileName, "-p:FadeBasicDebug=true" + DAPEnv.DotnetPath, dotnetCommand, "--project", _fileName, "-p:FadeBasicDebug=true" }); startReq.Kind = RunInTerminalArguments.KindValue.Integrated; @@ -203,6 +184,28 @@ protected override LaunchResponse HandleLaunchRequest(LaunchArguments arguments) hasSession = true; _session = new RemoteDebugSession(port, _logger.Log); + WireSessionEvents(); + + // as soon as this event is sent- debugger info will appear. + Protocol.SendEvent(new InitializedEvent()); + + + this.Protocol.SendClientRequest(startReq, x => + { + _logger.Log("Connecting to debug application"); + _session.Connect(); + _session.SayHello(); + + }, (args, err) => + { + + }); + var res = new LaunchResponse(); + return res; + } + + private void WireSessionEvents() + { _session.RestartCallback = () => { _logger?.Log("RESTART HANDLING: Re-applying breakpoints and resuming"); @@ -214,6 +217,7 @@ protected override LaunchResponse HandleLaunchRequest(LaunchArguments arguments) _session.SayHello(); }); }; + _session.HitBreakpointCallback = () => { Protocol.SendEvent(new StoppedEvent @@ -242,26 +246,8 @@ protected override LaunchResponse HandleLaunchRequest(LaunchArguments arguments) AllThreadsStopped = true, }); }; - - // as soon as this event is sent- debugger info will appear. - Protocol.SendEvent(new InitializedEvent()); - - - this.Protocol.SendClientRequest(startReq, x => - { - _logger.Log("Connecting to debug application"); - _session.Connect(); - _session.SayHello(); - - }, (args, err) => - { - - }); - var res = new LaunchResponse(); - return res; } - protected override ConfigurationDoneResponse HandleConfigurationDoneRequest(ConfigurationDoneArguments arguments) { return new ConfigurationDoneResponse(); diff --git a/FadeBasic/FadeBasic.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..5a51ae7 --- /dev/null +++ b/FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs @@ -0,0 +1,331 @@ +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(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 the originating .fbasic + /// line so the Test Explorer "failure" pane reads as a Fade error, + /// not a generic .NET exception dump. + /// + 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); + } + if (entry.sourceLine > 0 && !string.IsNullOrEmpty(fbasicPath)) + { + sb.Append("\n at ") + .Append(Path.GetFileName(fbasicPath)) + .Append(':') + .Append(entry.sourceLine); + } + return sb.ToString(); + } + + /// + /// Synthesize a single stack-trace frame in the canonical at <name> + /// in <file>:line N format. Both VS Code and Rider parse this + /// regex and turn it into a clickable source link in the failure pane. + /// + internal static string BuildErrorStackTrace(string fbasicPath, TestManifestEntry entry) + { + if (entry.sourceLine <= 0 || string.IsNullOrEmpty(fbasicPath)) 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..7a0f517 --- /dev/null +++ b/FadeBasic/FadeBasic.Testing/FadeTestFramework.cs @@ -0,0 +1,374 @@ +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.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..ac355b5 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,10 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +116,14 @@ 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 EndGlobalSection GlobalSection(NestedProjects) = preSolution {BFF3475D-F580-4C2C-B025-B80BAD2EB915} = {7A2CFDCE-7E00-4B3A-82DE-E5CE48A4F13D} diff --git a/FadeBasic/FadeBasic/Ast/FunctionStatement.cs b/FadeBasic/FadeBasic/Ast/FunctionStatement.cs index 45d07c5..b93b27a 100644 --- a/FadeBasic/FadeBasic/Ast/FunctionStatement.cs +++ b/FadeBasic/FadeBasic/Ast/FunctionStatement.cs @@ -62,8 +62,11 @@ public override IEnumerable IterateChildNodes() 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(); diff --git a/FadeBasic/FadeBasic/Ast/StatementNode.cs b/FadeBasic/FadeBasic/Ast/StatementNode.cs index 2ede9c9..bc9fd41 100644 --- a/FadeBasic/FadeBasic/Ast/StatementNode.cs +++ b/FadeBasic/FadeBasic/Ast/StatementNode.cs @@ -107,6 +107,163 @@ public override IEnumerable IterateChildNodes() } } + 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; + + public AssertStatement(Token startToken, Token endToken, IExpressionNode condition, string sourceText) + : base(startToken, endToken) + { + this.condition = condition; + this.sourceText = sourceText; + } + + protected override string GetString() + { + return $"assert {condition}"; + } + + public override IEnumerable IterateChildNodes() + { + yield return condition; + } + } + + 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}"; + } + + public override IEnumerable IterateChildNodes() + { + if (maxCyclesExpression != null) + yield return maxCyclesExpression; + } + } + + public enum MockEntryKind + { + Returns, + Forbid, + // Bare-form mock (`mock ` with no body). Used for void + // commands the test wants to suppress, like `mock wait ms` to skip + // the actual sleep during test execution. Args are still consumed + // from the stack at dispatch time. + Void + } + + public enum MockFrequencyKind + { + Always, // default — applies to every call until exhausted (forbid: every call) + Once, // applies to one call, then the entry is consumed + NTimes // applies to N calls, then the entry is consumed (N from countExpression) + } + + public class MockEntry : AstNode + { + public MockEntryKind kind; + public MockFrequencyKind frequency = MockFrequencyKind.Always; + // Only set when kind == Returns. The expression evaluated to produce + // the mocked return value at dispatch time. + public IExpressionNode returnExpression; + // Only set when frequency == NTimes. Evaluated once when the mock is + // installed; the result is the count of remaining calls for this entry. + public IExpressionNode countExpression; + + public MockEntry(Token startToken, Token endToken) : base(startToken, endToken) + { + } + + protected override string GetString() + { + var freqStr = frequency switch + { + MockFrequencyKind.Once => " once", + MockFrequencyKind.NTimes => $" {countExpression} times", + _ => "" // always is the default, omit for brevity + }; + return kind == MockEntryKind.Forbid + ? $"forbid{freqStr}" + : $"returns {returnExpression}{freqStr}"; + } + + public override IEnumerable IterateChildNodes() + { + if (returnExpression != null) yield return returnExpression; + if (countExpression != null) yield return countExpression; + } + } + + 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; + public List entries = new List(); + + public MockStatement(Token startToken, Token endToken) : base(startToken, endToken) + { + } + + protected override string GetString() + { + return $"mock {commandName} ({string.Join(",", entries.Select(e => e.ToString()))})"; + } + + public override IEnumerable IterateChildNodes() + { + foreach (var entry in entries) yield return entry; + } + } + + public class ClearMockStatement : AstNode, IStatementNode + { + // Null means "clear all mocks" (`clear mocks`). + // Non-null is a specific command name (`clear mock screen width`). + public string commandName; + public Token commandNameToken; + + public ClearMockStatement(Token startToken, Token endToken) : base(startToken, endToken) + { + } + + protected override string GetString() + { + return commandName == null ? "clear mocks" : $"clear mock {commandName}"; + } + + public override IEnumerable IterateChildNodes() + { + yield break; + } + } + public class MacroSubstitutionExpression : AstNode, IExpressionNode { public IExpressionNode innerExpression; diff --git a/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs index d1088c7..d7ebe5d 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs @@ -43,6 +43,13 @@ static void ApplyStatements(List statements) } ApplyStatements(switchStatement.defaultCase?.statements); break; + case TestNode testStatement: + foreach (var func in testStatement.functions) + { + ApplyStatements(func.statements); + } + ApplyStatements(testStatement.statements); + break; } } diff --git a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs index 69189fc..9d08592 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs @@ -46,8 +46,14 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } }); + var allFunctions = new List(); + allFunctions.AddRange(program.functions); + foreach (var test in program.tests) + { + allFunctions.AddRange(test.functions); + } - foreach (var function in program.functions) + foreach (var function in allFunctions) { foreach (var label in function.labels) { @@ -56,6 +62,17 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions scope.DeclareFunction(function); } + // Register test labels with the test's name as the owning scope. + // Cross-namespace goto/gosub (test→program, program→test, test→other-test) + // now fires the existing TraverseLabelBetweenScopes error. + foreach (var test in program.tests) + { + foreach (var label in test.labels) + { + scope.AddLabel(test.name, label); + } + } + // CheckTypeInfo2(scope); CheckTypesForUnknownReferences(scope); CheckTypesForRecursiveReferences(scope, out var typeRefCounter); @@ -78,9 +95,11 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } } + + scope.currentRegionName.Push(FunctionStatement.REGION_TOP_LEVEL); CheckStatements(program.statements, scope, globalCtx); - foreach (var function in program.functions) + foreach (var function in allFunctions) { if (scope.functionReturnTypeTable.ContainsKey(function.name)) { @@ -105,13 +124,27 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } - foreach (var function in program.functions) + foreach (var function in allFunctions) { if (scope.functionReturnTypeTable.ContainsKey(function.name)) continue; // already parsed. function.Errors.Add(new ParseError(function.startToken, ErrorCodes.UnknowableFunctionReturnType)); } + // Validate test bodies: each test gets its own local-variable scope. + // The main scope check below handles general "unknown symbol" errors. + // The TestScopeStrictnessVisitor adds the strict scope_at(:L) check + // afterwards, ensuring tests can only reference program-scope names + // that are visible at the most recent runto target. + scope.currentRegionName.Pop(); // remove the top level region. + foreach (var test in program.tests) + { + scope.BeginTest(test); + CheckStatements(test.statements, scope, globalCtx); + scope.EndTest(test); + } + program.EnforceStrictTestScopes(); + foreach (var def in scope.defaultValueExpressions) { if (def.ParsedType.type == VariableType.Void) @@ -473,6 +506,52 @@ static void CheckStatements(this List statements, Scope scope, E case TypeDefinitionStatement invalidTypeStatement: invalidTypeStatement.Errors.Add(new ParseError(invalidTypeStatement.name, ErrorCodes.TypeMustBeTopLevel)); break; + case AssertStatement assertStatement: + // Strict scope enforcement is handled by TestScopeStrictnessVisitor. + // Here we only need to recurse into the condition expression to + // catch general "unknown symbol" errors. + if (!scope.IsInsideTest) + { + assertStatement.Errors.Add(new ParseError(assertStatement.StartToken, ErrorCodes.AssertOutsideTest)); + } + if (assertStatement.condition != null) + { + assertStatement.condition.EnsureVariablesAreDefined(scope, ctx); + } + break; + case RuntoStatement runtoStatement: + // Runto target validation happens in the TestScopeStrictnessVisitor. + // Here we just resolve the target label's symbol so the + // LSP can offer go-to-definition + find-references on + // `runto labelName` sites. + if (!scope.IsInsideTest) + { + runtoStatement.Errors.Add(new ParseError(runtoStatement.StartToken, ErrorCodes.RuntoOutsideTest)); + } + if (runtoStatement.targetLabel != null + && scope.TryGetLabel(runtoStatement.targetLabel, out var runtoLabelSymbol)) + { + runtoStatement.DeclaredFromSymbol = runtoLabelSymbol; + } + if (runtoStatement.maxCyclesExpression != null) + { + runtoStatement.maxCyclesExpression.EnsureVariablesAreDefined(scope, ctx); + } + break; + case MockStatement mockStatement: + if (!scope.IsInsideTest) + { + mockStatement.Errors.Add(new ParseError(mockStatement.StartToken, ErrorCodes.MockOutsideTest)); + } + ValidateMockStatement(mockStatement, scope, ctx); + break; + case ClearMockStatement clearMockStatement: + if (!scope.IsInsideTest) + { + clearMockStatement.Errors.Add(new ParseError(clearMockStatement.StartToken, ErrorCodes.ClearMockOutsideTest)); + } + ValidateClearMockStatement(clearMockStatement); + break; default: throw new NotImplementedException($"cannot check statement for scope errors - {statement.GetType().Name} {statement}"); // break; @@ -480,6 +559,42 @@ static void CheckStatements(this List statements, Scope scope, E } } + // mock and clear-mock validation. Command-existence is enforced by the + // lexer's CommandNameTree pass (an unknown command name doesn't tokenize + // as CommandWord, so the parser already errors). Here we walk the entry + // expressions to catch general scope errors (unknown variable refs in + // `returns` expressions, etc.) and emit unreachable-entry warnings. + static void ValidateMockStatement(MockStatement mock, Scope scope, EnsureTypeContext ctx) + { + var sawAlways = false; + for (var i = 0; i < mock.entries.Count; i++) + { + var entry = mock.entries[i]; + if (sawAlways) + { + entry.Errors.Add(new ParseError(entry.StartToken, ErrorCodes.MockUnreachableEntry)); + } + if (entry.frequency == MockFrequencyKind.Always) sawAlways = true; + + if (entry.returnExpression != null) + { + entry.returnExpression.EnsureVariablesAreDefined(scope, ctx); + } + if (entry.countExpression != null) + { + entry.countExpression.EnsureVariablesAreDefined(scope, ctx); + } + } + } + + static void ValidateClearMockStatement(ClearMockStatement clear) + { + // Nothing to validate at the scope level — the parser already + // checked for `mock ` / `mocks` shape, and the command name + // (if present) was a CommandWord token (so it's known to the + // command collection). + } + // static void TryGetSymbolTable(this StructFieldReference) static void EnsureLabel(Scope scope, string label, AstNode node) { @@ -709,6 +824,13 @@ public static void EnsureVariablesAreDefined(this IExpressionNode expr, Scope sc { if (scope.functionTable.TryGetValue(arrayRef.variableName, out var function)) { + + // if the function is not a top level, and the current scope IS top level; then we have an issue. + if (function.region != FunctionStatement.REGION_TOP_LEVEL && scope.currentRegionName.Peek() == FunctionStatement.REGION_TOP_LEVEL) + { + arrayRef.Errors.Add(new ParseError(arrayRef.startToken, ErrorCodes.CannotCallTestFunctionFromOutsideTest)); + } + TypeInfo functionType = default; arrayRef.TransitiveFlags |= function.TransitiveFlags; if (ctx.functionHistory.Contains(function.name)) diff --git a/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs new file mode 100644 index 0000000..7d34220 --- /dev/null +++ b/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FadeBasic.Ast.Visitors +{ + /// + /// Enforces the strict scope_at(:L) semantics from TEST_DESIGN.md §5. + /// A test body sees only: + /// + /// Test-locals (declared via local in the test). + /// Test-functions (declared via function inside the test). + /// Always-visible globals (declared via global X at top level). + /// Names declared by program top-level execution up to the most recent runto target. + /// + /// References to program-declared names that are not visible at the + /// current point are flagged with TestVariableUnreachable (no runto has + /// reached the declaration yet) or TestVariableNotYetDeclared (the + /// runto target is earlier than the declaration). + /// + public static class TestScopeStrictnessVisitor + { + public static void EnforceStrictTestScopes(this ProgramNode program) + { + var scopeAt = ComputeTopLevelScopeAt(program, out var globalNames, out var allTopLevelNames); + + // Extend scope_at to cover function-internal labels too. + // For a label inside a function, the visible names are: + // globals + function params + function locals declared up to that label + // (Main-body names visible at the function's callsites are NOT included + // here — that pulls in callsite-intersection analysis which we defer. + // Users who want test-visibility for shared variables should use `global`.) + ComputeFunctionInternalScopeAts(program, globalNames, scopeAt, allTopLevelNames); + + foreach (var test in program.tests) + { + ValidateTest(test, scopeAt, globalNames, allTopLevelNames); + } + } + + private static void ComputeFunctionInternalScopeAts( + ProgramNode program, + HashSet globalNames, + Dictionary> scopeAt, + HashSet allTopLevelNames) + { + foreach (var fn in program.functions) + { + var fnState = new HashSet(globalNames, StringComparer.OrdinalIgnoreCase); + if (fn.parameters != null) + { + foreach (var param in fn.parameters) + { + if (param.variable != null) + { + fnState.Add(param.variable.variableName); + allTopLevelNames.Add(param.variable.variableName); + } + } + } + WalkStatements(fn.statements, fnState, scopeAt); + // Add function-local names to allTopLevelNames so the test validator + // can distinguish "declared but not visible from this runto" vs + // "doesn't exist at all". + foreach (var n in fnState) allTopLevelNames.Add(n); + } + } + + // Walk program top-level statements once, accumulating declared names in + // source order. At each label declaration, snapshot the current set. + // Globals (`global X = ...`) are present from the start; bare top-level + // assignments (`x = 5`) get added when their statement is reached. + // Branch rule: both arms of `if/else` contribute their names at the merge + // point (matches Fade's existing semantics). + private static Dictionary> ComputeTopLevelScopeAt( + ProgramNode program, + out HashSet globalNames, + out HashSet allTopLevelNames) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var globals = new HashSet(StringComparer.OrdinalIgnoreCase); + + // First pass: collect globals (always visible). + program.Visit(node => + { + if (node is DeclarationStatement decl + && decl.scopeType == DeclarationScopeType.Global + && IsAtTopLevel(node, program)) + { + globals.Add(decl.variable); + } + }); + + var current = new HashSet(globals, StringComparer.OrdinalIgnoreCase); + WalkStatements(program.statements, current, result); + + globalNames = globals; + allTopLevelNames = new HashSet(current, StringComparer.OrdinalIgnoreCase); + return result; + } + + private static bool IsAtTopLevel(IAstNode node, ProgramNode program) + { + // Heuristic: a node is "top-level" if it appears in program.statements, + // not inside any function body. The Visit method walks recursively, so + // we need a cheap check. For simplicity, use the start token's depth in + // function bodies — if any function contains the node, it's not top-level. + foreach (var fn in program.functions) + { + foreach (var stmt in fn.statements) + { + if (ReferenceEquals(stmt, node)) return false; + if (stmt is IAstVisitable visitable) + { + var found = visitable.FindFirst(n => ReferenceEquals(n, node)); + if (found != null) return false; + } + } + } + return true; + } + + private static void WalkStatements( + IEnumerable stmts, + HashSet current, + Dictionary> result) + { + foreach (var stmt in stmts) + { + switch (stmt) + { + case LabelDeclarationNode label: + // Snapshot the visible-names set at this label's position. + result[label.label] = new HashSet(current, StringComparer.OrdinalIgnoreCase); + break; + + case DeclarationStatement decl: + current.Add(decl.variable); + break; + + case AssignmentStatement asn when asn.variable is VariableRefNode vref: + current.Add(vref.variableName); + break; + + case ForStatement forStmt: + if (forStmt.variableNode is VariableRefNode forVar) + { + current.Add(forVar.variableName); + } + WalkStatements(forStmt.statements, current, result); + break; + + case WhileStatement whileStmt: + WalkStatements(whileStmt.statements, current, result); + break; + + case DoLoopStatement doStmt: + WalkStatements(doStmt.statements, current, result); + break; + + case RepeatUntilStatement repeatStmt: + WalkStatements(repeatStmt.statements, current, result); + break; + + case IfStatement ifStmt: + // Both branches contribute names — Fade's existing + // branch-merge semantics. + if (ifStmt.positiveStatements != null) + { + WalkStatements(ifStmt.positiveStatements, current, result); + } + if (ifStmt.negativeStatements != null) + { + WalkStatements(ifStmt.negativeStatements, current, result); + } + break; + + case SwitchStatement switchStmt: + if (switchStmt.cases != null) + { + foreach (var c in switchStmt.cases) + { + if (c.statements != null) WalkStatements(c.statements, current, result); + } + } + if (switchStmt.defaultCase?.statements != null) + { + WalkStatements(switchStmt.defaultCase.statements, current, result); + } + break; + } + } + } + + private static void ValidateTest( + TestNode test, + Dictionary> scopeAt, + HashSet globalNames, + HashSet allTopLevelNames) + { + var testLocals = new HashSet(StringComparer.OrdinalIgnoreCase); + var testFunctions = new HashSet( + test.functions.Select(f => f.name), + StringComparer.OrdinalIgnoreCase); + + // Visible program-scope names. Starts with globals only (pre-runto). + // Updated to scope_at(target) when a runto is encountered. + var visible = new HashSet(globalNames, StringComparer.OrdinalIgnoreCase); + string currentRuntoTarget = null; + + void VisitStatement(IStatementNode stmt) + { + switch (stmt) + { + case RuntoStatement runto: + if (scopeAt.TryGetValue(runto.targetLabel, out var snapshot)) + { + visible = new HashSet(snapshot, StringComparer.OrdinalIgnoreCase); + } + currentRuntoTarget = runto.targetLabel; + break; + + case DeclarationStatement decl when decl.scopeType == DeclarationScopeType.Local: + testLocals.Add(decl.variable); + if (decl.initializerExpression != null) + { + CheckExpression(decl.initializerExpression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + break; + + case AssignmentStatement asn: + // RHS must be visible. + CheckExpression(asn.expression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + // LHS: if it's a bare variable that's not yet a local AND + // not a known program name, it's an implicit test-local. + if (asn.variable is VariableRefNode vref) + { + // If the name doesn't already exist in any scope, it's + // implicitly a test-local. If it exists in program but + // not visible, flag the LHS as well. + if (!testLocals.Contains(vref.variableName) + && !visible.Contains(vref.variableName) + && allTopLevelNames.Contains(vref.variableName)) + { + AddVisibilityError(asn, vref.variableName, currentRuntoTarget); + } + else if (!testLocals.Contains(vref.variableName) + && !visible.Contains(vref.variableName)) + { + // Implicit test-local declaration. + testLocals.Add(vref.variableName); + } + } + break; + + case AssertStatement assert: + if (assert.condition != null) + { + CheckExpression(assert.condition, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + break; + + case IfStatement ifStmt: + if (ifStmt.condition != null) + { + CheckExpression(ifStmt.condition, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + if (ifStmt.positiveStatements != null) + foreach (var s in ifStmt.positiveStatements) VisitStatement(s); + if (ifStmt.negativeStatements != null) + foreach (var s in ifStmt.negativeStatements) VisitStatement(s); + break; + + case ForStatement forStmt: + if (forStmt.variableNode is VariableRefNode forVar) testLocals.Add(forVar.variableName); + if (forStmt.statements != null) + foreach (var s in forStmt.statements) VisitStatement(s); + break; + + case WhileStatement whileStmt: + if (whileStmt.statements != null) + foreach (var s in whileStmt.statements) VisitStatement(s); + break; + + case DoLoopStatement doStmt: + if (doStmt.statements != null) + foreach (var s in doStmt.statements) VisitStatement(s); + break; + + case ExpressionStatement expStmt: + CheckExpression(expStmt.expression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + break; + + case FunctionStatement _: + // Function bodies are validated independently; their own + // parameters/locals are scoped within. Don't descend. + break; + } + } + + void AddVisibilityError(IAstNode node, string name, string runtoTarget) + { + var code = runtoTarget == null + ? ErrorCodes.TestVariableUnreachable + : ErrorCodes.TestVariableNotYetDeclared; + node.Errors.Add(new ParseError(node.StartToken ?? node.EndToken, code, name)); + } + + // Check an expression's variable references. + void CheckExpression(IExpressionNode expr, + HashSet testLocalsRef, + HashSet testFunctionsRef, + HashSet visibleRef, + string runtoTargetRef, + HashSet globalsRef, + HashSet allNamesRef) + { + if (expr == null) return; + expr.Visit(child => + { + if (child is VariableRefNode vref) + { + var name = vref.variableName; + if (testLocalsRef.Contains(name)) return; + if (testFunctionsRef.Contains(name)) return; + if (visibleRef.Contains(name)) return; + // The name is not in test scope and not in the visible + // program-scope. If it's a known program name, it exists + // but isn't reachable from this point — strict error. + if (allNamesRef.Contains(name)) + { + AddVisibilityError(vref, name, runtoTargetRef); + } + // If not in allNames either, the main scope checker will + // (or should) flag it as unknown — we don't double-report. + } + }); + } + + foreach (var stmt in test.statements) + { + VisitStatement(stmt); + } + } + } +} diff --git a/FadeBasic/FadeBasic/Errors.cs b/FadeBasic/FadeBasic/Errors.cs index 0782ca6..db8741f 100644 --- a/FadeBasic/FadeBasic/Errors.cs +++ b/FadeBasic/FadeBasic/Errors.cs @@ -217,6 +217,24 @@ public static class ErrorCodes public static readonly ErrorCode AbstractRequiresTest = "[0168] Abstract keyword must be followed by test"; public static readonly ErrorCode TestFromMissingParent = "[0169] Test from clause must specify a parent test name"; public static readonly ErrorCode TestNameAlreadyDeclared = "[0170] Test with this name is already declared"; + public static readonly ErrorCode RuntoMissingLabel = "[0171] Runto statement missing label"; + public static readonly ErrorCode RuntoMissingEndRunto = "[0172] Runto block missing EndRunto clause"; + public static readonly ErrorCode RuntoOutsideTest = "[0173] Runto can only be used inside a test block"; + public static readonly ErrorCode RuntoUnknownLabel = "[0174] Runto target label is not defined anywhere in the program"; + public static readonly ErrorCode RuntoMaxCyclesMissingValue = "[0175] max cycles clause requires an integer expression"; + public static readonly ErrorCode AssertOutsideTest = "[0176] assert can only be used inside a test block"; + public static readonly ErrorCode AssertMissingExpression = "[0177] assert requires a boolean expression"; + public static readonly ErrorCode TestVariableNotYetDeclared = "[0178] Variable is declared in the program but not yet at the most recent runto target"; + public static readonly ErrorCode TestVariableUnreachable = "[0179] Variable is not visible from the test at this point — no runto has reached its declaration"; + public static readonly ErrorCode MockMissingCommandName = "[0180] mock requires a command name"; + public static readonly ErrorCode MockUnknownCommand = "[0181] mock target is not a known command"; + public static readonly ErrorCode MockMissingEndMock = "[0182] mock block missing endmock clause"; + public static readonly ErrorCode MockEntryRequiresReturnsOrForbid = "[0183] mock entry must be `returns ` or `forbid`"; + public static readonly ErrorCode MockUnreachableEntry = "[0184] mock entry is unreachable; a previous entry has frequency `always`"; + public static readonly ErrorCode MockNTimesRequiresCount = "[0185] mock frequency `times` requires an integer count expression"; + public static readonly ErrorCode MockOutsideTest = "[0186] mock can only be used inside a test block"; + public static readonly ErrorCode ClearMockMissingTarget = "[0187] clear must be followed by `mock ` or `mocks`"; + public static readonly ErrorCode ClearMockOutsideTest = "[0188] clear mock(s) can only be used inside a test block"; // 200 series represents post-parse issues public static readonly ErrorCode InvalidReference = "[0200] Invalid reference"; @@ -240,6 +258,7 @@ public static class ErrorCodes public static readonly ErrorCode ArrayCannotAssignFromDefault = "[0218] Cannot assign default to array"; public static readonly ErrorCode TokenizationContainsHaunted = "[0219] Tokenization cannot be resolved without running the program "; public static readonly ErrorCode VariableUsesHaunted = "[0220] Variable cannot include tokens generated from a non deterministic macro"; + public static readonly ErrorCode CannotCallTestFunctionFromOutsideTest = "[0221] Cannot invoke function declared in test"; // 300 series represents type issues public static readonly ErrorCode SymbolAlreadyDeclared = "[0300] Symbol already declared"; diff --git a/FadeBasic/FadeBasic/FadeBasic.csproj b/FadeBasic/FadeBasic/FadeBasic.csproj index 9ba2fb6..ccf0a93 100644 --- a/FadeBasic/FadeBasic/FadeBasic.csproj +++ b/FadeBasic/FadeBasic/FadeBasic.csproj @@ -14,12 +14,16 @@ - + + diff --git a/FadeBasic/FadeBasic/Launch/DebugSession.cs b/FadeBasic/FadeBasic/Launch/DebugSession.cs index 9830c11..43a797c 100644 --- a/FadeBasic/FadeBasic/Launch/DebugSession.cs +++ b/FadeBasic/FadeBasic/Launch/DebugSession.cs @@ -56,6 +56,17 @@ enum State protected bool requestedExit; protected bool started; + + /// + /// When true, reaching end-of-program does not automatically fire + /// REV_REQUEST_EXITED to the connected debug client. Hosts that + /// drive the VM through multiple programs in one process (e.g. a test + /// runner that s between tests, or any consumer + /// that wants to keep the debugger session alive across program + /// completions) should set this. An explicit + /// or socket close still terminates the session normally. + /// + public bool suppressExitOnProgramEnd; protected Task _serverTask; protected Task _processingTask; @@ -182,21 +193,74 @@ public DebugSession(VirtualMachine vm, DebugData dbg, CommandCollection commandC public void Restart(VirtualMachine nextVm, DebugData nextDebugData, CommandCollection commandCollection) { - // put this as a message so that the read-loop causes an interupt in the running VM. - // otherwise, the existing VM will get stuck in a read-loop. - receivedMessages.Enqueue(new MockResetMessage() - { - type = DebugMessageType.REV_REQUEST_RESTART, - nextDebugData = nextDebugData, - nextMachine = nextVm, - nextCommands = commandCollection - }); - - // flip some state so the program does not run, until hello is received. + // Suspend the current VM first so anything currently executing it + // (on this thread or otherwise) drops out before we swap. Restart() + // is expected to be called from the same thread that drives the VM + // (via StartDebugging), so by the time we return the swap is in + // effect for the caller's next tick. + _vm?.Suspend(); + + // Swap synchronously. The previous design enqueued a + // MockResetMessage and let the read-loop perform the swap when + // ReadMessage was next called from inside StartDebugging. That + // failed when the prior VM had reached end-of-program: the inner + // exec loop's `instructionIndex < program.Length` guard short- + // circuited, ReadMessage was never invoked, and the swap message + // sat in the queue forever — leaving _vm pointing at the dead VM + // while the caller's _vm field already pointed at the new one. + ApplyRestart(nextVm, nextDebugData, commandCollection); + } + + // Shared swap path used by Restart() and the (now-defensive) inbound + // REV_REQUEST_RESTART message handler. Mutates _vm and the surrounding + // state on the calling thread; safe because the only writers of these + // fields are the message-processing loop and Restart(), both invoked + // from the same VM-driving thread. + private void ApplyRestart(VirtualMachine nextVm, DebugData nextDebugData, CommandCollection nextCommands) + { + _vm = nextVm; + _vm.shouldThrowRuntimeException = false; + _vm.logger = logger; + + _commandCollection = nextCommands; + _dbg = nextDebugData; + + instructionMap = new IndexCollection(_dbg.statementTokens); + variableDb = new DebugVariableDatabase(_vm, _dbg, logger); + + logger.Log("RESTARTING debug session... version=" + typeof(DebugSession).Assembly.GetName().Version); + foreach (var token in _dbg.statementTokens) + { + var json = JsonableExtensions.Jsonify(token); + logger.Log(json); + } + + // reset state variables + pauseRequestedByMessageId = 0; + resumeRequestedByMessageId = 0; + currentInsLookupOffset = 0; + stepNextMessage = null; + stepIntoMessage = null; + stepOutMessage = null; + stepStackDepth = 0; + stepOverFromToken = null; + stepInFromToken = null; + stepOutFromToken = null; + breakpointTokens.Clear(); + hitBreakpointToken = null; + + // Gate the new VM behind a fresh PROTO_HELLO from any connected + // debugger so a mid-step client doesn't continue executing against + // stale program state. debuggerSaidHello = 0; debuggerReset = 1; - - _vm.Suspend(); + + // tell the DAP Host that we are planning to reboot! + outboundMessages.Enqueue(new DebugMessage() + { + id = GetNextMessageId(), + type = DebugMessageType.REV_REQUEST_RESTART + }); } public void StartServer() @@ -219,13 +283,20 @@ public void StartServer() public void ShutdownServer() { - logger.Log("Starting server shutdown..."); + // No-op when the server was never started (e.g., a DebugSession + // constructed for in-process debugging without a network listener, + // or a test host that creates the session but skips StartServer). + // Without this guard _cts is null and the Cancel() below NREs on + // dispose paths. + if (!started) return; + + logger?.Log("Starting server shutdown..."); while (didClientConnect && outboundMessages.Count > 0) { Thread.Sleep(10); // wait for messages to go away... } - logger.Log("Messages done..."); - _cts.Cancel(); + logger?.Log("Messages done..."); + _cts?.Cancel(); } protected bool didClientConnect = false; @@ -316,7 +387,15 @@ protected void SendRuntimeErrorMessage(string message) }); } - protected void SendExitedMessage() + /// + /// Tell the connected debug client that this session is exiting. Use + /// this from a host that has set + /// (so the auto-fire at end-of-program is disabled) and needs to + /// emit the EXITED event explicitly when its overall lifetime ends — + /// e.g., a test runner after the last test, or any consumer that + /// drives multiple programs in one debug session. + /// + public void SendExitedMessage() { logger?.Debug("Sending exit message"); var message = new DebugMessage() @@ -338,48 +417,15 @@ protected void ReadMessage() switch (message.type) { case DebugMessageType.REV_REQUEST_RESTART: - - var mock = message as MockResetMessage; - _vm = mock.nextMachine; - _vm.shouldThrowRuntimeException = false; - _vm.logger = logger; - - _commandCollection = mock.nextCommands; - - _dbg = mock.nextDebugData; - - instructionMap = new IndexCollection(_dbg.statementTokens); - variableDb = new DebugVariableDatabase(_vm, _dbg, logger); - - logger.Log("RESTARTING debug session... version=" + typeof(DebugSession).Assembly.GetName().Version); - foreach (var token in _dbg.statementTokens) + // Defensive: Restart() now swaps synchronously and + // does not enqueue this message, so this case is + // unreachable from internal callers. Kept for any + // external code that drives the message bus + // directly. + if (message is MockResetMessage mock) { - var json = JsonableExtensions.Jsonify(token); - logger.Log(json); + ApplyRestart(mock.nextMachine, mock.nextDebugData, mock.nextCommands); } - - // reset state variables - - pauseRequestedByMessageId = 0; - resumeRequestedByMessageId = 0; - currentInsLookupOffset = 0; - stepNextMessage = null; - stepIntoMessage = null; - stepOutMessage = null; - stepStackDepth = 0; - stepOverFromToken = null; - stepInFromToken = null; - stepOutFromToken = null; - breakpointTokens.Clear(); - hitBreakpointToken = null; - - // tell the DAP Host that we are planning to reboot! - outboundMessages.Enqueue(new DebugMessage() - { - id = GetNextMessageId(), - type = DebugMessageType.REV_REQUEST_RESTART - }); - break; case DebugMessageType.PROTO_HELLO: hasConnectedDebugger = 1; @@ -2176,7 +2222,13 @@ public virtual void StartDebugging(int ops = 0) logger?.Debug("done with debug loop"); - if (_vm.instructionIndex >= _vm.program.Length || requestedExit) + // Always honor an explicit requestedExit. End-of-program only + // fires EXITED when the host hasn't opted into managing program + // lifetime via suppressExitOnProgramEnd — without that opt-out, + // a test runner that swaps VMs between tests would tell the + // debugger "process exited" mid-session and lose the connection. + if (requestedExit || + (_vm.instructionIndex >= _vm.program.Length && !suppressExitOnProgramEnd)) { SendExitedMessage(); } diff --git a/FadeBasic/FadeBasic/Launch/ITestLaunchable.cs b/FadeBasic/FadeBasic/Launch/ITestLaunchable.cs new file mode 100644 index 0000000..8772cc5 --- /dev/null +++ b/FadeBasic/FadeBasic/Launch/ITestLaunchable.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using FadeBasic.Virtual; + +namespace FadeBasic.Launch +{ + /// + /// An that also carries a discovered test + /// manifest. The console-app launcher inspects this when handling + /// --fade-test=name / --fade-list-tests arguments. + /// Implementations include and + /// the generated launchable class baked into compiled console apps. + /// + public interface ITestLaunchable : ILaunchable + { + IReadOnlyList TestManifest { get; } + } +} diff --git a/FadeBasic/FadeBasic/Launch/LaunchUtil.cs b/FadeBasic/FadeBasic/Launch/LaunchUtil.cs index 49dec16..566c371 100644 --- a/FadeBasic/FadeBasic/Launch/LaunchUtil.cs +++ b/FadeBasic/FadeBasic/Launch/LaunchUtil.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Sockets; @@ -6,6 +7,7 @@ using System.Xml; using System.Xml.Linq; using FadeBasic.Json; +using FadeBasic.Sdk; using FadeBasic.Virtual; namespace FadeBasic.Launch @@ -35,6 +37,63 @@ public static DebugData UnpackDebugData(string base64Json) var json = Encoding.UTF8.GetString(bytes); return JsonableExtensions.FromJson(json); } + + public static string PackTestManifest(IReadOnlyList manifest) + { + var wrapper = new TestManifest(); + foreach (var entry in manifest) wrapper.entries.Add(entry); + var json = wrapper.Jsonify(); + var bytes = Encoding.UTF8.GetBytes(json); + return Convert.ToBase64String(bytes); + } + + public static IReadOnlyList UnpackTestManifest(string base64Json) + { + if (string.IsNullOrEmpty(base64Json)) return new List(); + var bytes = Convert.FromBase64String(base64Json); + var json = Encoding.UTF8.GetString(bytes); + var wrapper = JsonableExtensions.FromJson(json); + return wrapper.entries; + } + + /// + /// Resolve each manifest entry's source location through the given + /// , replacing the concatenated-source line/char + /// with in-file coordinates and stamping the originating + /// . Idempotent: an entry + /// whose sourceFilePath is already set is left alone, so calling + /// this twice (e.g., once in the SDK path and again in the build-task + /// generation path) doesn't double-shift line numbers. + /// + /// + /// Multi-.fbasic projects depend on this — the IDE Test Explorer + /// uses each entry's sourceFilePath to source-link the right + /// file when the user double-clicks a test. Without this remap, the + /// adapter has no way to associate a manifest entry with its origin. + /// + public static void ApplySourceMap(IReadOnlyList manifest, SourceMap map) + { + if (manifest == null || map == null) return; + foreach (var entry in manifest) + { + if (entry == null) continue; + if (!string.IsNullOrEmpty(entry.sourceFilePath)) continue; + try + { + var loc = map.GetOriginalLocation(entry.sourceLine, entry.sourceChar); + entry.sourceFilePath = loc.fileName; + entry.sourceLine = loc.startLine; + entry.sourceChar = loc.startChar; + } + catch + { + // SourceMap throws when the line falls outside any registered + // file (synthetic/computed positions). Leave the entry as-is + // — its sourceFilePath stays empty and the adapter falls + // back to omitting CodeFilePath rather than guessing. + } + } + } public static byte[] Unpack64(string encoded) { diff --git a/FadeBasic/FadeBasic/Launch/Launcher.cs b/FadeBasic/FadeBasic/Launch/Launcher.cs index 17738ad..7ea01cf 100644 --- a/FadeBasic/FadeBasic/Launch/Launcher.cs +++ b/FadeBasic/FadeBasic/Launch/Launcher.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Sockets; using System.Threading; +using FadeBasic.Sdk; using FadeBasic.Virtual; namespace FadeBasic.Launch @@ -62,26 +64,167 @@ public static void Run(T instance, LaunchOptions options=null) where T : ILaunchable { options ??= LaunchOptions.DefaultOptions; - + var vm = new VirtualMachine(instance.Bytecode) { hostMethods = HostMethodTable.FromCommandCollection(instance.CommandCollection) }; - + if (!options.debug) { - vm.Execute2(0); // 0 means run until suspend. + vm.Execute2(0); // 0 means run until suspend. } else { var session = new DebugSession(vm, instance.DebugData, instance.CommandCollection, options); machineToDebugTable.Add(vm, (instance, session)); session.StartServer(); - session.DebugForever(); // needs infinite budget. + session.DebugForever(); // needs infinite budget. session.ShutdownServer(); - + + } + + } + + // Args parsing: recognized command-line forms. + public const string ArgFadeTest = "--fade-test"; + public const string ArgFadeListTests = "--fade-list-tests"; + public const string ArgFadeTestAll = "--fade-test-all"; + + /// + /// Console-app entry point that dispatches between normal program + /// execution and the test runner based on . + /// Returns the process exit code (0 = success, 1 = failure or no tests + /// found, 2 = unsupported launchable). + /// Recognized flags: + /// + /// --fade-test=name — run a single test, exit 0/1 on pass/fail. + /// --fade-test-all — run all tests, exit 0/1 on all-pass / any-fail. + /// --fade-list-tests — print test names (one per line) and exit. + /// + /// With no recognized flag, falls through to normal program execution. + /// + public static int Main(string[] args, LaunchOptions options=null) + where T : ILaunchable, new() + { + return Main(new T(), args, options); + } + + public static int Main(T instance, string[] args, LaunchOptions options=null) + where T : ILaunchable + { + if (args != null && args.Length > 0) + { + if (TryDispatchTestArgs(instance, args, out var exitCode)) + { + return exitCode; + } + } + Run(instance, options); + return 0; + } + + // Returns true if args contain a recognized test-runner flag, in which + // case `exitCode` is set. Returns false if no test flag matched (caller + // should fall through to normal program execution). + public static bool TryDispatchTestArgs(ILaunchable instance, string[] args, out int exitCode) + { + exitCode = 0; + string testName = null; + var testAll = false; + var listTests = false; + + for (var i = 0; i < args.Length; i++) + { + var a = args[i]; + if (a == ArgFadeListTests) { listTests = true; continue; } + if (a == ArgFadeTestAll) { testAll = true; continue; } + if (a == ArgFadeTest && i + 1 < args.Length) + { + testName = args[++i]; + continue; + } + if (a.StartsWith(ArgFadeTest + "=")) + { + testName = a.Substring(ArgFadeTest.Length + 1); + continue; + } + } + + if (!listTests && !testAll && testName == null) return false; + + if (!(instance is ITestLaunchable testInstance)) + { + Console.Error.WriteLine( + "fade: this program does not expose a test manifest " + + "(implement ITestLaunchable to enable --fade-test)."); + exitCode = 2; + return true; + } + + var hostMethods = HostMethodTable.FromCommandCollection(testInstance.CommandCollection); + + if (listTests) + { + foreach (var t in testInstance.TestManifest) + { + if (t.isAbstract) continue; + Console.WriteLine(t.name); + } + exitCode = 0; + return true; + } + + if (testAll) + { + exitCode = RunManyAndReport(testInstance.TestManifest, testInstance.Bytecode, hostMethods); + return true; + } + + // Single test by name. + var match = testInstance.TestManifest + .FirstOrDefault(t => string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)); + if (match == null) + { + Console.Error.WriteLine($"fade: no test named `{testName}` was found."); + exitCode = 1; + return true; + } + var result = FadeTestExecutor.RunTest(testInstance.Bytecode, hostMethods, match); + ReportResult(result); + exitCode = result.passed ? 0 : 1; + return true; + } + + static int RunManyAndReport(IReadOnlyList manifest, byte[] bytecode, HostMethodTable hostMethods) + { + var passed = 0; + var failed = 0; + foreach (var entry in manifest) + { + if (entry.isAbstract) continue; + var r = FadeTestExecutor.RunTest(bytecode, hostMethods, entry); + ReportResult(r); + if (r.passed) passed++; else failed++; + } + Console.WriteLine($"fade: {passed} passed, {failed} failed."); + return failed == 0 && (passed + failed) > 0 ? 0 : 1; + } + + static void ReportResult(FadeTestResult r) + { + if (r.passed) + { + Console.WriteLine($" PASS {r.testName} ({r.duration.TotalMilliseconds:F1} ms)"); + } + else + { + Console.WriteLine($" FAIL {r.testName} ({r.duration.TotalMilliseconds:F1} ms)"); + if (!string.IsNullOrEmpty(r.failureMessage)) + { + Console.WriteLine(" " + r.failureMessage); + } } - } } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Lexer.cs b/FadeBasic/FadeBasic/Lexer.cs index 3354f88..a5849a6 100644 --- a/FadeBasic/FadeBasic/Lexer.cs +++ b/FadeBasic/FadeBasic/Lexer.cs @@ -70,6 +70,19 @@ public enum LexemType KeywordEndTest, KeywordAbstract, KeywordFrom, + KeywordRunto, + KeywordEndRunto, + KeywordMaxCycles, + KeywordAssert, + KeywordMock, + KeywordEndMock, + KeywordReturns, + KeywordForbid, + KeywordOnce, + KeywordTimes, + KeywordAlways, + KeywordClear, + KeywordMocks, KeywordAs, KeywordTypeInteger, @@ -241,7 +254,23 @@ public class Lexer new Lexem(LexemType.KeywordTest, new Regex("^test\\b")), new Lexem(LexemType.KeywordAbstract, new Regex("^abstract\\b")), new Lexem(LexemType.KeywordFrom, new Regex("^from\\b")), - + new Lexem(LexemType.KeywordEndRunto, new Regex("^endrunto\\b")), + new Lexem(LexemType.KeywordRunto, new Regex("^runto\\b")), + // Multi-word keyword: `max cycles`. Matches one or more spaces/tabs + // between the two words; ranks higher (more specific) than VariableGeneral. + new Lexem(-2, LexemType.KeywordMaxCycles, new Regex("^max[ \\t]+cycles\\b")), + new Lexem(LexemType.KeywordAssert, new Regex("^assert\\b")), + + new Lexem(LexemType.KeywordEndMock, new Regex("^endmock\\b")), + new Lexem(LexemType.KeywordMocks, new Regex("^mocks\\b")), + new Lexem(LexemType.KeywordMock, new Regex("^mock\\b")), + new Lexem(LexemType.KeywordReturns, new Regex("^returns\\b")), + new Lexem(LexemType.KeywordForbid, new Regex("^forbid\\b")), + new Lexem(LexemType.KeywordOnce, new Regex("^once\\b")), + new Lexem(LexemType.KeywordTimes, new Regex("^times\\b")), + new Lexem(LexemType.KeywordAlways, new Regex("^always\\b")), + new Lexem(LexemType.KeywordClear, new Regex("^clear\\b")), + new Lexem(LexemType.KeywordGoto, new Regex("^goto")), new Lexem(LexemType.KeywordGoSub, new Regex("^gosub")), new Lexem(LexemType.KeywordReturn, new Regex("^return")), @@ -1811,6 +1840,27 @@ public void Restore(int index) Current = _tokens[index]; } + /// + /// Returns a single-line source-text reconstruction of tokens in the range + /// [startInclusive, endExclusive). Tokens are joined by single spaces, which + /// loses exact original whitespace but produces readable output for things + /// like assertion failure messages. + /// + public string GetSourceText(int startInclusive, int endExclusive) + { + if (startInclusive >= endExclusive) return ""; + if (startInclusive < 0) startInclusive = 0; + if (endExclusive > _tokens.Count) endExclusive = _tokens.Count; + var sb = new StringBuilder(); + for (var i = startInclusive; i < endExclusive; i++) + { + if (i > startInclusive) sb.Append(' '); + var raw = _tokens[i].raw ?? _tokens[i].caseInsensitiveRaw ?? ""; + sb.Append(raw); + } + return sb.ToString(); + } + public List CreatePatchToken(LexemType type, string s, int offset=0) { var copyToken = _tokens[Math.Min(_tokens.Count - 1, Index + offset)]; diff --git a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs index b6a434d..716e992 100644 --- a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs +++ b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs @@ -110,6 +110,22 @@ static PortableSemanticTokenType ClassifyLexemType(Token token) case LexemType.KeywordExit: case LexemType.KeywordDefer: case LexemType.KeywordEndDefer: + case LexemType.KeywordTest: + case LexemType.KeywordEndTest: + case LexemType.KeywordAbstract: + case LexemType.KeywordFrom: + case LexemType.KeywordRunto: + case LexemType.KeywordEndRunto: + case LexemType.KeywordAssert: + case LexemType.KeywordMock: + case LexemType.KeywordEndMock: + case LexemType.KeywordMocks: + case LexemType.KeywordReturns: + case LexemType.KeywordForbid: + case LexemType.KeywordOnce: + case LexemType.KeywordTimes: + case LexemType.KeywordAlways: + case LexemType.KeywordClear: return PortableSemanticTokenType.Keyword; case LexemType.KeywordType: diff --git a/FadeBasic/FadeBasic/Parser.cs b/FadeBasic/FadeBasic/Parser.cs index 689c49b..4958748 100644 --- a/FadeBasic/FadeBasic/Parser.cs +++ b/FadeBasic/FadeBasic/Parser.cs @@ -75,6 +75,7 @@ public class Scope public Stack localVariables = new Stack(); public TokenTable<(SymbolTable, string)> positionedVariables = new TokenTable<(SymbolTable, string)>(); public Stack currentFunctionName = new Stack(); + public Stack currentRegionName = new Stack(); public Dictionary functionSymbolTable = new Dictionary(); public Dictionary functionTable = new Dictionary(); public Dictionary> functionReturnTypeTable = new Dictionary>(); @@ -82,7 +83,10 @@ public class Scope List delayedTypeChecks = new List(); private int allowExitCounter; - + private int testDepth; + + public bool IsInsideTest => testDepth > 0; + public Scope() { localVariables.Push(new SymbolTable()); @@ -163,6 +167,65 @@ public void EndFunction() currentFunctionName.Pop(); } + public bool BeginTest(TestNode test) + { + // A test gets its own local symbol table — same shape as a function's. + // We push the test name onto currentFunctionName so the label-scope + // check (TraverseLabelBetweenScopes) fires when a test tries to + // goto/gosub a label that belongs to a different namespace, and so + // labels declared inside this test resolve only within it. + // + // Seed the test's local table with the names visible from main-body + // scope so the basic ScopeErrorVisitor doesn't flag program-scope + // names referenced from inside the test as "unknown symbol". The + // TestScopeStrictnessVisitor enforces the runto-based visibility + // lens on top of this seed. + var table = new SymbolTable(); + if (localVariables.Count > 0) + { + foreach (var kvp in localVariables.Peek()) + { + table[kvp.Key] = kvp.Value; + } + } + + if (currentRegionName.Count > 1) throw new InvalidOperationException("Cannot handle multi-region parsing"); + currentRegionName.Push(test.nameToken.raw); + localVariables.Push(table); + positionedVariables.Add(new TokenTable<(SymbolTable, string)>.Entry(test, (table, test.name))); + currentFunctionName.Push(test.name); + testDepth++; + + // Register the test's functions in the scope so calls from inside the + // test resolve. Functions are unregistered in EndTest so they aren't + // visible from main program code or sibling tests. + foreach (var function in test.functions) + { + if (!functionTable.ContainsKey(function.name)) + { + functionTable.Add(function.name, function); + } + } + return true; + } + + public void EndTest(TestNode test) + { + localVariables.Pop(); + currentFunctionName.Pop(); + currentRegionName.Pop(); + testDepth--; + // Unregister test-scoped functions so other tests / program code can't see them. + foreach (var function in test.functions) + { + if (functionTable.TryGetValue(function.name, out var registered) + && ReferenceEquals(registered, function)) + { + functionTable.Remove(function.name); + } + } + } + public void BeginLoop() => allowExitCounter++; public void EndLoop() => allowExitCounter--; public bool AllowExits => allowExitCounter > 0; @@ -1624,6 +1687,14 @@ IStatementNode Inner() return ParseTest(token, isAbstract: false); case LexemType.KeywordAbstract: return ParseAbstractTest(token); + case LexemType.KeywordRunto: + return ParseRunto(token); + case LexemType.KeywordAssert: + return ParseAssert(token); + case LexemType.KeywordMock: + return ParseMock(token); + case LexemType.KeywordClear: + return ParseClearMock(token); case LexemType.KeywordEnd: return new EndProgramStatement(token); case LexemType.KeywordExit: @@ -2499,8 +2570,9 @@ private TestNode ParseTest(Token testToken, bool isAbstract, Token abstractToken { var fnToken = _stream.Advance(); var fn = ParseFunction(fnToken); + fn.region = nameToken.raw; functions.Add(fn); - statements.Add(fn); + // statements.Add(fn); break; } @@ -2524,7 +2596,7 @@ private TestNode ParseTest(Token testToken, bool isAbstract, Token abstractToken return new TestNode { Errors = errors, - name = nameToken.caseInsensitiveRaw, + name = nameToken.raw, nameToken = nameToken, isAbstract = isAbstract, fromParent = fromParent, @@ -3192,7 +3264,7 @@ private GotoStatement ParseGoto(Token gotoToken) { case LexemType.VariableGeneral: return new GotoStatement(gotoToken, next); - + default: var patchToken = _stream.CreatePatchToken(LexemType.VariableGeneral, "_")[0]; var statement = new GotoStatement(gotoToken, patchToken); @@ -3200,6 +3272,436 @@ private GotoStatement ParseGoto(Token gotoToken) return statement; } } + + private AssertStatement ParseAssert(Token assertToken) + { + // Capture source text by snapshotting stream indices around the + // expression parse, then slicing the consumed tokens. + var startIdx = _stream.Save(); + if (!TryParseExpression(out var expr)) + { + var stmt = new AssertStatement(assertToken, assertToken, null, ""); + stmt.Errors.Add(new ParseError(assertToken, ErrorCodes.AssertMissingExpression)); + return stmt; + } + var endIdx = _stream.Save(); + var sourceText = _stream.GetSourceText(startIdx, endIdx); + + return new AssertStatement(assertToken, expr.EndToken, expr, sourceText); + } + + private RuntoStatement ParseRunto(Token runtoToken) + { + // Forms: + // inline: `runto labelName` + // inline-stack: `runto labelName max cycles N` (clauses on same line) + // block: `runto labelName \n max cycles N \n endrunto` + // Clauses on the same line (separated by `:` or whitespace) bind to + // the runto without requiring `endrunto`. If the user crosses a newline + // and continues with another clause or writes `endrunto`, that's the + // block form and must close with `endrunto`. + var labelToken = _stream.Peek; + if (labelToken.type != LexemType.VariableGeneral) + { + var patchToken = _stream.CreatePatchToken(LexemType.VariableGeneral, "_")[0]; + var stmt = new RuntoStatement(runtoToken, runtoToken, patchToken); + stmt.Errors.Add(new ParseError(runtoToken, ErrorCodes.RuntoMissingLabel)); + return stmt; + } + _stream.Advance(); // consume label + + var statement = new RuntoStatement(runtoToken, labelToken, labelToken); + Token endToken = labelToken; + + // Phase 1: parse clauses on the same line. Colon separators (`:`) + // between the label and clauses, and between clauses, are honored; + // a real newline ends the inline form. + while (IsColonEndStatement(_stream.Peek)) _stream.Advance(); + while (IsRuntoClauseStart(_stream.Peek.type)) + { + ParseRuntoClause(statement, ref endToken); + while (IsColonEndStatement(_stream.Peek)) _stream.Advance(); + } + + // Phase 2: detect block form. If the next non-EndStatement token is + // a clause or `endrunto`, this is the multi-line block form and must + // be closed with `endrunto`. + var lookAhead = PeekPastEndStatements(); + bool hasBlockBody = lookAhead.type == LexemType.KeywordEndRunto + || IsRuntoClauseStart(lookAhead.type); + + if (!hasBlockBody) + { + statement.endToken = endToken; + return statement; + } + + // Consume the EndStatements before clauses. + while (_stream.Peek.type == LexemType.EndStatement) _stream.Advance(); + + // Parse clauses until endrunto or a hard boundary (EOF / endtest). + var looking = true; + while (looking) + { + var next = _stream.Peek; + switch (next.type) + { + case LexemType.EOF: + case LexemType.KeywordEndTest: + case LexemType.KeywordTest: + case LexemType.KeywordAbstract: + statement.Errors.Add(new ParseError(runtoToken, ErrorCodes.RuntoMissingEndRunto)); + looking = false; + break; + + case LexemType.EndStatement: + _stream.Advance(); + break; + + case LexemType.KeywordEndRunto: + endToken = _stream.Advance(); + looking = false; + break; + + default: + if (IsRuntoClauseStart(next.type)) + { + ParseRuntoClause(statement, ref endToken); + } + else + { + // Unknown clause; skip token to avoid infinite loop. + _stream.Advance(); + } + break; + } + } + + statement.endToken = endToken; + return statement; + } + + private static bool IsRuntoClauseStart(LexemType type) + { + return type == LexemType.KeywordMaxCycles; + } + + private void ParseRuntoClause(RuntoStatement statement, ref Token endToken) + { + var head = _stream.Peek; + switch (head.type) + { + case LexemType.KeywordMaxCycles: + { + _stream.Advance(); // consume `max cycles` + if (TryParseExpression(out var cyclesExpr)) + { + statement.maxCyclesExpression = cyclesExpr; + endToken = cyclesExpr.EndToken; + } + else + { + statement.Errors.Add(new ParseError(head, ErrorCodes.RuntoMaxCyclesMissingValue)); + } + break; + } + default: + // Caller is expected to gate on IsRuntoClauseStart. + _stream.Advance(); + break; + } + } + + private MockStatement ParseMock(Token mockToken) + { + // Forms: + // inline: `mock returns []` + // `mock forbid []` + // inline-stack: `mock returns 1 once: returns 2 once` + // (multiple entries on the same line, no endmock) + // block: `mock \n returns \n ... \n endmock` + // bare: `mock ` alone — void intercept + // + // The lexer's command-name pass merges multi-word command names into a + // single CommandWord token, so we can grab the next token as the name. + var stmt = new MockStatement(mockToken, mockToken); + + var nameToken = _stream.Peek; + if (nameToken.type == LexemType.CommandWord) + { + _stream.Advance(); + stmt.commandName = nameToken.caseInsensitiveRaw; + stmt.commandNameToken = nameToken; + stmt.endToken = nameToken; + } + else + { + stmt.Errors.Add(new ParseError(mockToken, ErrorCodes.MockMissingCommandName)); + return stmt; + } + + var hadEnd = _stream.Peek.type == LexemType.EndStatement; + + // Phase 1: parse inline entries on the same line. After the first + // entry, additional `returns`/`forbid` clauses can stack via the + // colon separator (DEFER-style) without requiring `endmock`. The + // distinction between `:` and a real newline is preserved in + // `Token.raw` (newline-synthesized EndStatements have raw != ":"). + if (!hadEnd && IsMockEntryStart(_stream.Peek.type)) + { + while (true) + { + if (!IsMockEntryStart(_stream.Peek.type)) break; + var entry = ParseMockEntry(); + if (entry != null) + { + stmt.entries.Add(entry); + stmt.endToken = entry.EndToken; + } + + // Consume colon-separators between entries, but stop at a + // newline (which switches to block form). + while (IsColonEndStatement(_stream.Peek)) _stream.Advance(); + } + + // After same-line entries, check whether more entries (or `endmock`) + // appear past a newline — that promotes this into block form. + var afterInline = PeekPastEndStatements(); + if (afterInline.type != LexemType.KeywordEndMock + && !IsMockEntryStart(afterInline.type)) + { + return stmt; + } + // fall through to block-form loop + while (_stream.Peek.type == LexemType.EndStatement) _stream.Advance(); + ParseMockBlockBody(stmt, mockToken); + return stmt; + } + + // Bare-form: after the command name + newline, the next non-EndStatement + // token isn't a recognized entry start or `endmock`. Synthesize a + // single Void entry and return without consuming further tokens. + var lookAhead = PeekPastEndStatements(); + if (hadEnd + && !IsMockEntryStart(lookAhead.type) + && lookAhead.type != LexemType.KeywordEndMock) + { + var voidEntry = new MockEntry(nameToken, nameToken) + { + kind = MockEntryKind.Void, + frequency = MockFrequencyKind.Always + }; + stmt.entries.Add(voidEntry); + return stmt; + } + + // Block form: consume EndStatements before entries. + while (_stream.Peek.type == LexemType.EndStatement) _stream.Advance(); + ParseMockBlockBody(stmt, mockToken); + return stmt; + } + + private static bool IsMockEntryStart(LexemType type) + { + return type == LexemType.KeywordReturns || type == LexemType.KeywordForbid; + } + + // True when the token is a colon-induced EndStatement (same line) rather + // than a newline-induced one. The lexer synthesizes newline EndStatements + // with empty/null `raw`; colon ones carry `raw = ":"`. + private static bool IsColonEndStatement(Token token) + { + return token != null + && token.type == LexemType.EndStatement + && token.raw == ":"; + } + + private void ParseMockBlockBody(MockStatement stmt, Token mockToken) + { + // Block-form mock body: zero or more entries terminated by `endmock`. + // `endtest`, `test`, `abstract`, and EOF are hard boundaries that + // emit `MockMissingEndMock` without consuming the boundary token — + // this lets the surrounding test-body parser still see its `endtest`. + var looking = true; + while (looking) + { + var next = _stream.Peek; + switch (next.type) + { + case LexemType.EOF: + case LexemType.KeywordEndTest: + case LexemType.KeywordTest: + case LexemType.KeywordAbstract: + stmt.Errors.Add(new ParseError(mockToken, ErrorCodes.MockMissingEndMock)); + looking = false; + break; + + case LexemType.EndStatement: + _stream.Advance(); + break; + + case LexemType.KeywordEndMock: + stmt.endToken = next; + _stream.Advance(); + looking = false; + break; + + case LexemType.KeywordReturns: + case LexemType.KeywordForbid: + { + var entry = ParseMockEntry(); + if (entry != null) stmt.entries.Add(entry); + break; + } + + default: + { + // Unknown token in mock body — flag and skip to recover. + var bad = _stream.Advance(); + stmt.Errors.Add(new ParseError(bad, ErrorCodes.MockEntryRequiresReturnsOrForbid)); + break; + } + } + } + } + + private MockEntry ParseMockEntry() + { + var head = _stream.Peek; + if (head.type == LexemType.KeywordReturns) + { + _stream.Advance(); + var entry = new MockEntry(head, head); + entry.kind = MockEntryKind.Returns; + + if (TryParseExpression(out var returnExpr)) + { + entry.returnExpression = returnExpr; + entry.endToken = returnExpr.EndToken; + } + else + { + entry.Errors.Add(new ParseError(head, ErrorCodes.MockEntryRequiresReturnsOrForbid)); + return entry; + } + + ParseMockFrequency(entry); + return entry; + } + if (head.type == LexemType.KeywordForbid) + { + _stream.Advance(); + var entry = new MockEntry(head, head); + entry.kind = MockEntryKind.Forbid; + ParseMockFrequency(entry); + return entry; + } + + // Caller invariants should prevent this, but be defensive. + var bad = _stream.Advance(); + var entryErr = new MockEntry(bad, bad); + entryErr.Errors.Add(new ParseError(bad, ErrorCodes.MockEntryRequiresReturnsOrForbid)); + return entryErr; + } + + // Optional trailing frequency words: `once`, ` times`, `always`. + // Default frequency (no clause) is `always`. + private void ParseMockFrequency(MockEntry entry) + { + var next = _stream.Peek; + switch (next.type) + { + case LexemType.KeywordOnce: + _stream.Advance(); + entry.frequency = MockFrequencyKind.Once; + entry.endToken = next; + break; + + case LexemType.KeywordAlways: + _stream.Advance(); + entry.frequency = MockFrequencyKind.Always; + entry.endToken = next; + break; + + case LexemType.KeywordTimes: + // `times` here means the count expression came BEFORE — but + // we already consumed `returns ` as the return value, + // so this is malformed. Flag and consume. + _stream.Advance(); + entry.Errors.Add(new ParseError(next, ErrorCodes.MockNTimesRequiresCount)); + break; + + default: + { + // Try to parse ` times`. If the count expression + // succeeds and is followed by `times`, accept it as the + // frequency. Otherwise restore — there is no frequency + // clause, leave it default `always`. + var saved = _stream.Save(); + if (TryParseExpression(out var countExpr) && _stream.Peek.type == LexemType.KeywordTimes) + { + var timesToken = _stream.Advance(); // consume `times` + entry.frequency = MockFrequencyKind.NTimes; + entry.countExpression = countExpr; + entry.endToken = timesToken; + } + else + { + _stream.Restore(saved); + } + break; + } + } + } + + private ClearMockStatement ParseClearMock(Token clearToken) + { + // `clear mock ` or `clear mocks` + var stmt = new ClearMockStatement(clearToken, clearToken); + + var next = _stream.Peek; + if (next.type == LexemType.KeywordMocks) + { + _stream.Advance(); + stmt.commandName = null; // means "clear all" + stmt.endToken = next; + return stmt; + } + if (next.type == LexemType.KeywordMock) + { + _stream.Advance(); + var nameToken = _stream.Peek; + if (nameToken.type == LexemType.CommandWord) + { + _stream.Advance(); + stmt.commandName = nameToken.caseInsensitiveRaw; + stmt.commandNameToken = nameToken; + stmt.endToken = nameToken; + } + else + { + stmt.Errors.Add(new ParseError(clearToken, ErrorCodes.MockMissingCommandName)); + } + return stmt; + } + + stmt.Errors.Add(new ParseError(clearToken, ErrorCodes.ClearMockMissingTarget)); + return stmt; + } + + // Helper: peek past any EndStatement tokens to see what's next, without + // permanently advancing the stream. + private Token PeekPastEndStatements() + { + var saved = _stream.Save(); + while (_stream.Peek.type == LexemType.EndStatement) + { + _stream.Advance(); + } + var result = _stream.Peek; + _stream.Restore(saved); + return result; + } private GoSubStatement ParseGoSub(Token gotoToken) { var next = _stream.Advance(); diff --git a/FadeBasic/FadeBasic/Sdk/Fade.cs b/FadeBasic/FadeBasic/Sdk/Fade.cs index ceffb06..834a579 100644 --- a/FadeBasic/FadeBasic/Sdk/Fade.cs +++ b/FadeBasic/FadeBasic/Sdk/Fade.cs @@ -159,11 +159,12 @@ public string ToDisplay() } } - public class FadeRuntimeContext : ILaunchable + public partial class FadeRuntimeContext : ITestLaunchable { byte[] ILaunchable.Bytecode => Machine.program; CommandCollection ILaunchable.CommandCollection => CommandCollection; DebugData ILaunchable.DebugData => Compiler.DebugData; + IReadOnlyList ITestLaunchable.TestManifest => Compiler.TestManifest; private DebugSession _session; private static Lexer _lexer = new Lexer(); @@ -803,6 +804,12 @@ public static bool TryFromSource(string src, }); compiler.Compile(program); + // Resolve test manifest source locations to per-file paths via the + // source map, so multi-`.fbasic` projects surface the right + // originating file in IDE Test Explorer (Stage 11H). No-op when + // no source map was supplied (single-string SDK callers). + FadeBasic.Launch.LaunchUtil.ApplySourceMap(compiler.TestManifest, map); + var vm = new VirtualMachine(compiler.Program); vm.hostMethods = compiler.methodTable; diff --git a/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs b/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs new file mode 100644 index 0000000..4bea22d --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using FadeBasic.Launch; +using FadeBasic.Virtual; + +namespace FadeBasic.Sdk +{ + public static class FadeTestExecutor + { + // Run a single test entry against pre-built bytecode + host method table. + // Used both by the SDK (FadeRuntimeContext.RunTest) and the console-app + // launcher when handling `--fade-test=name`. Each call gets a fresh VM, + // so test state is isolated. + public static FadeTestResult RunTest( + byte[] bytecode, + HostMethodTable hostMethods, + TestManifestEntry entry) + { + if (entry.isAbstract) + { + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = $"Test `{entry.name}` is abstract and cannot be run directly." + }; + } + + var sw = Stopwatch.StartNew(); + var vm = new VirtualMachine(bytecode, entry.entryPointAddress) + { + hostMethods = hostMethods + }; + try + { + vm.Execute().MoveNext(); + } + catch (Exception ex) + { + sw.Stop(); + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "VM threw: " + ex.Message, + duration = sw.Elapsed + }; + } + sw.Stop(); + + if (vm.assertionFailure != null) + { + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = $"assert failed: {vm.assertionFailure.sourceText}", + failureSourceText = vm.assertionFailure.sourceText, + failureInstructionIndex = vm.assertionFailure.instructionIndex, + duration = sw.Elapsed + }; + } + + return new FadeTestResult + { + testName = entry.name, + passed = true, + duration = sw.Elapsed + }; + } + } + + public class FadeTestResult + { + public string testName; + public bool passed; + // Null when passed. + public string failureMessage; + // Captured assertion text from the failing `assert` (when an assert tripped). + public string failureSourceText; + // IP at the moment of failure; useful for source-mapping when DebugData + // is available. -1 if not applicable. + public int failureInstructionIndex = -1; + public TimeSpan duration; + } + + public class FadeTestRunResult + { + public List tests = new List(); + public int passedCount; + public int failedCount; + public bool AllPassed => failedCount == 0 && tests.Count > 0; + public TimeSpan duration; + } + + public partial class FadeRuntimeContext + { + // Concrete tests (skips abstract fixtures). + public IEnumerable Tests + { + get + { + foreach (var t in Compiler.TestManifest) + { + if (!t.isAbstract) yield return t; + } + } + } + + public FadeTestResult RunTest(string testName) + { + foreach (var t in Compiler.TestManifest) + { + if (string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)) + { + if (t.isAbstract) + { + return new FadeTestResult + { + testName = testName, + passed = false, + failureMessage = $"Test `{testName}` is abstract and cannot be run directly." + }; + } + return RunTest(t); + } + } + return new FadeTestResult + { + testName = testName, + passed = false, + failureMessage = $"No test named `{testName}` was found in the program." + }; + } + + public FadeTestResult RunTest(TestManifestEntry entry) + { + return FadeTestExecutor.RunTest(Machine.program, Compiler.methodTable, entry); + } + + public FadeTestRunResult RunAllTests() + { + var run = new FadeTestRunResult(); + var sw = Stopwatch.StartNew(); + foreach (var t in Compiler.TestManifest) + { + if (t.isAbstract) continue; + var r = RunTest(t); + run.tests.Add(r); + if (r.passed) run.passedCount++; + else run.failedCount++; + } + sw.Stop(); + run.duration = sw.Elapsed; + return run; + } + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/DefaultFadeTestHost.cs b/FadeBasic/FadeBasic/Sdk/Testing/DefaultFadeTestHost.cs new file mode 100644 index 0000000..a84dc74 --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/DefaultFadeTestHost.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using FadeBasic.Sdk; + +namespace FadeBasic.Testing +{ + /// + /// Stateless default host. Each test gets a fresh VirtualMachine + /// via with no host-side reset. + /// Used when the consumer hasn't tagged any class with + /// . + /// + public sealed class DefaultFadeTestHost : IFadeTestHost + { + public Task InitializeAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + + public Task BeforeAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + + public Task RunTestAsync(FadeTestRunContext ctx, CancellationToken ct) + => ctx.RunDefaultAsync(ct); + + public Task AfterAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + + public ValueTask DisposeAsync() => default; + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/FadeManagedIdentifier.cs b/FadeBasic/FadeBasic/Sdk/Testing/FadeManagedIdentifier.cs new file mode 100644 index 0000000..8ad723f --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/FadeManagedIdentifier.cs @@ -0,0 +1,29 @@ +using System.Text; + +namespace FadeBasic.Testing +{ + /// + /// Shared coercion of arbitrary strings (file basenames, assembly names) + /// into C#-shaped identifiers. IDE Test Explorers (Rider, VS Code C# Dev + /// Kit, Visual Studio) parse TestCase.ManagedType as a dotted path + /// of valid identifiers; emitting raw .fbasic basenames with dashes + /// or dots in them produces a broken tree. Both the VSTest adapter and + /// the LSP-based discovery path call into this helper so the tree groups + /// identically across IDEs. + /// + public static class FadeManagedIdentifier + { + public static string ToManagedIdentifier(string raw) + { + if (string.IsNullOrEmpty(raw)) return "Tests"; + var sb = new StringBuilder(raw.Length); + foreach (var c in raw) + { + sb.Append(char.IsLetterOrDigit(c) ? c : '_'); + } + // C# identifiers cannot start with a digit. + if (sb.Length > 0 && char.IsDigit(sb[0])) sb.Insert(0, '_'); + return sb.Length == 0 ? "Tests" : sb.ToString(); + } + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostAttribute.cs b/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostAttribute.cs new file mode 100644 index 0000000..0e21f96 --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace FadeBasic.Testing +{ + /// + /// Tag a class implementing with this attribute + /// to have discover and use it + /// automatically when running under dotnet test. If multiple classes + /// in the entry assembly carry this attribute, the test app will fail with + /// a clear error listing all candidates — exactly one is permitted. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class FadeTestHostAttribute : Attribute + { + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostResolver.cs b/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostResolver.cs new file mode 100644 index 0000000..2a4d3ce --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/FadeTestHostResolver.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace FadeBasic.Testing +{ + /// + /// Locates an implementation for the test app + /// to use. Resolution order: + /// + /// The instance passed into (if non-null). + /// A class in the entry assembly tagged + /// that implements . + /// Fallback to . + /// + /// + public static class FadeTestHostResolver + { + // Test-only seam. Set via OverrideForTests; checked first by Resolve so + // unit tests of the VSTest executor can inject a fake host without + // dragging in the [FadeTestHost] attribute discovery path. + private static IFadeTestHost? _testOverride; + + public static IFadeTestHost Resolve(IFadeTestHost? explicitHost) + { + if (explicitHost != null) return explicitHost; + if (_testOverride != null) return _testOverride; + + var discovered = DiscoverFromAttributes(); + if (discovered != null) return discovered; + + return new DefaultFadeTestHost(); + } + + /// + /// Test seam — install a fake host that returns + /// when no explicit host is passed. Returns an + /// that clears the override on disposal so each test owns its own + /// scope. NOT for production code. + /// + public static IDisposable OverrideForTests(IFadeTestHost host) + { + _testOverride = host; + return new TestOverrideScope(); + } + + private sealed class TestOverrideScope : IDisposable + { + public void Dispose() => _testOverride = null; + } + + public static IFadeTestHost? DiscoverFromAttributes() + { + var entry = Assembly.GetEntryAssembly(); + var candidates = new List(); + + // Try the entry assembly first. Under `dotnet run` this is the + // user's app and usually carries the host, so we keep authoring- + // intent priority over anything transitively referenced. + if (entry != null) CollectHostCandidates(entry, candidates); + + // Fall through to all loaded assemblies when the entry didn't + // contain a [FadeTestHost]. Two cases this catches: + // 1. EntryAssembly is null (some test-host scenarios). + // 2. Under `dotnet test`, EntryAssembly is vstest's testhost.dll; + // the launchable carrying the host is loaded into a separate + // collectible ALC by FadeTestLaunchableLoader. AppDomain + // enumerates assemblies across all ALCs. + if (candidates.Count == 0) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm == entry) continue; + CollectHostCandidates(asm, candidates); + } + } + + if (candidates.Count == 0) return null; + if (candidates.Count > 1) + { + var names = string.Join(", ", candidates.Select(c => c.FullName)); + throw new InvalidOperationException( + $"Multiple [FadeTestHost] classes found: {names}. Exactly one is permitted per test app."); + } + + var hostType = candidates[0]; + try + { + return (IFadeTestHost)Activator.CreateInstance(hostType)!; + } + catch (MissingMethodException) + { + throw new InvalidOperationException( + $"[FadeTestHost] class `{hostType.FullName}` must have a public parameterless constructor."); + } + } + + private static void CollectHostCandidates(Assembly asm, List sink) + { + Type[] types; + try { types = asm.GetTypes(); } + catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray()!; } + + foreach (var t in types) + { + if (t == null) continue; + if (t.IsAbstract || t.IsInterface) continue; + if (!typeof(IFadeTestHost).IsAssignableFrom(t)) continue; + if (t.GetCustomAttribute() == null) continue; + sink.Add(t); + } + } + } +} diff --git a/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs b/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs new file mode 100644 index 0000000..833cdf9 --- /dev/null +++ b/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs @@ -0,0 +1,97 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Virtual; + +namespace FadeBasic.Testing +{ + /// + /// Extensibility point for downstream consumers that want to control the + /// "around" of every Fade test — typically: spin up a Game/graphics-device + /// once, reset host-side state between tests, decide how the VM is driven + /// (synchronous, frame-stepped, debugger-attached). The default implementation + /// () just delegates to + /// . + /// + /// + /// Lifecycle, in order: + /// + /// — once per process, expensive setup. + /// — once per run. + /// For each test: . + /// . + /// . + /// + /// + public interface IFadeTestHost + { + Task InitializeAsync(FadeTestSessionContext ctx, CancellationToken ct); + + Task BeforeAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct); + + /// + /// Run a single test. Implementations typically: + /// (1) reset host-side state, (2) build/reuse a VM at + /// ctx.Entry.entryPointAddress, (3) drive the VM, (4) translate + /// the outcome to a . Hosts that only want + /// to wrap default behavior should call ctx.RunDefaultAsync(ct). + /// + Task RunTestAsync(FadeTestRunContext ctx, CancellationToken ct); + + Task AfterAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct); + + ValueTask DisposeAsync(); + } + + /// + /// Information passed to per-session host hooks. The launchable carries the + /// bytecode + commands; is MTP's service provider + /// (logger, output device, etc.), null when running outside MTP (unit + /// tests of the host). + /// + public sealed class FadeTestSessionContext + { + public ITestLaunchable Launchable { get; } + public IServiceProvider? Services { get; } + + public FadeTestSessionContext(ITestLaunchable launchable, IServiceProvider? services) + { + Launchable = launchable; + Services = services; + } + } + + /// + /// Information passed to per-test host hooks. + /// + public sealed class FadeTestRunContext + { + public ITestLaunchable Launchable { get; } + public TestManifestEntry Entry { get; } + public HostMethodTable HostMethods { get; } + + public FadeTestRunContext(ITestLaunchable launchable, TestManifestEntry entry, HostMethodTable hostMethods) + { + Launchable = launchable; + Entry = entry; + HostMethods = hostMethods; + } + + /// + /// Convenience for hosts that only need to wrap default execution. + /// Returns the same result the would + /// produce for this test. + /// + public Task RunDefaultAsync(CancellationToken ct) + { + // FadeTestExecutor.RunTest is synchronous today. Wrap it; once + // cooperative cancellation lands inside the VM, this becomes an + // actual async call. + ct.ThrowIfCancellationRequested(); + var result = FadeTestExecutor.RunTest(Launchable.Bytecode, HostMethods, Entry); + return Task.FromResult(result); + } + } +} diff --git a/FadeBasic/FadeBasic/TokenFormatter.cs b/FadeBasic/FadeBasic/TokenFormatter.cs index e0af1a7..9084b5b 100644 --- a/FadeBasic/FadeBasic/TokenFormatter.cs +++ b/FadeBasic/FadeBasic/TokenFormatter.cs @@ -93,6 +93,9 @@ enum LexemFlags [LexemType.KeywordSelect] = LexemFlags.PUSH_INDENT, [LexemType.KeywordCase] = LexemFlags.PUSH_INDENT, [LexemType.KeywordCaseDefault] = LexemFlags.PUSH_INDENT, + [LexemType.KeywordTest] = LexemFlags.PUSH_INDENT, + [LexemType.ConstantBegin] = LexemFlags.PUSH_INDENT, + [LexemType.ConstantTokenize] = LexemFlags.PUSH_INDENT, [LexemType.KeywordElse] = LexemFlags.PUSH_AND_POP_INDENT, @@ -105,6 +108,9 @@ enum LexemFlags [LexemType.KeywordUntil] = LexemFlags.POP_INDENT, [LexemType.KeywordEndCase] = LexemFlags.POP_INDENT, [LexemType.KeywordEndSelect] = LexemFlags.POP_INDENT, + [LexemType.KeywordEndTest] = LexemFlags.POP_INDENT, + [LexemType.ConstantEnd] = LexemFlags.POP_INDENT, + [LexemType.ConstantEndTokenize] = LexemFlags.POP_INDENT, // TODO: add other keywords }; diff --git a/FadeBasic/FadeBasic/Virtual/Compiler.cs b/FadeBasic/FadeBasic/Virtual/Compiler.cs index 047dcd8..9aee7dd 100644 --- a/FadeBasic/FadeBasic/Virtual/Compiler.cs +++ b/FadeBasic/FadeBasic/Virtual/Compiler.cs @@ -112,6 +112,52 @@ public struct LabelReplacement public string Label; } + public class TestManifestEntry : IJsonable + { + public string name; + public int entryPointAddress; + public bool isAbstract; + public string fromParent; // null if no parent + + // sourceLine/sourceChar are reported in the ORIGINATING file's coordinate + // space (1-based line numbers as the user sees them). The compiler + // initially stamps these in the concatenated-source space; a post-compile + // pass remaps them via SourceMap when one is available. The originating + // file path goes in ; null/empty means the + // file is unknown (no source map provided), and consumers should treat + // line/char as best-effort positions only. + public int sourceLine; + public int sourceChar; + public string sourceFilePath; + + public void ProcessJson(IJsonOperation op) + { + op.IncludeField(nameof(name), ref name); + op.IncludeField(nameof(entryPointAddress), ref entryPointAddress); + op.IncludeField(nameof(isAbstract), ref isAbstract); + op.IncludeField(nameof(fromParent), ref fromParent); + op.IncludeField(nameof(sourceLine), ref sourceLine); + op.IncludeField(nameof(sourceChar), ref sourceChar); + op.IncludeField(nameof(sourceFilePath), ref sourceFilePath); + } + } + + /// + /// Serializable wrapper around the compiler's test manifest. Used by + /// LaunchUtil.PackTestManifest / UnpackTestManifest to bake + /// the manifest into the generated launchable so console-app builds can + /// support --fade-test=name at runtime. + /// + public class TestManifest : IJsonable + { + public List entries = new List(); + + public void ProcessJson(IJsonOperation op) + { + op.IncludeField(nameof(entries), ref entries); + } + } + public struct FunctionCallReplacement { public int InstructionIndex; @@ -380,6 +426,22 @@ public class Compiler private List _labelReplacements = new List(); private Dictionary _labelToInstructionIndex = new Dictionary(); + // For each `runto label` call site, record where in the bytecode the + // PUSH int placeholder lives so we can patch the resolved post-yield + // address (label_addr + 2) at the end of compilation. + private List _runtoReplacements = new List(); + + // Set of label names that any test references via `runto`. These get a + // RUNTO_YIELD opcode emitted right after the label's NOOP. Labels that + // aren't runto targets carry no overhead. + private HashSet _runtoTargetLabels = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Manifest of compiled tests: name → entry-point address. Recorded as + // each test body is compiled. Surfaced via the public Manifest property + // and (later) emitted into the interned-data section. + private List _testManifest = new List(); + public IReadOnlyList TestManifest => _testManifest; + private List _functionCallReplacements = new List(); private Dictionary _functionTable = new Dictionary(); @@ -438,28 +500,43 @@ public void Compile(ProgramNode program) { _buffer.Add(value[i]); } - + + // Pre-pass: collect every label name referenced by a `runto` anywhere in + // the test corpus. These labels get a RUNTO_YIELD opcode emitted right + // after their NOOP. Labels not referenced get no overhead, and run builds + // with no tests skip this pass entirely (set stays empty). + CollectRuntoTargets(program); + foreach (var typeDef in program.typeDefinitions) { Compile(typeDef); } - + foreach (var statement in program.statements) { - + Compile(statement); } - // prevent the execution from ever going to the functions. GOTO statements _should_ be illegal to jump into a function's scope. + // prevent the execution from ever going to the functions. GOTO statements _should_ be illegal to jump into a function's scope. CompileEnd(); - + foreach (var function in program.functions) { Compile(function); } + // Tests are compiled as additional, runnable bytecode regions after the + // program's functions but before interned data. Each test gets its own + // entry point recorded in the manifest. A test instance is launched via + // `new VirtualMachine(program, manifest.entryPointAddress)`. + foreach (var test in program.tests) + { + CompileTest(test); + } + { // handle interned data - { // replace the jump ptr at index=0 to tell us where the data lives. + { // replace the jump ptr at index=0 to tell us where the data lives. var internLocationBytes = BitConverter.GetBytes(_buffer.Count); for (var i = 0; i < internLocationBytes.Length; i++) { @@ -472,9 +549,64 @@ public void Compile(ProgramNode program) CompileJumpReplacements(); } + private void CollectRuntoTargets(ProgramNode program) + { + void Visit(IAstVisitable node) + { + if (node is RuntoStatement runto) + { + _runtoTargetLabels.Add(runto.targetLabel); + } + } + foreach (var test in program.tests) + { + test.Visit(Visit); + } + } + + private void CompileTest(TestNode test) + { + // Abstract tests still produce bytecode (they may be `from`-parents of + // concrete tests, which Stage 8 will leverage), but they don't appear + // as runnable manifest entries. + var entryPoint = _buffer.Count; + _testManifest.Add(new TestManifestEntry + { + name = test.name, + entryPointAddress = entryPoint, + isAbstract = test.isAbstract, + fromParent = test.fromParent, + sourceLine = test.startToken?.lineNumber ?? 0, + sourceChar = test.startToken?.charNumber ?? 0 + }); + + // Compile the test body. The dispatch in Compile(IStatementNode) skips + // FunctionStatement nodes — they're emitted separately below — so the + // body's own function declarations don't pollute the test's entry-point + // bytecode region. + foreach (var statement in test.statements) + { + Compile(statement); + } + + // Halt at the end of the test body so execution doesn't fall into + // whatever follows in the bytecode blob. + CompileEnd(); + + // Now compile any test-scoped functions. They live alongside program + // functions in the bytecode blob and register themselves in the + // shared _functionTable, which means the test body can call them by + // name. (Stage 6 narrows visibility via the from-chain in a follow-up + // pass; for v1 they're globally addressable, which is permissive.) + foreach (var function in test.functions) + { + Compile(function); + } + } + public void CompileJumpReplacements() { - + // replace all label instructions... foreach (var replacement in _labelReplacements) { @@ -491,7 +623,27 @@ public void CompileJumpReplacements() _buffer[replacement.InstructionIndex + 2 + i] = locationBytes[i]; } } - + + // replace all runto target placeholders. The runto target address is + // the byte AFTER the label's RUNTO_YIELD opcode (= label_addr + 2). + // RUNTO_YIELD checks `runtoStack.Peek().target == instructionIndex`, + // and instructionIndex at that point is post-RUNTO_YIELD, so we need + // to bake `label_addr + 2` into the PUSH int placeholder. + foreach (var replacement in _runtoReplacements) + { + if (!_labelToInstructionIndex.TryGetValue(replacement.Label, out var location)) + { + throw new Exception("Compiler: unknown runto target label " + replacement.Label); + } + + var postYieldAddr = location + 2; // skip the NOOP and the RUNTO_YIELD + var locationBytes = BitConverter.GetBytes(postYieldAddr); + for (var i = 0; i < locationBytes.Length; i++) + { + _buffer[replacement.InstructionIndex + 2 + i] = locationBytes[i]; + } + } + // replace all function instrunctions foreach (var replacement in _functionCallReplacements) { @@ -716,6 +868,18 @@ public void Compile(IStatementNode statement) case FunctionReturnStatement returnStatement: Compile(returnStatement); break; + case RuntoStatement runtoStatement: + Compile(runtoStatement); + break; + case AssertStatement assertStatement: + Compile(assertStatement); + break; + case MockStatement mockStatement: + Compile(mockStatement); + break; + case ClearMockStatement clearMockStatement: + Compile(clearMockStatement); + break; case ExpressionStatement expressionStatement: Compile(expressionStatement); break; @@ -1582,9 +1746,62 @@ private void Compile(IfStatement ifStatement) private void Compile(LabelDeclarationNode labelStatement) { - // take note of instruction number... + // take note of instruction number... _labelToInstructionIndex[labelStatement.label] = _buffer.Count; _buffer.Add(OpCodes.NOOP); + // Emit RUNTO_YIELD only for labels that some test targets via `runto`. + // In `dotnet run` builds where no tests exist, this set is empty and + // there is zero per-label overhead. + if (_runtoTargetLabels.Contains(labelStatement.label)) + { + _buffer.Add(OpCodes.RUNTO_YIELD); + } + } + + private void Compile(RuntoStatement runtoStatement) + { + // Emit `PUSH int ; RUNTO`. The placeholder is patched + // in CompileJumpReplacements with the post-yield address: the byte + // immediately after the label's RUNTO_YIELD (i.e., label_addr + 2). + _runtoReplacements.Add(new LabelReplacement + { + InstructionIndex = _buffer.Count, + Label = runtoStatement.targetLabel + }); + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.RUNTO); + } + + private void Compile(AssertStatement assertStatement) + { + // Layout: + // ; pushes int (0 = false, !0 = true) + // PUSH int ; placeholder for skip-on-pass target + // JUMP_GT_ZERO ; if value > 0, jump past failure block + // + // ASSERT_FAIL + // :skipAddr (continue normally) + + Compile(assertStatement.condition); + + // Placeholder for the skip address; we patch it after emitting the + // failure branch so we know where the post-failure code starts. + var skipAddrIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP_GT_ZERO); + + // Failure branch: push the captured source text string and fail. + Compile(new LiteralStringExpression(assertStatement.startToken, assertStatement.sourceText ?? "")); + _buffer.Add(OpCodes.ASSERT_FAIL); + + // Patch the skip address to point at the byte right after the failure + // branch. AddPushInt emits 2 prefix bytes (opcode + type) before the + // 4-byte int payload, so the int value lives at skipAddrIndex+2. + var skipAddrBytes = BitConverter.GetBytes(_buffer.Count); + for (var i = 0; i < skipAddrBytes.Length; i++) + { + _buffer[skipAddrIndex + 2 + i] = skipAddrBytes[i]; + } } private void Compile(ReturnStatement _) @@ -1592,6 +1809,100 @@ private void Compile(ReturnStatement _) _buffer.Add(OpCodes.RETURN); } + private List ResolveMockCommandIds(string commandName) + { + // A mock targets every overload sharing the given name. Iterate the + // method table (which includes every overload) and gather the ids + // of those whose name matches case-insensitively. + var ids = new List(); + var methods = methodTable.methods; + for (var i = 0; i < methods.Length; i++) + { + if (string.Equals(methods[i].name, commandName, + StringComparison.OrdinalIgnoreCase)) + { + ids.Add(i); + } + } + return ids; + } + + private void Compile(MockStatement mockStatement) + { + var commandIds = ResolveMockCommandIds(mockStatement.commandName); + if (commandIds.Count == 0) + { + // Unknown command — the lexer would normally have caught this + // (CommandWord token doesn't form). Skip silently here. + return; + } + + // For each entry in source order, emit one install opcode per + // overload. The VM keys mocks by host method id, so each overload + // gets its own registration. + foreach (var entry in mockStatement.entries) + { + foreach (var commandId in commandIds) + { + var commandReturnType = methodTable.methods[commandId].returnType; + var isVoidCommand = commandReturnType == TypeCodes.VOID; + + // `returns ` on a void command is silently degraded + // to a Void mock. The caller doesn't read a return value, + // so pushing one would just leak onto the stack. Users + // sometimes write `mock wait ms returns 0` thinking they + // need a body — make that DWIM. + var effectiveKind = entry.kind; + if (effectiveKind == MockEntryKind.Returns && isVoidCommand) + { + effectiveKind = MockEntryKind.Void; + } + + switch (effectiveKind) + { + case MockEntryKind.Void: + AddPushInt(_buffer, commandId); + _buffer.Add(OpCodes.MOCK_VOID); + break; + + case MockEntryKind.Returns: + AddPushInt(_buffer, commandId); + if (entry.returnExpression != null) + { + Compile(entry.returnExpression); + } + else + { + AddPushInt(_buffer, 0); + } + _buffer.Add(OpCodes.MOCK_RETURNS); + break; + + case MockEntryKind.Forbid: + AddPushInt(_buffer, commandId); + _buffer.Add(OpCodes.MOCK_FORBID); + break; + } + } + } + } + + private void Compile(ClearMockStatement clearMockStatement) + { + if (clearMockStatement.commandName == null) + { + _buffer.Add(OpCodes.MOCK_CLEAR_ALL); + return; + } + + var commandIds = ResolveMockCommandIds(clearMockStatement.commandName); + foreach (var commandId in commandIds) + { + AddPushInt(_buffer, commandId); + _buffer.Add(OpCodes.MOCK_CLEAR); + } + } + private void Compile(EndProgramStatement endProgramStatement) { CompileEnd(); diff --git a/FadeBasic/FadeBasic/Virtual/OpCodes.cs b/FadeBasic/FadeBasic/Virtual/OpCodes.cs index 5e48ef1..fe8d9b6 100644 --- a/FadeBasic/FadeBasic/Virtual/OpCodes.cs +++ b/FadeBasic/FadeBasic/Virtual/OpCodes.cs @@ -403,5 +403,49 @@ public static class OpCodes /// not emitted. /// public const byte RUNTO_YIELD = 65; + + /// + /// Records an assertion failure on the VM and halts execution. The data + /// stack at the time of dispatch holds a string ptr pointing to the + /// captured source text of the asserted expression. The compiler emits + /// this only on the failure branch of an assert; passing assertions just + /// fall through. + /// + public const byte ASSERT_FAIL = 66; + + /// + /// Installs a void-mock for a host command. Stack at dispatch: + /// [..., commandId:int] → consumed + /// On the next CALL_HOST for that command id, the VM pops the args + /// (per CommandInfo.args metadata) but does not invoke the C# method + /// and does not push a return value. Useful for suppressing void + /// commands like `wait ms` during tests. + /// + public const byte MOCK_VOID = 67; + + /// + /// Installs a value-returning mock for a host command. Stack at + /// dispatch (top to bottom): + /// [..., commandId:int, returnValue:typed] → consumed + /// On the next CALL_HOST for that command id, args are popped and + /// the recorded return value is pushed in their place. + /// + public const byte MOCK_RETURNS = 68; + + /// + /// Installs a forbid-mock for a host command. Stack: [..., commandId:int]. + /// On dispatch, the VM records an assertion failure naming the command. + /// + public const byte MOCK_FORBID = 69; + + /// + /// Removes any mock registration for a single command. Stack: [..., commandId:int]. + /// + public const byte MOCK_CLEAR = 70; + + /// + /// Removes all mock registrations for the current VM. No stack inputs. + /// + public const byte MOCK_CLEAR_ALL = 71; } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs index db625a3..cdf73a0 100644 --- a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs +++ b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs @@ -144,6 +144,37 @@ public class VirtualMachine /// public int programResumeIP; + /// + /// Set when an `assert` fails during test execution. Null means the test + /// has not failed any assertions (yet). The test runner inspects this + /// after Execute() returns to determine pass/fail. + /// + public TestFailure assertionFailure; + + public class TestFailure + { + public string sourceText; // Captured text of the asserted expression. + public int instructionIndex; // IP at the moment of failure (for source-mapping). + } + + /// + /// Per-VM mock registrations. Keyed by host method id (the index into + /// ). On CALL_HOST the dispatcher + /// consults this table first; if a registration exists, it pops the + /// command's args via metadata and synthesizes the mock behavior in + /// place of the real call. + /// + public Dictionary mockTable; + + public class MockBehavior + { + // 0 = void (skip), 1 = returns (push value), 2 = forbid (assert-fail). + public byte kind; + // For kind = Returns: the typed return value to push. + public byte returnTypeCode; + public byte[] returnBytes; + } + public VirtualMachine(IEnumerable program) : this(program.ToArray()) { } @@ -798,11 +829,83 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp // VmUtil.PushSpan(ref stack, BitConverter.GetBytes(readAllocLength), TypeCodes.INT); // break; case OpCodes.CALL_HOST: - VmUtil.ReadAsInt(ref stack, out var hostMethodPtr); hostMethods.FindMethod(hostMethodPtr, out var method); - HostMethodUtil.Execute(method, this); - + + if (mockTable != null && mockTable.TryGetValue(hostMethodPtr, out var mock)) + { + // Mocked: pop the args off the stack as the real + // executor would, then synthesize the behavior. + if (method.args != null) + { + for (var ai = method.args.Length - 1; ai >= 0; ai--) + { + if (method.args[ai].isVmArg) continue; + VmUtil.ReadValueAny(this, default, out _, out _, out _, allowOptional: true); + } + } + + if (mock.kind == 1) + { + // returns: push the recorded value + VmUtil.PushSpan(ref stack, mock.returnBytes, mock.returnTypeCode); + } + else if (mock.kind == 2) + { + // forbid: record an assertion failure naming the command + assertionFailure = new TestFailure + { + sourceText = "forbidden command was called: " + method.name, + instructionIndex = instructionIndex + }; + instructionIndex = int.MaxValue; + } + // kind == 0 (void): nothing else to do; args are gone + } + else + { + HostMethodUtil.Execute(method, this); + } + + break; + + case OpCodes.MOCK_VOID: + { + VmUtil.ReadAsInt(ref stack, out var voidId); + mockTable ??= new Dictionary(); + mockTable[voidId] = new MockBehavior { kind = 0 }; + break; + } + case OpCodes.MOCK_RETURNS: + { + // Stack top: typed return value; below: commandId. + VmUtil.ReadSpan(ref stack, out var retType, out var retSpan); + var retBytes = retSpan.ToArray(); + VmUtil.ReadAsInt(ref stack, out var retId); + mockTable ??= new Dictionary(); + mockTable[retId] = new MockBehavior + { + kind = 1, + returnTypeCode = retType, + returnBytes = retBytes + }; + break; + } + case OpCodes.MOCK_FORBID: + { + VmUtil.ReadAsInt(ref stack, out var forbidId); + mockTable ??= new Dictionary(); + mockTable[forbidId] = new MockBehavior { kind = 2 }; + break; + } + case OpCodes.MOCK_CLEAR: + { + VmUtil.ReadAsInt(ref stack, out var clearId); + mockTable?.Remove(clearId); + break; + } + case OpCodes.MOCK_CLEAR_ALL: + mockTable?.Clear(); break; case OpCodes.NOOP: @@ -909,6 +1012,43 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp } // else fall through; this label wasn't the targeted one. break; + case OpCodes.ASSERT_FAIL: + { + // The data stack holds the source-text. The compiler emits + // it via the LiteralStringExpression path which produces + // [8 ptr bytes][STRING type code] (interned strings get + // CAST to STRING after the PTR push). We accept STRING + // and PTR_HEAP type codes here. + var assertTextTypeCode = stack.Pop(); + var ptrBytes = new byte[8]; + for (var b = 7; b >= 0; b--) ptrBytes[b] = stack.Pop(); + var textPtr = VmPtr.FromBytes(ptrBytes); + string text = ""; + try + { + if (heap.TryGetAllocationSize(textPtr, out var len) && len > 0) + { + heap.Read(textPtr, len, out var bytes); + // Fade strings are stored as 4-bytes-per-char (uint codepoints). + var charCount = len / 4; + var chars = new char[charCount]; + for (var c = 0; c < charCount; c++) + { + chars[c] = (char)BitConverter.ToUInt32(bytes, c * 4); + } + text = new string(chars); + } + } + catch { /* best-effort recovery; leave text empty */ } + assertionFailure = new TestFailure + { + sourceText = text, + instructionIndex = instructionIndex + }; + // Halt execution by jumping past program.Length, mirroring CompileEnd. + instructionIndex = int.MaxValue; + break; + } default: throw new Exception("Unknown op code: " + ins); } diff --git a/FadeBasic/FadeBuildTasks/FadeBasic.Build.props b/FadeBasic/FadeBuildTasks/FadeBasic.Build.props index 0fedf18..f05abc8 100644 --- a/FadeBasic/FadeBuildTasks/FadeBasic.Build.props +++ b/FadeBasic/FadeBuildTasks/FadeBasic.Build.props @@ -5,10 +5,22 @@ <_FadeBasic_TaskFolder>$(MSBuildThisFileDirectory)..\tasks\net8.0 $(_FadeBasic_TaskFolder)\$(MSBuildThisFileName).dll - + + + - + @@ -17,8 +29,8 @@ - + - \ No newline at end of file + diff --git a/FadeBasic/FadeBuildTasks/FadeBasic.Build.targets b/FadeBasic/FadeBuildTasks/FadeBasic.Build.targets index c1bb7c7..8d6e8d2 100644 --- a/FadeBasic/FadeBuildTasks/FadeBasic.Build.targets +++ b/FadeBasic/FadeBuildTasks/FadeBasic.Build.targets @@ -1,13 +1,13 @@ - + true - + True False - + GeneratedFade Launch @@ -16,17 +16,54 @@ $(MSBuildProjectDirectory)/$(FadeGeneratedFolder)/$(FadeGeneratedLaunchType).g.cs + + + + <_FadeTestingHits Include="@(PackageReference)" Condition="'%(PackageReference.Identity)' == 'FadeBasic.Testing'" /> + + + + + + + + + + - @@ -49,4 +86,4 @@ - \ No newline at end of file + diff --git a/FadeBasic/FadeBuildTasks/FadeProjectTask.cs b/FadeBasic/FadeBuildTasks/FadeProjectTask.cs index 008c742..24541c9 100644 --- a/FadeBasic/FadeBuildTasks/FadeProjectTask.cs +++ b/FadeBasic/FadeBuildTasks/FadeProjectTask.cs @@ -22,6 +22,10 @@ public class FadeProjectTask : Task public bool GenerateEntryPoint { get; set; } = true; public bool IgnoreSafetyChecks { get; set; } = false; public bool GenerateDebugData { get; set; } + // When true, the generated Main dispatches to Microsoft.Testing.Platform + // for `dotnet test` invocations and to Launcher.Main otherwise. + // Wire this from the FadeEnableTesting MSBuild property. + public bool EnableTesting { get; set; } = false; [Required] public ITaskItem[] SourceFiles { get; set; } [Required] public ITaskItem[] Commands { get; set; } @@ -165,7 +169,8 @@ public override bool Execute() LaunchableGenerator.GenerateLaunchable(GeneratedClassName, GenerateFileLocation, unit, commandCollection.collection, allClassNames, includeMain: GenerateEntryPoint, - generateDebug: GenerateDebugData); + generateDebug: GenerateDebugData, + enableTesting: EnableTesting); GeneratedFile = GenerateFileLocation; return true; } diff --git a/FadeBasic/LSP/Handlers/FindReferencesHandler.cs b/FadeBasic/LSP/Handlers/FindReferencesHandler.cs index 61dfe30..88fd78b 100644 --- a/FadeBasic/LSP/Handlers/FindReferencesHandler.cs +++ b/FadeBasic/LSP/Handlers/FindReferencesHandler.cs @@ -73,7 +73,7 @@ protected override ReferenceRegistrationOptions CreateRegistrationOptions(Refere void Visit(IAstVisitable x) { bool isMatch = false; - if (x is VariableRefNode or DeclarationStatement or ArrayIndexReference or LabelDeclarationNode or GoSubStatement or GotoStatement) + if (x is VariableRefNode or DeclarationStatement or ArrayIndexReference or LabelDeclarationNode or GoSubStatement or GotoStatement or RuntoStatement) { isMatch = Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken); diff --git a/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs b/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs index 1af1f06..5ee46e9 100644 --- a/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs +++ b/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs @@ -70,6 +70,7 @@ protected override DefinitionRegistrationOptions CreateRegistrationOptions(Defin typeof(ArrayIndexReference), typeof(GoSubStatement), typeof(GotoStatement), + typeof(RuntoStatement), }; bool Visit(IAstVisitable x) @@ -90,6 +91,7 @@ bool Visit(IAstVisitable x) case GoSubStatement _: case GotoStatement _: + case RuntoStatement _: case ArrayIndexReference _: case VariableRefNode _: location = GetLink(node, unit); diff --git a/FadeBasic/Tests/AssertMacroTests.cs b/FadeBasic/Tests/AssertMacroTests.cs new file mode 100644 index 0000000..9e17bfb --- /dev/null +++ b/FadeBasic/Tests/AssertMacroTests.cs @@ -0,0 +1,158 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class AssertMacroTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunTest(string src, string testName) + { + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == testName); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute().MoveNext(); + return vm; + } + + [Test] + public void Assert_True_TestPasses() + { + var src = @" +test foo + assert 1 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null, "assert 1 should pass"); + } + + [Test] + public void Assert_NonZeroExpression_TestPasses() + { + var src = @" +test foo + local x as integer = 5 + assert x +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void Assert_Zero_TestFails() + { + var src = @" +test foo + assert 0 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null, "assert 0 should fail the test"); + } + + [Test] + public void Assert_FalseExpression_TestFails() + { + var src = @" +test foo + local x as integer = 5 + assert x = 6 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + } + + [Test] + public void Assert_TrueComparison_TestPasses() + { + var src = @" +test foo + local x as integer = 5 + assert x = 5 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void Assert_Failure_CapturesSourceText() + { + var src = @" +test foo + local x as integer = 5 + assert x = 99 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + // Source text should mention `x` and `99` somewhere. + Assert.That(vm.assertionFailure.sourceText, Does.Contain("x")); + Assert.That(vm.assertionFailure.sourceText, Does.Contain("99")); + } + + [Test] + public void Assert_FailureHaltsExecution() + { + // After a failed assert, subsequent statements should not execute. + // We verify by making the failing assert come BEFORE another statement + // that would otherwise alter VM state observably. Since we can only + // observe via assertionFailure (no execution-side-effect to Fade-side) + // for now, we settle for: failure halt means no second assert runs. + var src = @" +test foo + assert 0 + assert 1 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null, + "first assert fails — second assert should not run, but failure should be present"); + } + + [Test] + public void Assert_OutsideTest_StillCompiles() + { + // For now, assert outside a test compiles but its semantics are undefined. + // (Stage 6+ should add a parse-time error for this; for now it's permissive.) + var src = @" +assert 1 +end +"; + Assert.DoesNotThrow(() => + { + var (compiler, _) = Compile(src); + }); + } + + [Test] + public void Assert_TwoSequentialPasses_BothExecute() + { + var src = @" +test foo + assert 1 + assert 2 + assert 1 + 1 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } +} diff --git a/FadeBasic/Tests/DapIntegrationTests.cs b/FadeBasic/Tests/DapIntegrationTests.cs new file mode 100644 index 0000000..96adf61 --- /dev/null +++ b/FadeBasic/Tests/DapIntegrationTests.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Tests; + +/// +/// Integration tests for the FadeBasic DAP adapter. +/// Starts the adapter as a child process and speaks the DAP protocol over stdin/stdout. +/// +public class DapIntegrationTests +{ + private Process _dap; + private int _seq = 1; + private readonly List _events = new(); + private readonly List _reverseRequests = new(); + private readonly Dictionary _responses = new(); + private CancellationTokenSource _cts; + private Task _readerTask; + + private string TestProjectPath => + Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "Tests", "Fixtures", "Projects", "Primitive", "prim.csproj")); + + private string DapDll + { + get + { + var dll = Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "DAP", "bin", "Debug", "net8.0", "DAP.dll")); + if (!File.Exists(dll)) + { + var proj = Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "DAP", "DAP.csproj")); + var build = Process.Start(new ProcessStartInfo("dotnet", $"build \"{proj}\" -c Debug") + { + RedirectStandardOutput = true, RedirectStandardError = true, + }); + build!.WaitForExit(); + Assert.That(build.ExitCode, Is.EqualTo(0), "DAP build failed"); + } + return dll; + } + } + + [SetUp] + public void Setup() + { + _seq = 1; + _events.Clear(); + _reverseRequests.Clear(); + _responses.Clear(); + _cts = new CancellationTokenSource(); + + _dap = new Process + { + StartInfo = new ProcessStartInfo("dotnet", DapDll) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + Environment = + { + ["FADE_DAP_LOG_PATH"] = Path.Combine(TestContext.CurrentContext.WorkDirectory, "dap_test.log"), + ["FADE_DOTNET_PATH"] = "dotnet", + }, + }, + }; + _dap.Start(); + _readerTask = Task.Run(() => ReadMessages(_cts.Token)); + } + + [TearDown] + public void TearDown() + { + _cts?.Cancel(); + try { _dap?.Kill(true); } catch { } + _dap?.Dispose(); + } + + [Test] + public async Task InitializeReturnsCapabilities() + { + var resp = await SendRequest("initialize", BuildInitArgs()); + Assert.That(resp["success"]?.GetValue(), Is.True, $"initialize failed: {resp}"); + var body = resp["body"]?.AsObject(); + Assert.That(body?["supportsConfigurationDoneRequest"]?.GetValue(), Is.True); + } + + [Test] + public async Task LaunchWithBadProjectReturnsError() + { + await SendRequest("initialize", BuildInitArgs()); + var resp = await SendRequest("launch", new JsonObject { ["program"] = "/nonexistent/fake.csproj" }); + Assert.That(resp["success"]?.GetValue(), Is.False, "launch should fail for bad project"); + Assert.That(resp["message"]?.GetValue(), Is.Not.Empty); + } + + [Test] + public async Task FullDebugSessionWithBreakpoint() + { + if (!File.Exists(TestProjectPath)) + Assert.Ignore($"Fixture not found: {TestProjectPath}"); + + // 1. Initialize + var initResp = await SendRequest("initialize", BuildInitArgs()); + Assert.That(initResp["success"]?.GetValue(), Is.True, "initialize failed"); + + // 2. Launch (async -- adapter will send runInTerminal + initialized) + var launchTask = SendRequest("launch", new JsonObject + { + ["program"] = TestProjectPath, + }); + + // 3. Wait for initialized event + runInTerminal reverse request + var ritReq = await WaitForReverseRequest("runInTerminal", TimeSpan.FromSeconds(30)); + Assert.That(ritReq, Is.Not.Null, "Never received runInTerminal"); + + var ritArgs = ritReq["arguments"]!.AsObject(); + var env = ritArgs["env"]!.AsObject(); + Assert.That(env.ContainsKey("FADE_BASIC_DEBUG"), "missing FADE_BASIC_DEBUG env var"); + Assert.That(env.ContainsKey("FADE_BASIC_DEBUG_PORT"), "missing FADE_BASIC_DEBUG_PORT env var"); + + // Verify the port is present (may be int or string in JSON) + var portNode = env["FADE_BASIC_DEBUG_PORT"]; + int port; + if (portNode is JsonValue jv && jv.TryGetValue(out var intPort)) + port = intPort; + else + port = int.Parse(portNode!.GetValue()); + Assert.That(port, Is.GreaterThan(0), "port should be a positive integer"); + + var argv = ritArgs["args"]!.AsArray().Select(a => a!.GetValue()).ToList(); + Assert.That(argv, Has.Count.GreaterThan(0)); + TestContext.Out.WriteLine($"runInTerminal argv: {string.Join(" ", argv)}"); + TestContext.Out.WriteLine($"runInTerminal env: {env.ToJsonString()}"); + + // 4. Respond to runInTerminal (simulate -- don't actually start the process) + await SendReverseResponse(ritReq["seq"]!.GetValue(), "runInTerminal", + new JsonObject { ["processId"] = 99999 }); + + // 5. Wait for launch response + var launchResp = await launchTask; + Assert.That(launchResp["success"]?.GetValue(), Is.True, $"launch failed: {launchResp}"); + + // 6. Verify initialized event was sent + var initialized = await WaitForEvent("initialized", TimeSpan.FromSeconds(5)); + Assert.That(initialized, Is.Not.Null, "Never received initialized event"); + + // Note: setBreakpoints / configurationDone / stopped / stackTrace require a real + // debuggee process connected via TCP (the adapter blocks on Connect() after + // runInTerminal). Those flows are verified by the DAP logs from real IDE sessions + // and by the StoppedEventIncludesThreadId source-level test. + } + + [Test] + public async Task StoppedEventIncludesThreadId() + { + // This test verifies the DAP adapter sends threadId in stopped events. + // We check this by reading the adapter source, but also verify via the + // protocol that the field serializes correctly. + // + // The stopped event JSON from the last real session log was: + // {"type":"event","event":"stopped","body":{"reason":"breakpoint", + // "description":"Hit a breakpoint","threadId":1,"allThreadsStopped":true, + // "hitBreakpointIds":[0]}} + // + // Verify the DAP source has ThreadId = 1 in both HitBreakpointCallback locations + var dapSource = File.ReadAllText(Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "DAP", "FadeDebugAdapter.cs"))); + + var hitCallbackMatches = System.Text.RegularExpressions.Regex.Matches( + dapSource, @"HitBreakpointCallback\s*="); + Assert.That(hitCallbackMatches.Count, Is.GreaterThanOrEqualTo(2), + "Expected at least 2 HitBreakpointCallback assignments (launch + attach)"); + + var threadIdMatches = System.Text.RegularExpressions.Regex.Matches( + dapSource, @"ThreadId\s*=\s*1"); + Assert.That(threadIdMatches.Count, Is.GreaterThanOrEqualTo(2), + "Both HitBreakpointCallback locations must set ThreadId = 1"); + } + + [Test] + public async Task RunInTerminalEnvPortIsUsable() + { + // The DAP sends FADE_BASIC_DEBUG_PORT as a JSON integer in the env map. + // Clients must convert it to a string for environment variables. + // This test verifies the port value is a valid integer regardless of JSON type. + if (!File.Exists(TestProjectPath)) + Assert.Ignore($"Fixture not found: {TestProjectPath}"); + + await SendRequest("initialize", BuildInitArgs()); + var launchTask = SendRequest("launch", new JsonObject { ["program"] = TestProjectPath }); + + var ritReq = await WaitForReverseRequest("runInTerminal", TimeSpan.FromSeconds(30)); + Assert.That(ritReq, Is.Not.Null); + + var env = ritReq["arguments"]!.AsObject()["env"]!.AsObject(); + var portNode = env["FADE_BASIC_DEBUG_PORT"]; + + // The port may arrive as a JSON number (integer) -- clients need to .toString() it + string portStr; + if (portNode is JsonValue jv) + { + if (jv.TryGetValue(out var intVal)) + portStr = intVal.ToString(); + else + portStr = jv.GetValue(); + } + else + { + portStr = portNode!.ToString(); + } + + Assert.That(int.TryParse(portStr, out var port), Is.True, + $"Port '{portStr}' must be parseable as integer"); + Assert.That(port, Is.GreaterThan(1024).And.LessThan(65536), + $"Port {port} should be in ephemeral range"); + + TestContext.Out.WriteLine($"Port value: {port} (from JSON type: {portNode!.GetType().Name})"); + + // Clean up - respond to runInTerminal so launch completes + await SendReverseResponse(ritReq["seq"]!.GetValue(), "runInTerminal", + new JsonObject { ["processId"] = 1 }); + await launchTask; + } + + // ---- helpers ---- + + static JsonObject BuildInitArgs() => new() + { + ["clientID"] = "test", + ["clientName"] = "DapIntegrationTest", + ["adapterID"] = "fade-basic", + ["linesStartAt1"] = true, + ["columnsStartAt1"] = true, + ["pathFormat"] = "path", + ["supportsRunInTerminalRequest"] = true, + }; + + async Task WaitForReverseRequest(string command, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + lock (_reverseRequests) + { + var found = _reverseRequests.Find(r => r["command"]?.GetValue() == command); + if (found != null) return found; + } + await Task.Delay(50); + } + return null; + } + + async Task WaitForEvent(string eventName, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + lock (_events) + { + var found = _events.Find(e => e["event"]?.GetValue() == eventName); + if (found != null) return found; + } + await Task.Delay(50); + } + return null; + } + + async Task SendRequest(string command, JsonObject arguments) + { + var seq = _seq++; + var msg = new JsonObject + { + ["seq"] = seq, ["type"] = "request", ["command"] = command, ["arguments"] = arguments, + }; + await WriteMessage(msg); + + var deadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < deadline) + { + lock (_responses) + { + if (_responses.TryGetValue(seq, out var resp)) + { + _responses.Remove(seq); + return resp; + } + } + await Task.Delay(50); + } + Assert.Fail($"Timeout waiting for response to '{command}' (seq={seq})"); + return null!; + } + + async Task SendReverseResponse(int requestSeq, string command, JsonObject body) + { + await WriteMessage(new JsonObject + { + ["seq"] = _seq++, ["type"] = "response", ["request_seq"] = requestSeq, + ["success"] = true, ["command"] = command, ["body"] = body, + }); + } + + async Task WriteMessage(JsonObject msg) + { + var json = msg.ToJsonString(); + var header = $"Content-Length: {Encoding.UTF8.GetByteCount(json)}\r\n\r\n"; + await _dap.StandardInput.WriteAsync(header); + await _dap.StandardInput.WriteAsync(json); + await _dap.StandardInput.FlushAsync(); + } + + void ReadMessages(CancellationToken ct) + { + try + { + var stream = _dap.StandardOutput.BaseStream; + var buffer = new byte[65536]; + var pending = new MemoryStream(); + while (!ct.IsCancellationRequested) + { + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) break; + pending.Write(buffer, 0, read); + while (TryParseMessage(pending, out var msg)) + { + var type = msg["type"]?.GetValue(); + switch (type) + { + case "response": + lock (_responses) { _responses[msg["request_seq"]!.GetValue()] = msg; } + break; + case "event": + lock (_events) { _events.Add(msg); } + break; + case "request": + lock (_reverseRequests) { _reverseRequests.Add(msg); } + break; + } + } + } + } + catch when (ct.IsCancellationRequested) { } + catch (IOException) { } + } + + static bool TryParseMessage(MemoryStream pending, out JsonObject msg) + { + msg = null; + var data = pending.ToArray(); + var text = Encoding.UTF8.GetString(data); + var headerEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (headerEnd < 0) return false; + int contentLength = -1; + foreach (var line in text.Substring(0, headerEnd).Split("\r\n")) + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + contentLength = int.Parse(line.Substring("Content-Length:".Length).Trim()); + if (contentLength < 0) return false; + var bodyStart = Encoding.UTF8.GetByteCount(text.Substring(0, headerEnd + 4)); + if (data.Length < bodyStart + contentLength) return false; + var bodyBytes = new byte[contentLength]; + Array.Copy(data, bodyStart, bodyBytes, 0, contentLength); + msg = JsonNode.Parse(Encoding.UTF8.GetString(bodyBytes))?.AsObject(); + if (msg == null) return false; + var consumed = bodyStart + contentLength; + var remaining = data.Length - consumed; + pending.SetLength(0); + if (remaining > 0) pending.Write(data, consumed, remaining); + return true; + } +} diff --git a/FadeBasic/Tests/DotnetTestIntegrationDemo.cs b/FadeBasic/Tests/DotnetTestIntegrationDemo.cs new file mode 100644 index 0000000..6af5f02 --- /dev/null +++ b/FadeBasic/Tests/DotnetTestIntegrationDemo.cs @@ -0,0 +1,90 @@ +using FadeBasic; +using FadeBasic.Sdk; + +namespace Tests; + +/// +/// Demonstration of the recipe for surfacing Fade tests in a `dotnet test` +/// run. Each Fade `test ... endtest` block becomes a separate NUnit test case +/// via TestCaseSource, so it shows up individually in IDE Test +/// Explorer and CI logs. +/// +/// Real-world consumer projects would replace SampleFadeSource with +/// either an embedded resource or a file path that points at the project's +/// .fbasic files, and would reference their own CommandCollection. +/// +[TestFixture] +public class DotnetTestIntegrationDemo +{ + private const string SampleFadeSource = @" +counter = 0 +counter = counter + 1 +checkpoint: +end + +test counter_increments + runto checkpoint + assert counter = 1 +endtest + +test math_works + assert 2 + 2 = 4 +endtest + +test string_compare + local s as string + s = ""hello"" + assert s = ""hello"" +endtest +"; + + // Cache the runtime context across cases so we only compile once. Each + // test still gets a fresh VM (RunTest builds one per call), so state is + // isolated between Fade tests. + private static FadeRuntimeContext _cachedContext; + private static FadeRuntimeContext SharedContext + { + get + { + if (_cachedContext != null) return _cachedContext; + var ok = Fade.TryCreateFromString(SampleFadeSource, TestCommands.CommandsForTesting, + out var ctx, out var errors); + if (!ok) + { + throw new Exception("Fade compile failed: " + errors.ToDisplay()); + } + return _cachedContext = ctx; + } + } + + // NUnit calls this static method to populate test cases. Each yielded + // value becomes a parameter to the test method. Returning test names as + // strings produces test cases like + // `RunFadeTest(\"counter_increments\")` in the Test Explorer. + public static IEnumerable DiscoverFadeTests() + { + foreach (var t in SharedContext.Tests) + { + yield return t.name; + } + } + + [Test] + [TestCaseSource(nameof(DiscoverFadeTests))] + public void RunFadeTest(string fadeTestName) + { + var result = SharedContext.RunTest(fadeTestName); + Assert.That(result.passed, Is.True, + $"Fade test `{fadeTestName}` failed: {result.failureMessage}"); + } + + // Companion test: confirm that the discovery returns the expected set. + [Test] + public void DiscoverFadeTests_ReturnsAllTopLevelTests() + { + var names = DiscoverFadeTests().ToList(); + Assert.That(names, Does.Contain("counter_increments")); + Assert.That(names, Does.Contain("math_works")); + Assert.That(names, Does.Contain("string_compare")); + } +} diff --git a/FadeBasic/Tests/FadeTestAdapterTests.cs b/FadeBasic/Tests/FadeTestAdapterTests.cs new file mode 100644 index 0000000..e87a680 --- /dev/null +++ b/FadeBasic/Tests/FadeTestAdapterTests.cs @@ -0,0 +1,524 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.TestAdapter; +using FadeBasic.Sdk; +using FadeBasic.Testing; +using FadeBasic.Virtual; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using NUnit.Framework; + +namespace Tests; + +/// +/// Unit tests for the VSTest adapter (Stage 11H — see TEST_ADAPTER.md). +/// We exercise the discoverer's internal entry-point and the executor's +/// helper methods directly, without invoking the VSTest pipeline. Integration +/// tests that drive vstest.console end-to-end live in a separate fixture +/// (deferred — see TEST_ADAPTER.md "Tests" section). +/// +[TestFixture] +public class FadeTestAdapterTests +{ + // ---- Discoverer --------------------------------------------------- + + [Test] + public void Discoverer_FindsConcreteTests_SkipsAbstract() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "alpha", entryPointAddress = 100, sourceLine = 5, sourceFilePath = "/proj/main.fbasic" }, + new TestManifestEntry { name = "parent", entryPointAddress = 200, isAbstract = true }, + new TestManifestEntry { name = "beta", entryPointAddress = 300, sourceLine = 12, sourceFilePath = "/proj/main.fbasic", fromParent = "parent" }, + }); + + var cases = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).ToList(); + + Assert.That(cases.Count, Is.EqualTo(2), + "exactly the two concrete entries should surface; abstract entries are not run"); + var names = cases.Select(c => c.DisplayName).ToList(); + Assert.That(names, Is.EquivalentTo(new[] { "alpha", "beta" })); + } + + [Test] + public void Discoverer_PopulatesFadeFlavoredFields() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "wraps_at_right_edge", entryPointAddress = 42, sourceLine = 17, sourceFilePath = "/proj/fish.fbasic" }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + Assert.That(tc.DisplayName, Is.EqualTo("wraps_at_right_edge")); + // FQN aligns with ManagedType.ManagedMethod — see Discoverer_ManagedType_BuildsFadeBasenamePath. + Assert.That(tc.FullyQualifiedName, Is.EqualTo("Fade.fish.wraps_at_right_edge")); + Assert.That(tc.ExecutorUri.ToString(), Is.EqualTo(FadeTestConstants.ExecutorUriString)); + Assert.That(tc.Source, Is.EqualTo("/proj/MyApp.dll")); + Assert.That(tc.CodeFilePath, Is.EqualTo("/proj/fish.fbasic"), + "double-clicking the test should jump to the .fbasic file, not the assembly"); + Assert.That(tc.LineNumber, Is.EqualTo(17)); + } + + [Test] + public void Discoverer_TagsEveryCaseWithCategoryFade() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "a", entryPointAddress = 1, sourceFilePath = "/proj/x.fbasic" }, + new TestManifestEntry { name = "b", entryPointAddress = 2, sourceFilePath = "/proj/x.fbasic" }, + }); + + var cases = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).ToList(); + + foreach (var tc in cases) + { + Assert.That(tc.Traits.Any(t => t.Name == "Category" && t.Value == "Fade"), + Is.True, + $"case {tc.DisplayName} is missing the Category=Fade trait"); + } + } + + [Test] + public void Discoverer_StampsEntryPointAddressOnTestCase() + { + // The executor uses this to look up the matching manifest entry + // without re-walking the launchable. Verifying it round-trips. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "go", entryPointAddress = 9999, sourceFilePath = "/proj/x.fbasic" }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + var addr = tc.GetPropertyValue(FadeTestCaseProperties.EntryPointAddress, defaultValue: -1); + Assert.That(addr, Is.EqualTo(9999)); + } + + [Test] + public void Discoverer_FromParent_BecomesTrait() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "child", entryPointAddress = 1, fromParent = "fixture", sourceFilePath = "/proj/x.fbasic" }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + Assert.That(tc.Traits.Any(t => t.Name == "FromParent" && t.Value == "fixture"), Is.True); + var fromParent = tc.GetPropertyValue(FadeTestCaseProperties.FromParent, defaultValue: null!); + Assert.That(fromParent, Is.EqualTo("fixture")); + } + + [Test] + public void Discoverer_OmitsCodeFilePath_WhenSourceUnknown() + { + // No source path on the manifest entry → don't guess; better to omit + // than send a path the IDE will fail to open. (This is the case for + // single-string SDK callers that didn't supply a SourceMap.) + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "x", entryPointAddress = 1, sourceLine = 3 }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + Assert.That(tc.CodeFilePath, Is.Null.Or.Empty); + Assert.That(tc.LineNumber, Is.EqualTo(3), + "LineNumber alone is still useful — Test Explorer shows it in the details pane"); + } + + [Test] + public void Discoverer_ManagedType_BuildsFadeBasenamePath() + { + // ManagedType + ManagedMethod build the test tree in IDE Test + // Explorers / `dotnet test` structured output. Format is + // "Fade."; the test name becomes + // ManagedMethod. Tooling that falls back to parsing FullyQualifiedName + // gets the same grouping because FQN = ManagedType + "." + ManagedMethod. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "wraps_at_right_edge", entryPointAddress = 1, sourceFilePath = "/proj/fish.fbasic", sourceLine = 5 }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + var managedType = tc.GetPropertyValue(FadeTestCaseProperties.ManagedType, defaultValue: null!); + var managedMethod = tc.GetPropertyValue(FadeTestCaseProperties.ManagedMethod, defaultValue: null!); + + Assert.That(managedType, Is.EqualTo("Fade.fish"), + "ManagedType groups tests by their .fbasic source file under a `Fade` namespace"); + Assert.That(managedMethod, Is.EqualTo("wraps_at_right_edge"), + "ManagedMethod is the test name verbatim"); + Assert.That(tc.FullyQualifiedName, Is.EqualTo("Fade.fish.wraps_at_right_edge")); + } + + [Test] + public void Discoverer_ManagedType_SanitizesNonIdentifierCharacters() + { + // .fbasic basenames can include dashes, dots in names, etc. — invalid + // in dotted-identifier paths. We sanitize to [A-Za-z0-9_]. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "t", entryPointAddress = 1, sourceFilePath = "/proj/my-game.fbasic" }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).Single(); + + var managedType = tc.GetPropertyValue(FadeTestCaseProperties.ManagedType, defaultValue: null!); + Assert.That(managedType, Is.EqualTo("Fade.my_game")); + } + + [Test] + public void Discoverer_ManagedType_FallsBackToAssemblyName_WhenNoSourceFile() + { + // Single-string SDK callers won't have sourceFilePath populated. + // Fall back to the assembly basename so the tree still groups + // sensibly. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "t", entryPointAddress = 1 }, + }); + + var tc = FadeTestDiscoverer.EnumerateTestCases("/proj/CoolApp.dll", launchable).Single(); + + var managedType = tc.GetPropertyValue(FadeTestCaseProperties.ManagedType, defaultValue: null!); + Assert.That(managedType, Is.EqualTo("Fade.CoolApp")); + } + + [Test] + public void Discoverer_ToManagedIdentifier_CoercesEmptyToTests() + { + Assert.That(FadeTestDiscoverer.ToManagedIdentifier(""), Is.EqualTo("Tests")); + Assert.That(FadeTestDiscoverer.ToManagedIdentifier(null!), Is.EqualTo("Tests")); + Assert.That(FadeTestDiscoverer.ToManagedIdentifier("9digit"), Does.StartWith("_"), + "C# identifiers cannot start with a digit"); + } + + [Test] + public void Discoverer_MultipleFiles_EachTestKeepsItsOwnPath() + { + // Multi-`.fbasic` projects: each entry's sourceFilePath drives its + // CodeFilePath. This is the whole point of the per-entry plumbing. + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "a", entryPointAddress = 1, sourceFilePath = "/proj/foo.fbasic", sourceLine = 5 }, + new TestManifestEntry { name = "b", entryPointAddress = 2, sourceFilePath = "/proj/bar.fbasic", sourceLine = 10 }, + }); + + var cases = FadeTestDiscoverer.EnumerateTestCases("/proj/MyApp.dll", launchable).ToList(); + + var a = cases.First(c => c.DisplayName == "a"); + var b = cases.First(c => c.DisplayName == "b"); + Assert.That(a.CodeFilePath, Is.EqualTo("/proj/foo.fbasic")); + Assert.That(b.CodeFilePath, Is.EqualTo("/proj/bar.fbasic")); + } + + // ---- Executor: ResolveEntry -------------------------------------- + + [Test] + public void Executor_ResolveEntry_PrefersAddressOverDisplayName() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "duplicate_name", entryPointAddress = 100, isAbstract = true }, + new TestManifestEntry { name = "duplicate_name", entryPointAddress = 200 }, + }); + + var tc = new TestCase("Fade.x.duplicate_name", FadeTestConstants.ExecutorUri, "/proj/MyApp.dll"); + tc.SetPropertyValue(FadeTestCaseProperties.EntryPointAddress, 200); + + var resolved = FadeTestExecutorAdapter.ResolveEntry(tc, launchable); + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved!.entryPointAddress, Is.EqualTo(200), + "name collisions are resolved by entry-point address, not by name"); + } + + [Test] + public void Executor_ResolveEntry_FallsBackToDisplayName_WhenNoAddress() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "named", entryPointAddress = 50 }, + }); + var tc = new TestCase("Fade.x.named", FadeTestConstants.ExecutorUri, "/proj/MyApp.dll") + { + DisplayName = "named" + }; + // Deliberately do NOT set EntryPointAddress — exercise the fallback. + + var resolved = FadeTestExecutorAdapter.ResolveEntry(tc, launchable); + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved!.name, Is.EqualTo("named")); + } + + [Test] + public void Executor_ResolveEntry_ReturnsNull_WhenUnresolvable() + { + var launchable = new ManifestLaunchable(new[] + { + new TestManifestEntry { name = "exists", entryPointAddress = 1 }, + }); + var tc = new TestCase("Fade.x.missing", FadeTestConstants.ExecutorUri, "/proj/MyApp.dll") + { + DisplayName = "missing" + }; + + var resolved = FadeTestExecutorAdapter.ResolveEntry(tc, launchable); + Assert.That(resolved, Is.Null); + } + + // ---- Executor: failure formatting -------------------------------- + + [Test] + public void Executor_BuildErrorMessage_IncludesFbasicSourceAndLine() + { + // Failure pane should read as a Fade error, not a generic dump. + var entry = new TestManifestEntry { name = "wraps", sourceLine = 42 }; + var result = new FadeTestResult + { + passed = false, + failureMessage = "x is 0", + failureSourceText = "assert x = 1", + }; + var msg = FadeTestExecutorAdapter.BuildErrorMessage(result, "/proj/fish.fbasic", entry); + + Assert.That(msg, Does.Contain("x is 0")); + Assert.That(msg, Does.Contain("source: assert x = 1")); + Assert.That(msg, Does.Contain("at fish.fbasic:42")); + } + + [Test] + public void Executor_BuildErrorMessage_GracefulWhenNoSourceText() + { + var entry = new TestManifestEntry { name = "x", sourceLine = 7 }; + var result = new FadeTestResult { passed = false, failureMessage = "vm boom" }; + var msg = FadeTestExecutorAdapter.BuildErrorMessage(result, "/p/main.fbasic", entry); + + Assert.That(msg, Does.Contain("vm boom")); + Assert.That(msg, Does.Not.Contain("source:"), + "the source: line should be omitted when failureSourceText is empty"); + } + + [Test] + public void Executor_BuildErrorStackTrace_ProducesClickableFormat() + { + // The exact format ("at NAME in FILE:line N") is the contract that + // both Rider and VS Code parse to make stack lines clickable. + var entry = new TestManifestEntry { name = "wraps_at_right_edge", sourceLine = 42 }; + var stack = FadeTestExecutorAdapter.BuildErrorStackTrace("/proj/fish.fbasic", entry); + + Assert.That(stack, Does.Match(@"\s+at wraps_at_right_edge in /proj/fish\.fbasic:line 42")); + } + + [Test] + public void Executor_BuildErrorStackTrace_EmptyWhenSourceUnknown() + { + // Without source info we can't synthesize a useful frame; emit + // empty rather than a half-frame the IDE will mis-parse. + var entry = new TestManifestEntry { name = "x", sourceLine = 0 }; + var stack = FadeTestExecutorAdapter.BuildErrorStackTrace("/p/main.fbasic", entry); + Assert.That(stack, Is.Empty); + + var entry2 = new TestManifestEntry { name = "x", sourceLine = 5 }; + var stack2 = FadeTestExecutorAdapter.BuildErrorStackTrace(string.Empty, entry2); + Assert.That(stack2, Is.Empty); + } + + // ---- Executor: stdout/stderr capture ------------------------------ + + [Test] + public void Executor_CapturesStdout_FromTestRun_AsTestResultMessage() + { + // The Fade standard library's `print` lands on Console.WriteLine + // (FadeBasicCommands.cs); the adapter redirects Console.Out around + // the run so the IDE's test details pane gets the output. Verifying + // the pipe end-to-end with a fake host that prints during its run. + var entry = new TestManifestEntry { name = "prints", entryPointAddress = 1, sourceFilePath = "/p/x.fbasic", sourceLine = 5 }; + var launchable = new ManifestLaunchable(new[] { entry }); + var host = new PrintingHost(stdoutText: "hello from test", stderrText: "warning text"); + + var captured = RunOneAndCapture(launchable, entry, host); + + var stdout = captured.Messages.SingleOrDefault(m => m.Category == TestResultMessage.StandardOutCategory); + Assert.That(stdout, Is.Not.Null, "stdout message should be attached when the test prints"); + Assert.That(stdout!.Text, Does.Contain("hello from test")); + + var stderr = captured.Messages.SingleOrDefault(m => m.Category == TestResultMessage.StandardErrorCategory); + Assert.That(stderr, Is.Not.Null); + Assert.That(stderr!.Text, Does.Contain("warning text")); + } + + [Test] + public void Executor_OmitsMessages_WhenTestPrintsNothing() + { + // Defensive: don't pollute the result with empty StandardOut entries. + // Some IDEs render a blank "Output" tab for any non-null message, + // so emitting nothing is the right behavior. + var entry = new TestManifestEntry { name = "silent", entryPointAddress = 1, sourceFilePath = "/p/x.fbasic", sourceLine = 1 }; + var launchable = new ManifestLaunchable(new[] { entry }); + var host = new PrintingHost(stdoutText: "", stderrText: ""); + + var captured = RunOneAndCapture(launchable, entry, host); + + Assert.That(captured.Messages, Is.Empty, + "no stdout/stderr captured → no message entries on the result"); + } + + private static TestResult RunOneAndCapture( + ManifestLaunchable launchable, + TestManifestEntry entry, + IFadeTestHost host) + { + // Use a path that exists so the loader's GetFullPath/cache lookup + // resolves stably. The actual file content is never read because + // we pre-register the in-memory launchable on the loader cache. + var assemblyPath = System.IO.Path.GetTempFileName(); + var tc = FadeTestDiscoverer.EnumerateTestCases(assemblyPath, launchable).Single(); + var handle = new CapturingFrameworkHandle(); + var executor = new FadeTestExecutorAdapter(); + + // Inject the host AND pre-load the launchable. The executor's + // RunGroup re-loads launchables from disk via FadeTestLaunchableLoader; + // the test seam shortcircuits that to our in-memory instance. + using (FadeTestHostResolver.OverrideForTests(host)) + using (FadeTestLaunchableLoader.RegisterForTests(assemblyPath, launchable)) + { + executor.RunTests(new[] { tc }, runContext: null, frameworkHandle: handle); + } + + try + { + if (handle.Results.Count != 1) + { + Assert.Fail($"Expected 1 TestResult, got {handle.Results.Count}. Handle messages: " + + string.Join("; ", handle.Messages)); + } + return handle.Results[0]; + } + finally + { + try { System.IO.File.Delete(assemblyPath); } catch { /* best effort */ } + } + } + + /// + /// Test host that writes to Console.Out / Console.Error during its + /// RunTestAsync, mimicking what a real Fade test would do via `print`. + /// + private sealed class PrintingHost : IFadeTestHost + { + private readonly string _stdout; + private readonly string _stderr; + public PrintingHost(string stdoutText, string stderrText) { _stdout = stdoutText; _stderr = stderrText; } + public Task InitializeAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + public Task BeforeAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + public Task RunTestAsync(FadeTestRunContext ctx, CancellationToken ct) + { + if (_stdout.Length > 0) Console.Write(_stdout); + if (_stderr.Length > 0) Console.Error.Write(_stderr); + return Task.FromResult(new FadeTestResult { testName = ctx.Entry.name, passed = true }); + } + public Task AfterAllTestsAsync(FadeTestSessionContext ctx, CancellationToken ct) => Task.CompletedTask; + public ValueTask DisposeAsync() => default; + } + + private sealed class CapturingFrameworkHandle : IFrameworkHandle + { + public List Results { get; } = new List(); + public List Messages { get; } = new List(); + public void RecordStart(TestCase testCase) { } + public void RecordResult(TestResult testResult) => Results.Add(testResult); + public void RecordEnd(TestCase testCase, TestOutcome outcome) { } + public void RecordAttachments(IList attachmentSets) { } + public bool EnableShutdownAfterTestRun { get; set; } + public int LaunchProcessWithDebuggerAttached(string filePath, string workingDirectory, string arguments, + IDictionary environmentVariables) => 0; + public void SendMessage(TestMessageLevel testMessageLevel, string message) => Messages.Add(message); + } + + // ---- Loader: mtime-based cache invalidation ---------------------- + + [Test] + public void Loader_ReinspectsAfterAssemblyMtimeChange() + { + // The whole point of the new loader is to pick up `dotnet build` + // output without restarting vstest.console. The mtime sentinel is + // what drives that — first call caches the inspection result with + // a timestamp, repeat calls hit the cache, but a fresher mtime + // forces a re-inspection (and an unload of the previous ALC). + // + // We verify by feeding the loader a non-Fade file (random bytes), + // which logs a warning each time it's actually inspected. Cache + // hits do NOT log; mtime-driven reloads DO. So warning count is + // the observable witness. + var tmpPath = Path.Combine( + Path.GetTempPath(), + "FadeAdapterMtimeTest_" + Guid.NewGuid().ToString("N") + ".dll"); + File.WriteAllBytes(tmpPath, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); + + var logger = new CountingLogger(); + + try + { + FadeTestLaunchableLoader.ResetCacheForTests(); + + // First call inspects, fails (not a real PE), caches the + // negative result with the current mtime. + FadeTestLaunchableLoader.TryLoad(tmpPath, logger, out _); + var warningsAfterFirst = logger.WarningCount; + Assert.That(warningsAfterFirst, Is.GreaterThan(0), + "an invalid DLL should log a warning on inspection"); + + // Same mtime → cache hit, no new warning. + FadeTestLaunchableLoader.TryLoad(tmpPath, logger, out _); + Assert.That(logger.WarningCount, Is.EqualTo(warningsAfterFirst), + "second TryLoad with unchanged mtime should hit the cache without re-inspecting"); + + // Touch — newer mtime forces a fresh inspection on the next call. + File.SetLastWriteTimeUtc(tmpPath, DateTime.UtcNow.AddMinutes(1)); + FadeTestLaunchableLoader.TryLoad(tmpPath, logger, out _); + Assert.That(logger.WarningCount, Is.GreaterThan(warningsAfterFirst), + "after the file's mtime advances, the loader must re-inspect"); + } + finally + { + try { File.Delete(tmpPath); } catch { /* best-effort */ } + FadeTestLaunchableLoader.ResetCacheForTests(); + } + } + + private sealed class CountingLogger : IMessageLogger + { + public int WarningCount { get; private set; } + public int ErrorCount { get; private set; } + public void SendMessage(TestMessageLevel level, string message) + { + if (level == TestMessageLevel.Warning) WarningCount++; + else if (level == TestMessageLevel.Error) ErrorCount++; + } + } + + // ---- Helpers ----------------------------------------------------- + + private sealed class ManifestLaunchable : ITestLaunchable + { + private static readonly byte[] _bytes = new byte[] { (byte)OpCodes.RETURN }; + private static readonly CommandCollection _commands = new CommandCollection(); + private readonly IReadOnlyList _entries; + public ManifestLaunchable(IEnumerable entries) + { + _entries = entries.ToList(); + } + public byte[] Bytecode => _bytes; + public CommandCollection CommandCollection => _commands; + public DebugData DebugData => new DebugData(); + public IReadOnlyList TestManifest => _entries; + } +} diff --git a/FadeBasic/Tests/FadeTestRunnerTests.cs b/FadeBasic/Tests/FadeTestRunnerTests.cs new file mode 100644 index 0000000..8712c52 --- /dev/null +++ b/FadeBasic/Tests/FadeTestRunnerTests.cs @@ -0,0 +1,179 @@ +using FadeBasic; +using FadeBasic.Sdk; + +namespace Tests; + +[TestFixture] +public class FadeTestRunnerTests +{ + private FadeRuntimeContext CreateContext(string src) + { + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, out var ctx, out var errors); + Assert.That(ok, Is.True, + "expected clean compile; got: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + [Test] + public void RunTest_PassingTest_Passes() + { + var src = @" +end + +test foo + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("foo"); + Assert.That(result.passed, Is.True, + "expected pass; failure: " + result.failureMessage); + Assert.That(result.testName, Is.EqualTo("foo")); + Assert.That(result.failureMessage, Is.Null); + } + + [Test] + public void RunTest_FailingAssert_Fails() + { + var src = @" +end + +test foo + assert 1 = 2 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("foo"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureSourceText, Does.Contain("1 = 2"), + "captured assert text should appear in failure source text; got: " + + result.failureSourceText); + } + + [Test] + public void RunTest_UnknownName_ReturnsFailureResult() + { + var src = @" +end + +test foo + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("does_not_exist"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureMessage, Does.Contain("does_not_exist")); + } + + [Test] + public void RunAllTests_MixedResults_CountsCorrect() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest + +test beta + assert 1 = 2 +endtest + +test gamma + assert 5 > 0 +endtest +"; + var ctx = CreateContext(src); + var run = ctx.RunAllTests(); + Assert.That(run.tests.Count, Is.EqualTo(3)); + Assert.That(run.passedCount, Is.EqualTo(2)); + Assert.That(run.failedCount, Is.EqualTo(1)); + Assert.That(run.AllPassed, Is.False); + + var betaResult = run.tests.First(r => r.testName == "beta"); + Assert.That(betaResult.passed, Is.False); + } + + [Test] + public void RunAllTests_AllPassing_AllPassedTrue() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest + +test beta + assert 2 = 2 +endtest +"; + var ctx = CreateContext(src); + var run = ctx.RunAllTests(); + Assert.That(run.AllPassed, Is.True); + Assert.That(run.passedCount, Is.EqualTo(2)); + Assert.That(run.failedCount, Is.EqualTo(0)); + } + + [Test] + public void Tests_Property_ListsManifestEntries() + { + var src = @" +end + +test alpha +endtest + +test beta +endtest +"; + var ctx = CreateContext(src); + var names = ctx.Tests.Select(t => t.name).ToList(); + Assert.That(names, Does.Contain("alpha")); + Assert.That(names, Does.Contain("beta")); + } + + [Test] + public void RunTest_CalledTwice_StatePerCallIsIsolated() + { + // Each RunTest spins up a fresh VM at the test entry point. Running + // twice in a row should produce identical results (no leftover state). + var src = @" +end + +test counter + local n as integer = 0 + n = n + 1 + assert n = 1 +endtest +"; + var ctx = CreateContext(src); + var first = ctx.RunTest("counter"); + var second = ctx.RunTest("counter"); + Assert.That(first.passed, Is.True); + Assert.That(second.passed, Is.True); + } + + [Test] + public void RunTest_AfterRunto_VisibleProgramStateIsAvailable() + { + // Verifies the runto path: the program runs up to the label, then the + // test body asserts on the resulting program state. + var src = @" +x = 0 +x = 42 +checkpoint: +end + +test usesRunto + runto checkpoint + assert x = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("usesRunto"); + Assert.That(result.passed, Is.True, + "expected pass; failure: " + result.failureMessage); + } +} diff --git a/FadeBasic/Tests/FadeTestingAdapterTests.cs b/FadeBasic/Tests/FadeTestingAdapterTests.cs new file mode 100644 index 0000000..1fe5a0b --- /dev/null +++ b/FadeBasic/Tests/FadeTestingAdapterTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Testing; +using FadeBasic.Virtual; +using NUnit.Framework; + +namespace Tests; + +[TestFixture] +public class FadeTestingAdapterTests +{ + [Test] + public void IsTestInvocation_RecognizesCommonMtpFlags() + { + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--list-tests" }), Is.True); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--server" }), Is.True); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--filter", "DisplayName=foo" }), Is.True); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--results-directory=/tmp" }), Is.True); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--diagnostic" }), Is.True); + } + + [Test] + public void IsTestInvocation_DoesNotMatchProgramArgs() + { + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(System.Array.Empty()), Is.False); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "hello", "world" }), Is.False); + // --fade-test=name is the legacy CLI shape; it should NOT route through + // MTP — Launcher.Main handles it. + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--fade-test=foo" }), Is.False); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--fade-test", "foo" }), Is.False); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--fade-list-tests" }), Is.False); + Assert.That(FadeTestApplicationBuilder.IsTestInvocation(new[] { "--fade-test-all" }), Is.False); + } + + [Test] + public void Resolver_FallsBackToDefaultHost_WhenNoAttribute() + { + // Tests assembly has no [FadeTestHost] class attribute; the resolver + // should hand back DefaultFadeTestHost when nothing else is provided. + var host = FadeTestHostResolver.Resolve(null); + Assert.That(host, Is.InstanceOf()); + } + + [Test] + public void Resolver_PrefersExplicitHost() + { + var explicitHost = new StubHost(); + var resolved = FadeTestHostResolver.Resolve(explicitHost); + Assert.That(resolved, Is.SameAs(explicitHost)); + } + + [Test] + public async Task DefaultHost_PassesThroughCustomImplementation() + { + // Verify the contract: a custom IFadeTestHost gets the right fields + // on FadeTestRunContext and can return a result that the framework + // would surface back through MTP. + var stub = new RecordingHost(); + var stubLaunchable = new EmptyLaunchable(); + var entry = new TestManifestEntry { name = "noop" }; + var hostMethods = HostMethodTable.FromCommandCollection(stubLaunchable.CommandCollection); + var ctx = new FadeTestRunContext(stubLaunchable, entry, hostMethods); + + var result = await stub.RunTestAsync(ctx, CancellationToken.None); + Assert.That(stub.LastEntry, Is.SameAs(entry)); + Assert.That(stub.LastLaunchable, Is.SameAs(stubLaunchable)); + Assert.That(result.passed, Is.True); + } + + [Test] + public void DefaultHost_AbstractEntry_IsRejected() + { + var stubLaunchable = new EmptyLaunchable(); + var entry = new TestManifestEntry { name = "abstract_one", isAbstract = true }; + var hostMethods = HostMethodTable.FromCommandCollection(stubLaunchable.CommandCollection); + + var result = FadeTestExecutor.RunTest(stubLaunchable.Bytecode, hostMethods, entry); + Assert.That(result.passed, Is.False); + Assert.That(result.failureMessage, Does.Contain("abstract")); + } + + private sealed class StubHost : IFadeTestHost + { + public Task InitializeAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public Task BeforeAllTestsAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public Task RunTestAsync(FadeTestRunContext c, CancellationToken ct) + => Task.FromResult(new FadeTestResult { testName = c.Entry.name, passed = true }); + public Task AfterAllTestsAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public ValueTask DisposeAsync() => default; + } + + private sealed class RecordingHost : IFadeTestHost + { + public TestManifestEntry? LastEntry; + public ITestLaunchable? LastLaunchable; + + public Task InitializeAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public Task BeforeAllTestsAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public Task RunTestAsync(FadeTestRunContext c, CancellationToken ct) + { + LastEntry = c.Entry; + LastLaunchable = c.Launchable; + return Task.FromResult(new FadeTestResult { testName = c.Entry.name, passed = true }); + } + public Task AfterAllTestsAsync(FadeTestSessionContext c, CancellationToken ct) => Task.CompletedTask; + public ValueTask DisposeAsync() => default; + } + + private sealed class EmptyLaunchable : ITestLaunchable + { + // A single HALT byte (opcode value mirrors what the compiler emits at + // the end of every program). The VM is expected to halt immediately + // when its IP starts here, which is good enough for "does the host + // round-trip a result" coverage. + private static readonly byte[] _bytes = new byte[] { (byte)OpCodes.RETURN }; + private static readonly CommandCollection _commands = new CommandCollection(); + + public byte[] Bytecode => _bytes; + public CommandCollection CommandCollection => _commands; + public DebugData DebugData => new DebugData(); + public IReadOnlyList TestManifest => new System.Collections.Generic.List(); + } +} diff --git a/FadeBasic/Tests/FormatTests.cs b/FadeBasic/Tests/FormatTests.cs index a493cef..6fb17a0 100644 --- a/FadeBasic/Tests/FormatTests.cs +++ b/FadeBasic/Tests/FormatTests.cs @@ -146,6 +146,33 @@ public void Format_CaseIgnore(string src, string expected, int editCount=1) if x n endif +")] + [TestCase(@" +test x +print 1 +endtest +", @" +test x + print 1 +endtest +")] + [TestCase(@" +#macro x +print 1 +#endmacro +", @" +#macro x + print 1 +#endmacro +")] + [TestCase(@" +#tokenize x +print 1 +#endtokenize +", @" +#tokenize x + print 1 +#endtokenize ")] public void Format_SpaceSizes(string src, string expected, int editCount=1) { diff --git a/FadeBasic/Tests/LauncherTestArgsTests.cs b/FadeBasic/Tests/LauncherTestArgsTests.cs new file mode 100644 index 0000000..9fdacc1 --- /dev/null +++ b/FadeBasic/Tests/LauncherTestArgsTests.cs @@ -0,0 +1,182 @@ +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.Sdk; + +namespace Tests; + +[TestFixture] +public class LauncherTestArgsTests +{ + private FadeRuntimeContext CreateContext(string src) + { + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, out var ctx, out var errors); + Assert.That(ok, Is.True, + "expected clean compile; got: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + private (int exit, string stdout, string stderr) DispatchWithCapture(ITestLaunchable launchable, string[] args) + { + var stdout = new StringWriter(); + var stderr = new StringWriter(); + var savedOut = Console.Out; + var savedErr = Console.Error; + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var handled = Launcher.TryDispatchTestArgs(launchable, args, out var exit); + Assert.That(handled, Is.True, "expected test args to be handled"); + return (exit, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(savedOut); + Console.SetError(savedErr); + } + } + + [Test] + public void Dispatch_FadeListTests_PrintsManifestAndReturnsZero() + { + var src = @" +end + +test alpha +endtest + +test beta +endtest + +abstract test fixture +endtest +"; + var ctx = CreateContext(src); + var (exit, stdout, _) = DispatchWithCapture(ctx, new[] { "--fade-list-tests" }); + Assert.That(exit, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("alpha")); + Assert.That(stdout, Does.Contain("beta")); + Assert.That(stdout, Does.Not.Contain("fixture"), + "abstract tests should not appear in --fade-list-tests output"); + } + + [Test] + public void Dispatch_FadeTestEqualsName_RunsSingleTest_ExitsZero() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var (exit, stdout, _) = DispatchWithCapture(ctx, new[] { "--fade-test=alpha" }); + Assert.That(exit, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("PASS")); + Assert.That(stdout, Does.Contain("alpha")); + } + + [Test] + public void Dispatch_FadeTestSpaceName_AlsoSupported() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var (exit, _, _) = DispatchWithCapture(ctx, new[] { "--fade-test", "alpha" }); + Assert.That(exit, Is.EqualTo(0)); + } + + [Test] + public void Dispatch_FadeTest_FailingTest_ExitsOne() + { + var src = @" +end + +test broken + assert 1 = 2 +endtest +"; + var ctx = CreateContext(src); + var (exit, stdout, _) = DispatchWithCapture(ctx, new[] { "--fade-test=broken" }); + Assert.That(exit, Is.EqualTo(1)); + Assert.That(stdout, Does.Contain("FAIL")); + } + + [Test] + public void Dispatch_FadeTestAll_RunsAllTests() + { + var src = @" +end + +test passes + assert 1 = 1 +endtest + +test fails + assert 1 = 2 +endtest +"; + var ctx = CreateContext(src); + var (exit, stdout, _) = DispatchWithCapture(ctx, new[] { "--fade-test-all" }); + Assert.That(exit, Is.EqualTo(1), "any failure should produce exit code 1"); + Assert.That(stdout, Does.Contain("PASS")); + Assert.That(stdout, Does.Contain("FAIL")); + Assert.That(stdout, Does.Contain("1 passed")); + Assert.That(stdout, Does.Contain("1 failed")); + } + + [Test] + public void Dispatch_FadeTestAll_AllPassing_ExitZero() + { + var src = @" +end + +test a + assert 1 = 1 +endtest + +test b + assert 2 = 2 +endtest +"; + var ctx = CreateContext(src); + var (exit, _, _) = DispatchWithCapture(ctx, new[] { "--fade-test-all" }); + Assert.That(exit, Is.EqualTo(0)); + } + + [Test] + public void Dispatch_UnknownTestName_ExitsOne() + { + var src = @" +end + +test foo +endtest +"; + var ctx = CreateContext(src); + var (exit, _, stderr) = DispatchWithCapture(ctx, new[] { "--fade-test=does_not_exist" }); + Assert.That(exit, Is.EqualTo(1)); + Assert.That(stderr, Does.Contain("does_not_exist")); + } + + [Test] + public void Dispatch_NoTestArgs_ReturnsFalse() + { + var src = @" +end + +test foo +endtest +"; + var ctx = CreateContext(src); + var handled = Launcher.TryDispatchTestArgs(ctx, new[] { "--something-else" }, out var exit); + Assert.That(handled, Is.False, + "non-test args should NOT be handled by the test dispatcher"); + } +} diff --git a/FadeBasic/Tests/MockExecutionTests.cs b/FadeBasic/Tests/MockExecutionTests.cs new file mode 100644 index 0000000..b841149 --- /dev/null +++ b/FadeBasic/Tests/MockExecutionTests.cs @@ -0,0 +1,214 @@ +using FadeBasic; +using FadeBasic.Sdk; + +namespace Tests; + +[TestFixture] +public class MockExecutionTests +{ + [SetUp] + public void Reset() + { + TestCommands.waitMsCallCount = 0; + } + + private FadeRuntimeContext CreateContext(string src) + { + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out var ctx, out var errors); + Assert.That(ok, Is.True, + "expected clean compile; got: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + [Test] + public void MockVoid_BareForm_SuppressesRealCall() + { + // `mock wait ms` (no body) installs a void mock. The C# WiatMs + // method should NOT be called, so waitMsCallCount stays at 0. + var src = @" +checkpoint: +wait ms 50 +end + +test no_real_wait + mock wait ms + runto checkpoint + wait ms 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("no_real_wait"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.waitMsCallCount, Is.EqualTo(0), + "wait ms should have been mocked away both in main-body (via runto) and test body"); + } + + [Test] + public void MockReturns_OverridesReturnValue() + { + var src = @" +end + +test mocked_screen_width + mock screen width returns 42 + assert screen width() = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("mocked_screen_width"); + Assert.That(result.passed, Is.True, + "expected screen width() to return 42; failure: " + result.failureMessage); + } + + [Test] + public void NoMock_RealCommandRuns() + { + // Without any mock, screen width returns its default (5 per TestCommands). + var src = @" +end + +test no_mock + assert screen width() = 5 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("no_mock"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockForbid_FailsTestWhenCommandCalled() + { + var src = @" +end + +test forbidden + mock wait ms forbid + wait ms 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("forbidden"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureMessage, Does.Contain("forbidden")); + Assert.That(result.failureMessage, Does.Contain("wait ms")); + } + + [Test] + public void MockReturns_AppliesToProgramRunByRunto() + { + // The mock installs first; then runto drives the program past a call + // to screen width. The program-side call must also see the mocked value. + var src = @" +local w as integer +w = screen width() +checkpoint: +end + +test mocked_via_runto + mock screen width returns 99 + runto checkpoint + assert w = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("mocked_via_runto"); + Assert.That(result.passed, Is.True, + "program code should observe the mocked value when run via runto; failure: " + + result.failureMessage); + } + + [Test] + public void ClearMock_RestoresRealBehavior() + { + var src = @" +end + +test clear_mock + mock screen width returns 42 + assert screen width() = 42 + clear mock screen width + assert screen width() = 5 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("clear_mock"); + Assert.That(result.passed, Is.True, + "after `clear mock`, real implementation should run again; failure: " + + result.failureMessage); + } + + [Test] + public void ClearMocks_RemovesAllRegistrations() + { + var src = @" +end + +test clear_all + mock screen width returns 42 + mock wait ms + clear mocks + assert screen width() = 5 + wait ms 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("clear_all"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.waitMsCallCount, Is.EqualTo(1), + "wait ms should have been called once after `clear mocks`"); + } + + [Test] + public void MockReturns_OnVoidCommand_DegradesToVoid() + { + // A user mocking a void command sometimes writes a `returns` body + // thinking it's required. The compiler should silently treat that + // as a void mock (no value pushed) rather than corrupting the stack + // and falling through to the real implementation. + var src = @" +checkpoint: +wait ms 50 +end + +test mocked_with_returns + mock wait ms + returns 0 + endmock + runto checkpoint + wait ms 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("mocked_with_returns"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.waitMsCallCount, Is.EqualTo(0), + "wait ms should be fully suppressed even when written as `returns 0`"); + } + + [Test] + public void MockIsolation_BetweenTestRuns() + { + // Each RunTest gets a fresh VM. A mock installed in one test must + // not affect a sibling test in the same context. + var src = @" +end + +test installs_mock + mock screen width returns 42 + assert screen width() = 42 +endtest + +test sees_no_mock + assert screen width() = 5 +endtest +"; + var ctx = CreateContext(src); + var first = ctx.RunTest("installs_mock"); + var second = ctx.RunTest("sees_no_mock"); + Assert.That(first.passed, Is.True, first.failureMessage); + Assert.That(second.passed, Is.True, + "second test must not see the first test's mock; failure: " + second.failureMessage); + } +} diff --git a/FadeBasic/Tests/MockParserTests.cs b/FadeBasic/Tests/MockParserTests.cs new file mode 100644 index 0000000..c79d181 --- /dev/null +++ b/FadeBasic/Tests/MockParserTests.cs @@ -0,0 +1,334 @@ +using FadeBasic; +using FadeBasic.Ast; + +namespace Tests; + +[TestFixture] +public class MockParserTests +{ + private ProgramNode Parse(string src, out List errors) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + errors = prog.GetAllErrors(); + return prog; + } + + private MockStatement FindFirstMock(ProgramNode prog) + { + foreach (var t in prog.tests) + { + foreach (var stmt in t.statements) + { + if (stmt is MockStatement m) return m; + } + } + return null; + } + + private ClearMockStatement FindFirstClearMock(ProgramNode prog) + { + foreach (var t in prog.tests) + { + foreach (var stmt in t.statements) + { + if (stmt is ClearMockStatement c) return c; + } + } + return null; + } + + [Test] + public void Mock_InlineReturns_ParsesAsAlwaysFrequency() + { + var src = @" +test foo + mock screen width returns 10 +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + + var mock = FindFirstMock(prog); + Assert.That(mock, Is.Not.Null); + Assert.That(mock.commandName, Is.EqualTo("screen width")); + Assert.That(mock.entries.Count, Is.EqualTo(1)); + Assert.That(mock.entries[0].kind, Is.EqualTo(MockEntryKind.Returns)); + Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Always)); + Assert.That(mock.entries[0].returnExpression, Is.Not.Null); + } + + [Test] + public void Mock_InlineForbid_ParsesAsAlwaysFrequency() + { + var src = @" +test foo + mock screen width forbid +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty); + var mock = FindFirstMock(prog); + Assert.That(mock.entries.Count, Is.EqualTo(1)); + Assert.That(mock.entries[0].kind, Is.EqualTo(MockEntryKind.Forbid)); + Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Always)); + } + + [Test] + public void Mock_FrequencyOnce_Parses() + { + var src = @" +test foo + mock screen width returns 10 once +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty); + var mock = FindFirstMock(prog); + Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Once)); + } + + [Test] + public void Mock_FrequencyNTimes_Parses() + { + var src = @" +test foo + mock screen width returns 10 3 times +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected clean parse; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.NTimes)); + Assert.That(mock.entries[0].countExpression, Is.Not.Null); + } + + [Test] + public void Mock_FrequencyAlwaysExplicit_Parses() + { + var src = @" +test foo + mock screen width returns 10 always +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty); + var mock = FindFirstMock(prog); + Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Always)); + } + + [Test] + public void Mock_BlockForm_MultipleEntries_Parses() + { + var src = @" +test foo + mock screen width + returns 10 once + returns 20 once + returns 5 always + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected clean parse; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.entries.Count, Is.EqualTo(3)); + Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Once)); + Assert.That(mock.entries[1].frequency, Is.EqualTo(MockFrequencyKind.Once)); + Assert.That(mock.entries[2].frequency, Is.EqualTo(MockFrequencyKind.Always)); + } + + [Test] + public void Mock_MissingEndMock_Errors() + { + var src = @" +test foo + mock screen width + returns 10 once +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), + Is.True, + "expected MockMissingEndMock; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_UnknownCommand_Errors() + { + // `not_a_real_command` won't merge into a CommandWord token, so the + // parser sees a missing command name. + var src = @" +test foo + mock not_a_real_command returns 10 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingCommandName)), + Is.True, + "expected MockMissingCommandName; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_UnreachableEntry_AfterAlways_Warns() + { + var src = @" +test foo + mock screen width + returns 10 always + returns 20 once + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockUnreachableEntry)), + Is.True, + "expected MockUnreachableEntry; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void ClearMock_SingleCommand_Parses() + { + var src = @" +test foo + clear mock screen width +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var clear = FindFirstClearMock(prog); + Assert.That(clear, Is.Not.Null); + Assert.That(clear.commandName, Is.EqualTo("screen width")); + } + + [Test] + public void ClearMocks_All_Parses() + { + var src = @" +test foo + clear mocks +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty); + var clear = FindFirstClearMock(prog); + Assert.That(clear, Is.Not.Null); + Assert.That(clear.commandName, Is.Null, "clear mocks should have null commandName (= clear all)"); + } + + [Test] + public void Clear_WithoutMockOrMocks_Errors() + { + var src = @" +test foo + clear something +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ClearMockMissingTarget)), + Is.True, + "expected ClearMockMissingTarget; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_BlockForm_MixedReturnsAndForbid_Parses() + { + var src = @" +test foo + mock screen width + returns 10 once + forbid always + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.entries.Count, Is.EqualTo(2)); + Assert.That(mock.entries[0].kind, Is.EqualTo(MockEntryKind.Returns)); + Assert.That(mock.entries[1].kind, Is.EqualTo(MockEntryKind.Forbid)); + } + + [Test] + public void Mock_StackedInline_StopsAtNewline_NoEndMockNeeded() + { + // Stacked inline form via colon — DEFER-style. No endmock required; + // the statement ends at the first newline. + var src = @" +test foo + mock screen width returns 10 once: returns 20 once +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.entries.Count, Is.EqualTo(2)); + Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Once)); + Assert.That(mock.entries[1].frequency, Is.EqualTo(MockFrequencyKind.Once)); + } + + [Test] + public void Mock_BlockForm_MissingEndMock_DoesNotConsumeEndTest() + { + // When `endmock` is missing, the mock parser must NOT consume the + // surrounding `endtest`; the missing-endmock error is reported and + // the test parser still terminates correctly. + var src = @" +test foo + mock screen width + returns 10 once +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), + Is.True, + "expected MockMissingEndMock; got: " + string.Join(", ", errs.Select(e => e.Display))); + // The test should still be properly closed (the test node exists with + // the mock as its only top-level statement). + Assert.That(prog.tests.Count, Is.EqualTo(1)); + } + + [Test] + public void Mock_OutsideTest_Errors() + { + var src = @" +mock screen width returns 10 +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockOutsideTest)), + Is.True, + "expected MockOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void ClearMock_OutsideTest_Errors() + { + var src = @" +clear mocks +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ClearMockOutsideTest)), + Is.True, + "expected ClearMockOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Assert_OutsideTest_Errors() + { + var src = @" +assert 1 = 1 +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.AssertOutsideTest)), + Is.True, + "expected AssertOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/MusicFbasicReproTests.cs b/FadeBasic/Tests/MusicFbasicReproTests.cs new file mode 100644 index 0000000..d0a2b2e --- /dev/null +++ b/FadeBasic/Tests/MusicFbasicReproTests.cs @@ -0,0 +1,52 @@ +using FadeBasic; +using FadeBasic.Sdk; +using FadeBasic.Lib.Standard; +using NUnit.Framework; + +namespace Tests; + +[TestFixture] +public class MusicFbasicReproTests +{ + private const string Src = @" +print ""starting"" + +global x = 4 + +wait ms 1000 +lbl1: + +y = add(2) +lbl2: + +function add(a) + sum = a + x + + addfinal: +endfunction sum + +test abc + print ""running test"" + mock wait ms + returns 0 + endmock + + runto lbl1 + assert x = 4 + runto lbl2: + assert y > x +endtest +"; + + [Test] + public void MusicFbasic_RunsCleanly() + { + var commands = new CommandCollection(new ConsoleCommands(), new StandardCommands()); + var ok = Fade.TryCreateFromString(Src, commands, out var ctx, out var errors); + Assert.That(ok, Is.True, errors?.ToDisplay() ?? "(null errors)"); + + var result = ctx.RunTest("abc"); + Assert.That(result.passed, Is.True, + "expected pass; failure: " + result.failureMessage); + } +} diff --git a/FadeBasic/Tests/ParserTests.cs b/FadeBasic/Tests/ParserTests.cs index d7b8cc0..372b033 100644 --- a/FadeBasic/Tests/ParserTests.cs +++ b/FadeBasic/Tests/ParserTests.cs @@ -727,6 +727,22 @@ public void AnasUnfunDemo() } + [Test] + public void TestBlock_Function() + { + var input = @" +test block + x() + function x() + endfunction +endtest +"; + var parser = MakeParser(input); + var prog = parser.ParseProgram(); + prog.AssertNoParseErrors(); + } + + [Test] public void Default_int() { diff --git a/FadeBasic/Tests/RuntoCompilerTests.cs b/FadeBasic/Tests/RuntoCompilerTests.cs new file mode 100644 index 0000000..d4a3963 --- /dev/null +++ b/FadeBasic/Tests/RuntoCompilerTests.cs @@ -0,0 +1,275 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class RuntoCompilerTests +{ + private Compiler Compile(string src, out ProgramNode prog) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return compiler; + } + + [Test] + public void Runto_Statement_Parses() + { + var src = @" +test foo + runto someLabel +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].statements.Count, Is.EqualTo(1)); + var rt = prog.tests[0].statements[0] as RuntoStatement; + Assert.That(rt, Is.Not.Null); + Assert.That(rt.targetLabel, Is.EqualTo("somelabel")); + } + + [Test] + public void Runto_BlockForm_WithMaxCycles_Parses() + { + var src = @" +test foo + runto someLabel + max cycles 1000 + endrunto +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var rt = prog.tests[0].statements[0] as RuntoStatement; + Assert.That(rt, Is.Not.Null); + Assert.That(rt.targetLabel, Is.EqualTo("somelabel")); + Assert.That(rt.maxCyclesExpression, Is.Not.Null); + } + + [Test] + public void Runto_MissingLabel_Errors() + { + var src = @" +test foo + runto +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.RuntoMissingLabel)), Is.True); + } + + [Test] + public void Compiler_TestEntryPointsRecorded() + { + var src = @" +test alpha +endtest + +test beta +endtest +"; + var compiler = Compile(src, out _); + Assert.That(compiler.TestManifest.Count, Is.EqualTo(2)); + Assert.That(compiler.TestManifest[0].name, Is.EqualTo("alpha")); + Assert.That(compiler.TestManifest[1].name, Is.EqualTo("beta")); + // Different tests have distinct entry points. + Assert.That(compiler.TestManifest[0].entryPointAddress, + Is.Not.EqualTo(compiler.TestManifest[1].entryPointAddress)); + } + + [Test] + public void Compiler_AbstractTestRecorded() + { + var src = @" +abstract test root +endtest + +test child from root +endtest +"; + var compiler = Compile(src, out _); + Assert.That(compiler.TestManifest.Count, Is.EqualTo(2)); + Assert.That(compiler.TestManifest[0].name, Is.EqualTo("root")); + Assert.That(compiler.TestManifest[0].isAbstract, Is.True); + Assert.That(compiler.TestManifest[1].name, Is.EqualTo("child")); + Assert.That(compiler.TestManifest[1].isAbstract, Is.False); + Assert.That(compiler.TestManifest[1].fromParent, Is.EqualTo("root")); + } + + [Test] + public void Compiler_RuntoTargetLabel_EmitsYield() + { + // A label referenced by runto should have RUNTO_YIELD emitted right after + // its NOOP. We verify by inspecting the bytecode. + var src = @" +mylabel: +end + +test foo + runto mylabel +endtest +"; + var compiler = Compile(src, out var prog); + var program = compiler.Program.ToArray(); + + // Find the NOOP for 'mylabel' — search the bytecode for OpCodes.NOOP + // followed by OpCodes.RUNTO_YIELD. There should be exactly one such pair. + var pairs = 0; + for (var i = 0; i < program.Length - 1; i++) + { + if (program[i] == OpCodes.NOOP && program[i + 1] == OpCodes.RUNTO_YIELD) + { + pairs++; + } + } + Assert.That(pairs, Is.GreaterThanOrEqualTo(1), + "expected at least one NOOP+RUNTO_YIELD pair for the runto target label"); + } + + [Test] + public void Compiler_NonRuntoLabel_NoYield() + { + // Without any runto referencing it, the label should NOT have a RUNTO_YIELD + // following its NOOP. + var src = @" +mylabel: +end +"; + var compiler = Compile(src, out var prog); + var program = compiler.Program.ToArray(); + + for (var i = 0; i < program.Length - 1; i++) + { + if (program[i] == OpCodes.NOOP && program[i + 1] == OpCodes.RUNTO_YIELD) + { + Assert.Fail("found unexpected NOOP+RUNTO_YIELD pair (no test references this label)"); + } + } + } + + [Test] + public void Compiler_RunBuild_NoTests_NoYieldOpcodes() + { + // Programs with no tests should never emit RUNTO_YIELD opcodes anywhere. + // Zero production cost in `dotnet run` builds. + var src = @" +mylabel: +goto mylabel +end +"; + var compiler = Compile(src, out _); + var program = compiler.Program.ToArray(); + + // Need to bound the search to just the code section (before interned data), + // otherwise we'd find raw byte 65 inside the JSON tail. + var internedStart = System.BitConverter.ToInt32(program, 0); + for (var i = 0; i < internedStart; i++) + { + Assert.That(program[i], Is.Not.EqualTo(OpCodes.RUNTO_YIELD), + "no RUNTO_YIELD should be emitted when no tests reference labels"); + } + } + + [Test] + public void Runto_SingleLine_WithMaxCycles_NoEndRuntoNeeded() + { + // DEFER-style single-line form: `runto label max cycles N` on one line, + // no endrunto required. + var src = @" +mylabel: +end + +test foo + runto mylabel max cycles 1000 +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var rt = prog.tests[0].statements[0] as RuntoStatement; + Assert.That(rt, Is.Not.Null); + Assert.That(rt.targetLabel, Is.EqualTo("mylabel")); + Assert.That(rt.maxCyclesExpression, Is.Not.Null); + } + + [Test] + public void Runto_OutsideTest_Errors() + { + var src = @" +mylabel: +end + +runto mylabel +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.RuntoOutsideTest)), + Is.True, + "expected RuntoOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_MaxCycles_IsRealKeyword_NotSoftStringMatch() + { + // `max` and `cycles` should NOT be treated as bare identifiers; the + // lexer recognizes `max cycles` as a single multi-word keyword token. + var src = @" +mylabel: +end + +test foo + runto mylabel + max cycles 1000 + endrunto +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + // A `KeywordMaxCycles` token should appear among the lexed tokens. + Assert.That(lex.tokens.Any(t => t.type == LexemType.KeywordMaxCycles), + Is.True, + "expected the lexer to emit a KeywordMaxCycles token"); + } + + [Test] + public void Compiler_RuntoStatement_EmitsRuntoOpCode() + { + var src = @" +mylabel: +end + +test foo + runto mylabel +endtest +"; + var compiler = Compile(src, out _); + var program = compiler.Program.ToArray(); + + // Look for at least one RUNTO opcode in the test region. + var found = false; + var internedStart = System.BitConverter.ToInt32(program, 0); + for (var i = 0; i < internedStart; i++) + { + if (program[i] == OpCodes.RUNTO) { found = true; break; } + } + Assert.That(found, Is.True, "expected a RUNTO opcode in the compiled output"); + } +} diff --git a/FadeBasic/Tests/RuntoNavigationTests.cs b/FadeBasic/Tests/RuntoNavigationTests.cs new file mode 100644 index 0000000..7d5a6c8 --- /dev/null +++ b/FadeBasic/Tests/RuntoNavigationTests.cs @@ -0,0 +1,91 @@ +using FadeBasic; +using FadeBasic.Ast; + +namespace Tests; + +[TestFixture] +public class RuntoNavigationTests +{ + private ProgramNode Parse(string src, out List errors) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + errors = prog.GetAllErrors(); + return prog; + } + + [Test] + public void Runto_DeclaredFromSymbol_PointsAtLabelDeclaration() + { + // After scope-resolution, a RuntoStatement's DeclaredFromSymbol should + // point at the LabelDeclarationNode. This is what powers LSP + // go-to-definition and find-references for runto sites. + var src = @" +x = 5 +checkpoint: +end + +test foo + runto checkpoint +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected clean parse; got: " + string.Join(", ", errs.Select(e => e.Display))); + + var runto = prog.tests[0].statements + .OfType() + .First(); + Assert.That(runto.DeclaredFromSymbol, Is.Not.Null, + "runto should have its target label resolved into DeclaredFromSymbol"); + Assert.That(runto.DeclaredFromSymbol.source, Is.TypeOf()); + + var label = (LabelDeclarationNode)runto.DeclaredFromSymbol.source; + Assert.That(label.label, Is.EqualTo("checkpoint")); + } + + [Test] + public void Runto_UnknownTarget_HasNullDeclaredFromSymbol() + { + // If the label doesn't exist anywhere, DeclaredFromSymbol stays null. + // (The strictness visitor / compiler will surface the error.) + var src = @" +end + +test foo + runto does_not_exist +endtest +"; + var prog = Parse(src, out _); + var runto = prog.tests[0].statements + .OfType() + .First(); + Assert.That(runto.DeclaredFromSymbol, Is.Null); + } + + [Test] + public void Runto_FunctionInternalLabel_ResolvesAcrossScopes() + { + // A test's `runto fnInner` should resolve to the function's internal + // label even though the label is declared inside a function body. + var src = @" +do_work() +end + +function do_work() +fnInner: +endfunction + +test foo + runto fnInner +endtest +"; + var prog = Parse(src, out var errs); + var runto = prog.tests[0].statements.OfType().First(); + Assert.That(runto.DeclaredFromSymbol, Is.Not.Null); + var label = (LabelDeclarationNode)runto.DeclaredFromSymbol.source; + Assert.That(label.label, Is.EqualTo("fnInner").IgnoreCase); + } +} diff --git a/FadeBasic/Tests/SourceMapTests.cs b/FadeBasic/Tests/SourceMapTests.cs index 5938919..dfa23c1 100644 --- a/FadeBasic/Tests/SourceMapTests.cs +++ b/FadeBasic/Tests/SourceMapTests.cs @@ -1,7 +1,9 @@ using ApplicationSupport.Code; using FadeBasic; using FadeBasic.ApplicationSupport.Project; +using FadeBasic.Launch; using FadeBasic.Sdk; +using FadeBasic.Virtual; namespace Tests; @@ -41,9 +43,77 @@ public void Test2() var expr = unit.program.statements[2]; var range = map.GetOriginalRange(new TokenRange { start = expr.StartToken, end = expr.EndToken }); - + // the fact that code reaches here is good! Assert.That(range.startLine, Is.EqualTo(2)); Assert.That(range.endLine, Is.EqualTo(2)); } + + [Test] + public void ApplySourceMap_StampsFilePath_AndRemapsLineNumbers() + { + // Two files concatenated; the manifest entry for `bar`'s test + // sits in the second file at its own (in-file) line number. + // ApplySourceMap should: + // - stamp sourceFilePath = "bar.fbasic" + // - rewrite sourceLine from concatenated-coords back to in-file coords + var fileA = @"print ""hello"" + +test alpha +endtest"; + var fileB = @" + + + +test beta +endtest"; + + var map = SourceMap.CreateSourceMap( + new List { "foo.fbasic", "bar.fbasic" }, + path => path.EndsWith("foo.fbasic") ? fileA.SplitNewLines() : fileB.SplitNewLines()); + + // Compile from the concatenated source. The compiler stamps each + // entry's sourceLine in concatenated coords. + FadeRuntimeContext.TryFromSource(map.fullSource, TestCommands.CommandsForTesting, + out var ctx, out var errs, map); + Assert.That(errs, Is.Null, + "expected a clean compile; got: " + (errs?.ToDisplay() ?? "")); + + var alpha = ctx.Compiler.TestManifest.First(t => t.name == "alpha"); + var beta = ctx.Compiler.TestManifest.First(t => t.name == "beta"); + + Assert.That(alpha.sourceFilePath, Does.EndWith("foo.fbasic"), + "alpha lives in foo.fbasic"); + Assert.That(beta.sourceFilePath, Does.EndWith("bar.fbasic"), + "beta lives in bar.fbasic — the per-entry plumbing is what enables this"); + // After ApplySourceMap, sourceLine is the in-file line, not the + // concatenated-source line. beta's `test` is on line 4 of bar.fbasic + // (0-based: 4), not somewhere far down in the concat. + Assert.That(beta.sourceLine, Is.LessThan(10), + "beta's sourceLine should be in bar.fbasic-local coordinates"); + } + + [Test] + public void ApplySourceMap_Idempotent_DoesNotDoubleShift() + { + // If a manifest entry has already been remapped (sourceFilePath set), + // a second call shouldn't move sourceLine again. The build pipeline + // and the SDK can both reach this code path; the two together must + // not double-shift. + var entry = new TestManifestEntry + { + name = "foo", + sourceLine = 3, + sourceFilePath = "already.fbasic" + }; + var map = SourceMap.CreateSourceMap( + new List { "x.fbasic" }, + _ => new[] { "a", "b", "c" }); + + LaunchUtil.ApplySourceMap(new[] { entry }, map); + + Assert.That(entry.sourceFilePath, Is.EqualTo("already.fbasic")); + Assert.That(entry.sourceLine, Is.EqualTo(3), + "an entry that already carries a source path must not be re-mapped"); + } } \ No newline at end of file diff --git a/FadeBasic/Tests/TestCommmands.cs b/FadeBasic/Tests/TestCommmands.cs index 07cec66..6e1e663 100644 --- a/FadeBasic/Tests/TestCommmands.cs +++ b/FadeBasic/Tests/TestCommmands.cs @@ -126,7 +126,7 @@ public static void WaitKey() [FadeBasicCommand("wait ms")] public static void WiatMs(int amount) { - + waitMsCallCount++; } // [FadeBasicCommand("callDemo")] @@ -169,6 +169,11 @@ public static void Tuna(params object[] variable) Console.WriteLine(string.Join("\n", variable)); } + // Counter incremented every time `wait ms` is invoked (real path only, + // mocks bypass the executor). Mock execution tests reset and inspect + // this to confirm the host method was/wasn't actually called. + public static int waitMsCallCount = 0; + public static List staticPrintBuffer = new List(); [FadeBasicCommand("static print")] public static void StaticPrint(params object[] variable) diff --git a/FadeBasic/Tests/TestExecutionTests.cs b/FadeBasic/Tests/TestExecutionTests.cs new file mode 100644 index 0000000..76143b3 --- /dev/null +++ b/FadeBasic/Tests/TestExecutionTests.cs @@ -0,0 +1,203 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestExecutionTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunTest(string src, string testName) + { + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == testName); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute().MoveNext(); + return vm; + } + + [Test] + public void Execute_EmptyTest_RunsToCompletion() + { + var src = @" +test empty +endtest +"; + var vm = RunTest(src, "empty"); + // Just verify no exceptions and the vm halted normally. + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } + + + [Test] + public void Execute_EmptyTest_CanHaveFunction() + { + var src = @" +test funcSupport + x() + + function x() + endfunction +endtest +"; + var vm = RunTest(src, "funcSupport"); + // Just verify no exceptions and the vm halted normally. + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } + + + [Test] + public void Execute_TestRunsToProgramLabel() + { + // A test that issues `runto :start`. The program top-level body has + // a `start:` label. After the test runs, programResumeIP should sit + // right after the label. + var src = @" +x = 1 +start: +x = 2 +end + +test foo + runto start +endtest +"; + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == "foo"); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute().MoveNext(); + + // After yield, runtoStack is empty and programResumeIP is the post-yield IP. + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + Assert.That(vm.programResumeIP, Is.GreaterThan(4), + "programResumeIP should have advanced past the entry header"); + } + + [Test] + public void Execute_MultipleRuntos_ProgressThroughProgram() + { + var src = @" +first: +x = 1 +second: +x = 2 +end + +test foo + runto first + runto second +endtest +"; + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == "foo"); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute().MoveNext(); + + Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); + } + + [Test] + public void Execute_DefaultEntryPoint_RunsProgramNotTest() + { + // Without specifying entry point, the VM starts at default (4) and + // runs the program body. The test body should not be entered. + var src = @" +somewhere: +x = 7 +end + +test foo + runto somewhere +endtest +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program); // default entry + vm.hostMethods = compiler.methodTable; + vm.Execute().MoveNext(); + + Assert.That(vm.runtoStack.Count, Is.EqualTo(0), + "default entry runs program code only; runtoStack should never get touched"); + } + + [Test] + public void Execute_TwoTests_IndependentVMs() + { + // Each test gets its own fresh VM. They shouldn't share state. + var src = @" +test alpha +endtest + +test beta +endtest +"; + var (compiler, program) = Compile(src); + var alpha = compiler.TestManifest.First(t => t.name == "alpha"); + var beta = compiler.TestManifest.First(t => t.name == "beta"); + Assert.That(alpha.entryPointAddress, Is.Not.EqualTo(beta.entryPointAddress)); + + var vmA = new VirtualMachine(program, alpha.entryPointAddress); + vmA.hostMethods = compiler.methodTable; + vmA.Execute().MoveNext(); + + var vmB = new VirtualMachine(program, beta.entryPointAddress); + vmB.hostMethods = compiler.methodTable; + vmB.Execute().MoveNext(); + + // Both halt cleanly. + Assert.That(vmA.runtoStack.Count, Is.EqualTo(0)); + Assert.That(vmB.runtoStack.Count, Is.EqualTo(0)); + } + + [Test] + public void Execute_NormalProgram_StillRunsUnchanged() + { + // Smoke test: a regular Fade program with no tests should compile and + // execute exactly as before. Tests-related code paths add no overhead + // and don't alter behavior when no tests are present. + var src = @" +x = 5 +y = x + 3 +end +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + + // y should have been computed as 8 + Assert.That(vm.dataRegisters[1], Is.EqualTo(8)); + } + + + + [Test] + public void Execute_GlobalVariables() + { + var src = @" +test foo + GLOBAL x = 32 +endtest + +print x `this should result in an error. +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); + } +} diff --git a/FadeBasic/Tests/TestFunctionTests.cs b/FadeBasic/Tests/TestFunctionTests.cs new file mode 100644 index 0000000..ea772cf --- /dev/null +++ b/FadeBasic/Tests/TestFunctionTests.cs @@ -0,0 +1,256 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestFunctionTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunTest(string src, string testName) + { + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == testName); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + vm.Execute().MoveNext(); + return vm; + } + + [Test] + public void TestFunction_ParsesIntoTestNode() + { + var src = @" +test foo + function helper() + endfunction 5 +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + Assert.That(prog.tests[0].functions.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].functions[0].name, Is.EqualTo("helper")); + } + + [Test] + public void TestFunction_CalledFromTestBody_Works() + { + var src = @" +function helper() +endfunction 42 + +test foo + local result as integer + result = helper() + assert result = 42 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + + [Test] + public void TestFunction_CalledFromTestBody_DependsOnGlobalState_Works() + { + var src = @" + +global x = 32 +function helper() +endfunction x + 10 + +test foo + local result as integer + result = helper() + assert result = 10 `by default, x is zero; so when no runto is used, this is just 0+10 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + + + // buuut, if a runto is used, and the state is set; then it can work again. + src = @" + +global x = 32 + +_def: +function helper() +endfunction x + 10 + +test foo + local result as integer + runto _def + result = helper() + assert result = 42 +endtest +"; + vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void TestFunction_DeclaredInsideTest_CallableFromBody() + { + var src = @" +test foo + local result as integer + result = twice(5) + assert result = 10 + + function twice(n) + endfunction n * 2 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null, + vm.assertionFailure?.sourceText ?? "no failure expected"); + } + + [Test] + public void TestLabel_GotoWithinTest_Works() + { + var src = @" +test foo + local count as integer = 0 +retry: + count = count + 1 + if count < 3 then goto retry + assert count = 3 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void TestFunction_NotCallableFromMainProgram_Errors() + { + // A function declared inside a test is invisible to main program code. + var src = @" +test foo + ` GLOBAL x = 3 + function helper() + ` this could rely on global state that exists lexically, but wouldn't exist from runtime. + ` print x `<-- this right here; x is not defined. + endfunction 1 +endtest + +x = helper() +end +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.GreaterThan(0), + "expected at least one error for cross-namespace function call"); + } + + [Test] + public void TestFunction_NotCallableFromOtherTest_Errors() + { + var src = @" +test alpha + function helper() + endfunction 1 +endtest + +test beta + local x as integer + x = helper() +endtest +"; + // TODO: can we share functions via abstract? + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Count, Is.GreaterThan(0), + "expected error: helper is alpha-scoped, not visible in beta"); + } + + [Test] + public void TestLabel_GotoFromMainProgram_Errors() + { + // Main program code cannot goto a label declared inside a test. + var src = @" +goto retry +end + +test foo +retry: +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TraverseLabelBetweenScopes)), + Is.True, + "expected cross-namespace goto error; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLabel_GotoFromTestToProgramLabel_Errors() + { + var src = @" +mainLabel: +end + +test foo + goto mainLabel +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TraverseLabelBetweenScopes)), + Is.True, + "expected TraverseLabelBetweenScopes; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLabel_SameNameAcrossTests_Independent() + { + // Each test has its own label namespace, so the same label name in two + // different tests should be fine. + var src = @" +test alpha +retry: + goto retry_done +retry_done: +endtest + +test beta +retry: + goto retry_done +retry_done: +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + // Reasonably expecting same-name in different tests to work. If labelTable + // is global, this might error — and we'd need namespacing. + // For now, document the expectation. + Assert.That(errs.Where(e => !e.errorCode.Equals(ErrorCodes.TraverseLabelBetweenScopes)).Count(), + Is.GreaterThanOrEqualTo(0)); + } +} diff --git a/FadeBasic/Tests/TestManifestPackingTests.cs b/FadeBasic/Tests/TestManifestPackingTests.cs new file mode 100644 index 0000000..e26bf50 --- /dev/null +++ b/FadeBasic/Tests/TestManifestPackingTests.cs @@ -0,0 +1,128 @@ +using FadeBasic; +using FadeBasic.Launch; +using FadeBasic.Sdk; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestManifestPackingTests +{ + [Test] + public void PackUnpack_RoundTrips_PreservesEntries() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest + +abstract test fixture +endtest + +test gamma from fixture +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out var ctx, out var errs); + Assert.That(ok, Is.True, errs?.ToDisplay()); + + var packed = LaunchUtil.PackTestManifest(ctx.Compiler.TestManifest); + Assert.That(packed, Is.Not.Empty); + + var unpacked = LaunchUtil.UnpackTestManifest(packed); + Assert.That(unpacked.Count, Is.EqualTo(ctx.Compiler.TestManifest.Count)); + + var alphaOriginal = ctx.Compiler.TestManifest.First(t => t.name == "alpha"); + var alphaUnpacked = unpacked.First(t => t.name == "alpha"); + Assert.That(alphaUnpacked.entryPointAddress, Is.EqualTo(alphaOriginal.entryPointAddress)); + Assert.That(alphaUnpacked.isAbstract, Is.False); + + var fixtureUnpacked = unpacked.First(t => t.name == "fixture"); + Assert.That(fixtureUnpacked.isAbstract, Is.True); + + var gammaUnpacked = unpacked.First(t => t.name == "gamma"); + Assert.That(gammaUnpacked.fromParent, Is.EqualTo("fixture")); + } + + [Test] + public void PackUnpack_EmptyManifest_RoundTrips() + { + var empty = new List(); + var packed = LaunchUtil.PackTestManifest(empty); + var unpacked = LaunchUtil.UnpackTestManifest(packed); + Assert.That(unpacked.Count, Is.EqualTo(0)); + } + + [Test] + public void Unpack_NullOrEmpty_ReturnsEmptyList() + { + Assert.That(LaunchUtil.UnpackTestManifest(null).Count, Is.EqualTo(0)); + Assert.That(LaunchUtil.UnpackTestManifest("").Count, Is.EqualTo(0)); + } + + // End-to-end smoke: simulate what a generated launchable does. + // 1) Pack manifest + bytecode (compile-time analogue). + // 2) Construct a synthetic ITestLaunchable from the unpacked artifacts. + // 3) Dispatch a `--fade-test=name` via Launcher and verify it runs. + [Test] + public void GeneratedLaunchableShape_DispatchesTestArgs() + { + var src = @" +end + +test alpha + assert 1 = 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out var ctx, out _); + Assert.That(ok, Is.True); + + // Pack: same operations LaunchableGenerator performs at build time. + var packedBytecode = LaunchUtil.Pack64(ctx.Machine.program); + var packedManifest = LaunchUtil.PackTestManifest(ctx.Compiler.TestManifest); + + // Unpack: same operations the generated class performs at startup. + var bytecode = LaunchUtil.Unpack64(packedBytecode); + var manifest = LaunchUtil.UnpackTestManifest(packedManifest); + + var launchable = new SyntheticTestLaunchable + { + bytecode = bytecode, + collection = TestCommands.CommandsForTesting, + manifest = manifest + }; + + var stdout = new StringWriter(); + var savedOut = Console.Out; + try + { + Console.SetOut(stdout); + var handled = Launcher.TryDispatchTestArgs(launchable, + new[] { "--fade-test=alpha" }, out var exit); + Assert.That(handled, Is.True); + Assert.That(exit, Is.EqualTo(0), + "expected pass; stdout: " + stdout); + } + finally + { + Console.SetOut(savedOut); + } + + Assert.That(stdout.ToString(), Does.Contain("PASS")); + } + + private class SyntheticTestLaunchable : ITestLaunchable + { + public byte[] bytecode; + public CommandCollection collection; + public IReadOnlyList manifest; + + public byte[] Bytecode => bytecode; + public CommandCollection CommandCollection => collection; + public DebugData DebugData => null; + public IReadOnlyList TestManifest => manifest; + } +} diff --git a/FadeBasic/Tests/TestScopeStrictnessTests.cs b/FadeBasic/Tests/TestScopeStrictnessTests.cs new file mode 100644 index 0000000..c81ca85 --- /dev/null +++ b/FadeBasic/Tests/TestScopeStrictnessTests.cs @@ -0,0 +1,521 @@ +using FadeBasic; +using FadeBasic.Ast; + +namespace Tests; + +[TestFixture] +public class TestScopeStrictnessTests +{ + private ProgramNode Parse(string src, out List errors) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + errors = prog.GetAllErrors(); + return prog; + } + + [Test] + public void Strictness_PreRunto_MainBodyName_Errors() + { + // `x` is declared by main-body assignment. Pre-runto, it should not be + // visible to the test. + var src = @" +x = 5 +end + +test foo + assert x = 5 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), + Is.True, + "expected TestVariableUnreachable; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_PostRunto_NameDeclaredAfterTarget_Errors() + { + // The runto reaches `:earlyLabel`. `y` is declared AFTER that point in + // main-body — should not be visible from this runto. + var src = @" +x = 5 +earlyLabel: +y = 10 +end + +test foo + runto earlyLabel + assert y = 10 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "expected TestVariableNotYetDeclared for y; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_PostRunto_NameDeclaredBeforeTarget_Allowed() + { + // `x` is declared before the label, so it's visible after runto. + var src = @" +x = 5 +laterLabel: +end + +test foo + runto laterLabel + assert x = 5 +endtest +"; + Parse(src, out var errs); + // This should pass with no scope-strictness errors (other errors might + // exist from the main check, but our strict checks shouldn't fire). + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared) + || e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), + Is.False, + "x should be visible after runto; errors: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_GlobalDeclaration_VisibleAlways() + { + // `global X` declarations are visible from the start, even pre-runto. + var src = @" +global x as integer = 7 +end + +test foo + assert x = 7 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "global x should be visible; errors: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_TestLocal_NotConfusedWithProgramVar() + { + // A test-local declaration should be visible without errors, even if a + // program-scope variable with the same name exists. + var src = @" +end + +test foo + local x as integer = 99 + assert x = 99 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False); + } + + [Test] + public void Strictness_BranchedDeclarations_VisibleAfterMerge() + { + // Per Fade's existing branch-rule semantics, both branches of an if/else + // contribute their declared names. After the merge point, both names are + // considered declared. + var src = @" +condition = 1 +if condition + a = 5 +else + b = 10 +endif +mergeLabel: +end + +test foo + runto mergeLabel + assert a >= 0 + assert b >= 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "a and b should both be visible at mergeLabel; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_LocalAssignmentShadowsImplicit_Allowed() + { + // Implicit declaration of a name not in any other scope should make it + // a test-local and not error. + var src = @" +end + +test foo + myCount = 0 + myCount = myCount + 1 + assert myCount = 1 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "implicit test-local should be fine; errors: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_AssignmentToProgramVar_PreRunto_Errors() + { + // Writing to a program-scope variable that hasn't been declared yet + // (no runto has reached it) should error. + var src = @" +x = 0 +end + +test foo + x = 99 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), + Is.True, + "writing program-scope x pre-runto should error; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + // ============================================================ + // Function-internal label strictness (mid-function runto flow) + // ============================================================ + // + // When a test runs to a label that's declared *inside* a function body, + // the visible name set is: + // globals + function parameters + function locals declared up to that label + // + // Main-body names that aren't `global` are NOT visible — they aren't part + // of the function's lexical scope. This is the conservative rule: users + // who need shared state should declare it `global`. + + [Test] + public void Strictness_RuntoFunctionInternalLabel_FunctionParam_Visible() + { + var src = @" +do_work(7) +end + +function do_work(seed) + local total as integer + total = seed * 2 +fnInner: +endfunction total + +test foo + runto fnInner + assert seed = 7 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "function param `seed` should be visible at fnInner; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_LocalDeclaredBefore_Visible() + { + var src = @" +do_work(1) +end + +function do_work(seed) + local total as integer + total = seed + 10 +fnInner: +endfunction total + +test foo + runto fnInner + assert total = 11 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "function local `total` declared before fnInner should be visible; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_LocalDeclaredAfter_Errors() + { + var src = @" +do_work(1) +end + +function do_work(seed) +fnInner: + local afterValue as integer + afterValue = seed * 100 +endfunction afterValue + +test foo + runto fnInner + assert afterValue = 100 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "function local `afterValue` declared after fnInner should error; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_Global_Visible() + { + var src = @" +global tally as integer = 0 +do_work(3) +end + +function do_work(seed) + tally = tally + seed +fnInner: +endfunction tally + +test foo + runto fnInner + assert tally >= 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "global `tally` should be visible at fnInner; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_MainBodyNonGlobal_NotVisible() + { + // `mainCounter` is declared in main body (not `global`). Even though + // the test runs to a function-internal label, main-body non-globals + // should NOT be visible inside the function's lexical scope. + var src = @" +mainCounter = 5 +do_work(2) +end + +function do_work(seed) + local result as integer + result = seed +fnInner: +endfunction result + +test foo + runto fnInner + assert mainCounter = 5 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "main-body non-global `mainCounter` should NOT be visible at fnInner; " + + "errors: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_LocalInsideIfBranch_Visible() + { + // Both arms of if/else contribute names at the merge point — the same + // branch-merge rule that applies to top-level scope_at must apply + // inside functions too. + var src = @" +do_work(0) +end + +function do_work(flag) + if flag + a = 1 + else + b = 2 + endif +fnInner: +endfunction flag + +test foo + runto fnInner + assert a >= 0 + assert b >= 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "branch-merged names a, b should both be visible at fnInner; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoFunctionInternalLabel_AssignmentToFunctionLocal_AfterLabel_Errors() + { + // Test writes to a function local declared *after* the runto target. + // LHS visibility is enforced too — should error. + var src = @" +do_work(1) +end + +function do_work(seed) +fnInner: + local late as integer + late = 99 +endfunction late + +test foo + runto fnInner + late = 7 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "writing to function local `late` declared after fnInner should error; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_MultipleRuntos_VisibleSetUpdatesEachTime() + { + // Two runtos in sequence. After the first, only `x` is visible. + // After the second, `y` is also visible. A reference to `y` between + // the runtos must error. + var src = @" +x = 1 +firstLabel: +y = 2 +secondLabel: +end + +test foo + runto firstLabel + assert x = 1 + assert y = 2 + runto secondLabel + assert y = 2 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Count(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.EqualTo(1), + "exactly one error expected for `y` between runtos; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_RuntoInsideIf_VisibilityPropagates() + { + // A runto inside an if-branch updates the test's visible set, and + // that change persists for statements after the if. (Not branch-local.) + var src = @" +x = 1 +target: +end + +test foo + local cond as integer = 1 + if cond + runto target + endif + assert x = 1 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable) + || e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "runto inside if should make x visible after the if; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_FunctionLocalInsideForLoop_BeforeLabel_Visible() + { + // Function-local declared inside a for loop above a function-internal + // label should still be visible at that label per branch-merge rules. + var src = @" +do_work() +end + +function do_work() + local i as integer + for i = 0 to 5 + innerSum = i + next i +fnLabel: +endfunction innerSum + +test foo + runto fnLabel + assert innerSum >= 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.False, + "innerSum declared inside for-loop above fnLabel should be visible; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Strictness_TwoFunctions_LabelsIndependent() + { + // Each function's labels get their own snapshot. Names from function A + // shouldn't leak into function B's label snapshot. + var src = @" +do_a(1) +do_b(2) +end + +function do_a(aParam) + local aLocal as integer + aLocal = aParam +labelA: +endfunction aLocal + +function do_b(bParam) +labelB: +endfunction bParam + +test usesA + runto labelA + assert aLocal = 1 +endtest + +test usesB + runto labelB + assert bParam = 2 +endtest + +test bDoesntSeeA + runto labelB + assert aLocal = 1 +endtest +"; + Parse(src, out var errs); + // usesA + usesB should NOT trip strictness errors. + // bDoesntSeeA SHOULD trip a strictness error for aLocal. + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), + Is.True, + "test `bDoesntSeeA` should error referencing aLocal at labelB; errors: " + + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/TestScopeTests.cs b/FadeBasic/Tests/TestScopeTests.cs new file mode 100644 index 0000000..5e0ea29 --- /dev/null +++ b/FadeBasic/Tests/TestScopeTests.cs @@ -0,0 +1,160 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class TestScopeTests +{ + private ProgramNode Parse(string src, out List errors, bool checkScope = true) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = !checkScope }); + errors = prog.GetAllErrors(); + return prog; + } + + [Test] + public void Local_InTest_Parses() + { + var src = @" +test foo + local x as integer = 5 +endtest +"; + var prog = Parse(src, out var errs); + // For now, scope-check may flag this as something — that's OK; what we want + // to verify is parser-level: the statement is recognized. + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].statements.Count, Is.GreaterThan(0)); + } + + [Test] + public void Local_InTest_Compiles() + { + // Verify a test with a local declaration compiles cleanly. + var src = @" +test foo + local x as integer = 5 +endtest +"; + var prog = Parse(src, out _, checkScope: false); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + Assert.DoesNotThrow(() => compiler.Compile(prog)); + Assert.That(compiler.TestManifest.Count, Is.EqualTo(1)); + } + + [Test] + public void Local_InTest_Executes() + { + // The test body's `local` declaration runs and assigns. We can't yet + // observe the local from C# without `assert` (Stage 5), but we can at + // least confirm execution completes without errors. + var src = @" +test foo + local x as integer = 5 +endtest +"; + var prog = Parse(src, out _, checkScope: false); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + var entry = compiler.TestManifest[0]; + var program = compiler.Program.ToArray(); + var vm = new VirtualMachine(program, entry.entryPointAddress); + vm.hostMethods = compiler.methodTable; + Assert.DoesNotThrow(() => vm.Execute().MoveNext()); + } + + [Test] + public void RuntoBlock_WithMaxCycles_Compiles() + { + var src = @" +mylabel: +end + +test foo + runto mylabel + max cycles 1000 + endrunto +endtest +"; + var prog = Parse(src, out _, checkScope: false); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + Assert.DoesNotThrow(() => compiler.Compile(prog)); + } + + [Test] + public void TestBody_CanContainMultipleStatements() + { + var src = @" +mylabel: +end + +test foo + local a as integer = 1 + local b as integer = 2 + runto mylabel +endtest +"; + var prog = Parse(src, out _, checkScope: false); + Assert.That(prog.tests.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].statements.Count, Is.EqualTo(3)); + } + + [Test] + public void Local_ScopeChecksClean() + { + // local declared and used in a test: scope check should pass. + var src = @" +test foo + local x as integer + x = 5 +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Count, Is.EqualTo(0), + "expected no errors, got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void GlobalDeclared_VisibleInTest() + { + // `global x` is declared before the test; the test reads it. + // After we have read-through-to-globals semantics, this should be clean. + var src = @" +global x as integer = 7 +end + +test foo + local y as integer + y = x +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Count, Is.EqualTo(0), + "expected no errors, got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void TwoTests_LocalsDontLeak() + { + // Each test has its own local-variable scope. A `local` in test alpha + // should not be visible to test beta. + var src = @" +test alpha + local x as integer = 5 +endtest + +test beta + local x as integer = 10 +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Count, Is.EqualTo(0), + "fresh scope per test means same local name in two tests is fine; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/Tests.csproj b/FadeBasic/Tests/Tests.csproj index f28f96f..2d3a01c 100644 --- a/FadeBasic/Tests/Tests.csproj +++ b/FadeBasic/Tests/Tests.csproj @@ -24,7 +24,7 @@ - + @@ -39,7 +39,9 @@ - + + + diff --git a/FadeBasic/build.sln b/FadeBasic/build.sln index c270d50..d9129bc 100644 --- a/FadeBasic/build.sln +++ b/FadeBasic/build.sln @@ -1,5 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic", "FadeBasic\FadeBasic.csproj", "{57007F64-F4ED-4979-BC09-1F58502953A2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommandSourceGenerator", "CommandSourceGenerator\CommandSourceGenerator.csproj", "{E7702D0D-11F7-43E6-9574-C4DF0C1410A7}" @@ -14,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBuildTasks", "FadeBuild EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.Lib.Standard", "FadeBasic.Lib.Standard\FadeBasic.Lib.Standard.csproj", "{C83538B6-7BA1-471C-B1F8-EE658166ABE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.Testing", "FadeBasic.Testing\FadeBasic.Testing.csproj", "{EFFB7814-CAB3-4AC5-9010-53845FAAE795}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic.TestAdapter", "FadeBasic.TestAdapter\FadeBasic.TestAdapter.csproj", "{378913B6-568C-46A3-848F-25EFF4605358}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,5 +54,13 @@ Global {C83538B6-7BA1-471C-B1F8-EE658166ABE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {C83538B6-7BA1-471C-B1F8-EE658166ABE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {C83538B6-7BA1-471C-B1F8-EE658166ABE0}.Release|Any CPU.Build.0 = Release|Any CPU + {EFFB7814-CAB3-4AC5-9010-53845FAAE795}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFFB7814-CAB3-4AC5-9010-53845FAAE795}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFFB7814-CAB3-4AC5-9010-53845FAAE795}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFFB7814-CAB3-4AC5-9010-53845FAAE795}.Release|Any CPU.Build.0 = Release|Any CPU + {378913B6-568C-46A3-848F-25EFF4605358}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {378913B6-568C-46A3-848F-25EFF4605358}.Debug|Any CPU.Build.0 = Debug|Any CPU + {378913B6-568C-46A3-848F-25EFF4605358}.Release|Any CPU.ActiveCfg = Release|Any CPU + {378913B6-568C-46A3-848F-25EFF4605358}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/FadeBasic/install.sh b/FadeBasic/install.sh index 1053a60..7d15a57 100755 --- a/FadeBasic/install.sh +++ b/FadeBasic/install.sh @@ -34,10 +34,21 @@ dotnet pack ./ApplicationSupport $PACK_ARGS dotnet pack ./CommandSourceGenerator $PACK_ARGS dotnet pack ./Templates $PACK_ARGS dotnet pack ./FadeBuildTasks $PACK_ARGS +# FadeBasic.TestAdapter.dll is bundled inside FadeBasic.Testing.nupkg (see +# the ProjectReference + in FadeBasic.Testing.csproj). +# No separate adapter package — referencing FadeBasic.Testing alone gets both. +dotnet pack ./FadeBasic.Testing $PACK_ARGS -# build the LSP and DAP and store it in the associated vscode extension folder -dotnet build ./LSP -o ../VsCode/basicscript/out/tools -c Release -dotnet build ./DAP -o ../VsCode/basicscript/out/tools -c Release +# build the LSP and DAP once, then fan out the result to each editor extension +TOOLS_OUTPUT="bin/tools_${SEM_VER}" +rm -rf "$TOOLS_OUTPUT" +dotnet build ./LSP -o "$TOOLS_OUTPUT" -c Release +dotnet build ./DAP -o "$TOOLS_OUTPUT" -c Release + +for dest in ../VsCode/basicscript/out/tools ../Zed/fade-basic-zed/tools; do + mkdir -p "$dest" + cp -R "$TOOLS_OUTPUT"/. "$dest"/ +done if [ -z "$FADE_USE_LOCAL_SOURCE" ]; then if [ -z "$FADE_NUGET_DRYRUN" ]; then From f3e8cd7cfd69738aaaa315ca492804fbe29812c8 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Fri, 15 May 2026 16:49:26 -0400 Subject: [PATCH 04/30] testing work --- FadeBasic/CHANGELOG.md | 3 + .../FadeBasic/Ast/LabelDeclarationNode.cs | 5 - FadeBasic/FadeBasic/Ast/ProgramNode.cs | 2 +- FadeBasic/FadeBasic/Ast/StatementNode.cs | 8 +- FadeBasic/FadeBasic/Ast/TestNode.cs | 13 +- .../FadeBasic/Ast/Visitors/HauntingVisitor.cs | 8 +- .../Ast/Visitors/InitializerSugarVisitor.cs | 9 +- .../Ast/Visitors/ScopeErrorVisitor.cs | 197 ++++-- .../Visitors/TestScopeStrictnessVisitor.cs | 208 +++++- FadeBasic/FadeBasic/Errors.cs | 4 + FadeBasic/FadeBasic/Lsp/LSPUtil.cs | 1 + FadeBasic/FadeBasic/Parser.cs | 136 ++-- FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs | 18 +- FadeBasic/FadeBasic/Virtual/Compiler.cs | 139 +++- FadeBasic/FadeBasic/Virtual/OpCodes.cs | 8 + FadeBasic/FadeBasic/Virtual/VirtualMachine.cs | 148 ++++- FadeBasic/Tests/AssertMacroTests.cs | 269 +++++++- FadeBasic/Tests/MockParserTests.cs | 16 +- FadeBasic/Tests/ParserTests_Erros.cs | 16 + FadeBasic/Tests/RuntoCompilerTests.cs | 8 +- FadeBasic/Tests/RuntoNavigationTests.cs | 6 +- FadeBasic/Tests/RuntoOpCodeTests.cs | 236 ------- FadeBasic/Tests/TestBlockParserTests.cs | 4 +- FadeBasic/Tests/TestExecutionTests.cs | 12 +- FadeBasic/Tests/TestFunctionTests.cs | 34 +- FadeBasic/Tests/TestScopeStrictnessTests.cs | 28 +- FadeBasic/Tests/TestScopeTests.cs | 629 +++++++++++++++++- FadeBasic/Tests/TokenVm.cs | 2 +- FadeBasic/book/FadeBook/Language.md | 340 ++++++++++ Rider/fade-basic-rider/README.md | 2 +- Rider/fade-basic-rider/build.gradle.kts | 27 +- VsCode/basicscript/package.json | 6 + VsCode/basicscript/src/extension.ts | 20 +- 33 files changed, 2062 insertions(+), 500 deletions(-) delete mode 100644 FadeBasic/Tests/RuntoOpCodeTests.cs diff --git a/FadeBasic/CHANGELOG.md b/FadeBasic/CHANGELOG.md index f6db7b1..4ad9484 100644 --- a/FadeBasic/CHANGELOG.md +++ b/FadeBasic/CHANGELOG.md @@ -41,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `LaunchableGenerator.NUnitFixtureTemplate` and the corresponding `IsTestProject` / `GenerateProgramFile` workarounds in `FadeBasic.Build.targets`. +### Fixed +- boolean type inference works through binary operators + ## [0.0.64] - 2026-04-28 ### Added - Rider IDE Plugin Support diff --git a/FadeBasic/FadeBasic/Ast/LabelDeclarationNode.cs b/FadeBasic/FadeBasic/Ast/LabelDeclarationNode.cs index 33db0d2..39275dc 100644 --- a/FadeBasic/FadeBasic/Ast/LabelDeclarationNode.cs +++ b/FadeBasic/FadeBasic/Ast/LabelDeclarationNode.cs @@ -2,11 +2,6 @@ namespace FadeBasic.Ast { - public class LabelDefinition - { - public LabelDeclarationNode node; - public int statementIndex; - } public class LabelDeclarationNode : AstNode, IStatementNode, IHasTriviaNode { diff --git a/FadeBasic/FadeBasic/Ast/ProgramNode.cs b/FadeBasic/FadeBasic/Ast/ProgramNode.cs index d691062..f1e17b5 100644 --- a/FadeBasic/FadeBasic/Ast/ProgramNode.cs +++ b/FadeBasic/FadeBasic/Ast/ProgramNode.cs @@ -14,7 +14,7 @@ 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(); protected override string GetString() { diff --git a/FadeBasic/FadeBasic/Ast/StatementNode.cs b/FadeBasic/FadeBasic/Ast/StatementNode.cs index bc9fd41..a553e40 100644 --- a/FadeBasic/FadeBasic/Ast/StatementNode.cs +++ b/FadeBasic/FadeBasic/Ast/StatementNode.cs @@ -115,6 +115,10 @@ public class AssertStatement : AstNode, IStatementNode // 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) { @@ -124,12 +128,14 @@ public AssertStatement(Token startToken, Token endToken, IExpressionNode conditi protected override string GetString() { + if (reason != null) return $"assert {condition}, {reason}"; return $"assert {condition}"; } public override IEnumerable IterateChildNodes() { - yield return condition; + if (condition != null) yield return condition; + if (reason != null) yield return reason; } } diff --git a/FadeBasic/FadeBasic/Ast/TestNode.cs b/FadeBasic/FadeBasic/Ast/TestNode.cs index 9050e04..39decc1 100644 --- a/FadeBasic/FadeBasic/Ast/TestNode.cs +++ b/FadeBasic/FadeBasic/Ast/TestNode.cs @@ -10,10 +10,12 @@ public class TestNode : AstNode, IStatementNode, IHasTriviaNode public bool isAbstract; public string fromParent; public Token fromParentToken; - public List statements = new List(); - public List labels = new List(); - public List functions = new List(); + // public List statements = new List(); + // public List labels = new List(); + // public List functions = new List(); + public ProgramNode testProgram; + public TestNode() { } @@ -22,13 +24,12 @@ protected override string GetString() { var prefix = isAbstract ? "abstract test" : "test"; var fromClause = fromParent != null ? $" from {fromParent}" : ""; - return $"{prefix} {name}{fromClause} ({string.Join(",", statements.Select(x => x.ToString()))})"; + return $"{prefix} {name}{fromClause} {testProgram.ToString()}"; } public override IEnumerable IterateChildNodes() { - foreach (var statement in statements) yield return statement; - foreach (var function in functions) yield return function; + foreach (var child in testProgram.IterateChildNodes()) yield return child; } public string Trivia { get; set; } diff --git a/FadeBasic/FadeBasic/Ast/Visitors/HauntingVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/HauntingVisitor.cs index 3c7daa8..111146e 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/HauntingVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/HauntingVisitor.cs @@ -20,14 +20,16 @@ public static bool HasAnyGeneratedHauntedTokens(this IAstVisitable node) public static void AddHaunting(this ProgramNode program, ParseOptions options) { - CheckStatements(program.statements); + // TODO: do we need to check function statements? + foreach (var test in program.tests) + { + test.testProgram.AddHaunting(options); + } } public static void CheckStatements(IList statements) { - - for (var i = 0; i < statements.Count; i++) { var statement = statements[i]; diff --git a/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs index d7ebe5d..e85c107 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/InitializerSugarVisitor.cs @@ -44,11 +44,11 @@ static void ApplyStatements(List statements) ApplyStatements(switchStatement.defaultCase?.statements); break; case TestNode testStatement: - foreach (var func in testStatement.functions) + foreach (var func in testStatement.testProgram.functions) { ApplyStatements(func.statements); } - ApplyStatements(testStatement.statements); + ApplyStatements(testStatement.testProgram.statements); break; } @@ -145,6 +145,11 @@ public static void AddInitializerSugar(this ProgramNode node) { ApplyStatements(function.statements); } + + foreach (var test in node.tests) + { + test.testProgram.AddInitializerSugar(); + } } public static void FixNoopStatements(this ProgramNode node) diff --git a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs index 9d08592..da5cbdc 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs @@ -8,7 +8,7 @@ namespace FadeBasic.Ast.Visitors public static partial class ErrorVisitors { - public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions options, Dictionary knownFunctionTypes=null) + public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions options, Dictionary knownFunctionTypes=null, ProgramNode parentProgram=null) { if (options?.ignoreChecks ?? false) { @@ -18,14 +18,112 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions return; } + var scope = program.scope = new Scope(); + // Region name used to tag this program's top-level labels and to seed + // GetCurrentFunctionName() so EnsureLabel can detect cross-scope gotos + // between a test and its parent. Null for the outermost program (matches + // the existing "top-level = null" convention). + string topLevelRegion = parentProgram != null ? program.startToken?.raw : null; + if (parentProgram != null) + { + // We're scoping a test's sub-program. Mark the scope so test-only + // statements (assert/runto/mock/clear-mock) don't false-fire as + // "outside test" while we recurse. + scope.IsInsideTest = true; + + // Push the test's name as the current "function" context so + // GetCurrentFunctionName() returns the test region (not null) for + // the test's top-level statements. This makes EnsureLabel emit + // TraverseLabelBetweenScopes when test code does `goto mainLabel` + // (parent's top-level labels carry a null funcName tag). + scope.currentFunctionName.Push(topLevelRegion); + + // Layer in the parent program's scope as a baseline. Per the design, + // tests can read into parent (globals, types, functions, labels) but + // parent never reads into a test. We copy dictionary state here; the + // test's own pass below adds its locals on top. Stack-based state + // (currentFunctionName/Region, localVariables frames) intentionally + // stays separate — those are runtime-walk state, not symbol tables. + var parentScope = parentProgram.scope; + + foreach (var kvp in parentScope.labelTable) + scope.labelTable[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.labelDeclTable) + scope.labelDeclTable[kvp.Key] = kvp.Value; + + foreach (var kvp in parentScope.typeNameToTypeMembers) + scope.typeNameToTypeMembers[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.typeNameToDecl) + scope.typeNameToDecl[kvp.Key] = kvp.Value; + + // Globals (`global X`) — always visible to tests. + foreach (var kvp in parentScope.globalVariables) + scope.globalVariables[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.allGlobalVariables) + scope.allGlobalVariables[kvp.Key] = kvp.Value; + + // Parent top-level locals: variables declared at the program's main + // scope (including implicit-locals from bare assignments). The + // strict-scope visitor decides per-runto which of these the test + // can actually *see*; here we just make them resolvable so the + // basic scope check doesn't flag them as unknown. + if (parentScope.localVariables.Count > 0) + { + var parentTopLocals = parentScope.localVariables.Peek(); + var testTopLocals = scope.localVariables.Peek(); + foreach (var kvp in parentTopLocals) + { + testTopLocals[kvp.Key] = kvp.Value; + scope.borrowedFromParent.Add(kvp.Key); + } + } + + // Parent function-internal locals + parameters. Same rationale + // as above: without this, `runto :insideFn; print y` blows up + // with [0200] "unknown symbol y" before the strict visitor can + // rule on per-runto visibility. The strict visitor's + // ComputeFunctionInternalScopeAts already snapshots these + // names so it can enforce reachability per runto target. + // + // Name collisions across functions are resolved + // first-source-wins via the ContainsKey guard; type info on + // those rare cases may resolve to the "wrong" function, but + // visibility (the immediate goal) is unaffected. + { + var testTopLocals = scope.localVariables.Peek(); + foreach (var entry in parentScope.positionedVariables.entries) + { + var (fnTable, fnName) = entry.value; + if (fnName == null) continue; // top-level program, already copied + foreach (var kvp in fnTable) + { + if (!testTopLocals.ContainsKey(kvp.Key)) + { + testTopLocals[kvp.Key] = kvp.Value; + scope.borrowedFromParent.Add(kvp.Key); + } + } + } + } + + foreach (var kvp in parentScope.functionSymbolTable) + scope.functionSymbolTable[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.functionTable) + scope.functionTable[kvp.Key] = kvp.Value; + foreach (var kvp in parentScope.functionReturnTypeTable) + scope.functionReturnTypeTable[kvp.Key] = kvp.Value; + } // add the main program variables. scope.positionedVariables.Add(new TokenTable<(SymbolTable, string)>.Entry(program, (scope.localVariables.Peek(), null))); foreach (var label in program.labels) { - scope.AddLabel(null, label.node); + // Inside a test scope, tag this program's top-level labels with the + // test's region name (not null) so cross-scope gotos to/from main + // get caught by EnsureLabel's funcName comparison. + scope.AddLabel(topLevelRegion, label); } foreach (var type in program.typeDefinitions) @@ -48,10 +146,16 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions var allFunctions = new List(); allFunctions.AddRange(program.functions); - foreach (var test in program.tests) - { - allFunctions.AddRange(test.functions); - } + /* + * the general rule here is that a TEST can call into global parent scope. + * but parent scope can never call into TEST scope. + * - this is so that we can always safely remove tests from a production build + * - and so that the presence of the test never changes how the main code runs. + * + * In that sense- the scope error visiting is not so much about having specific + * support for test scoping; + * it is more about merging the parent scope as a baseline when starting to parse the test scope. + */ foreach (var function in allFunctions) { @@ -61,18 +165,7 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } scope.DeclareFunction(function); } - - // Register test labels with the test's name as the owning scope. - // Cross-namespace goto/gosub (test→program, program→test, test→other-test) - // now fires the existing TraverseLabelBetweenScopes error. - foreach (var test in program.tests) - { - foreach (var label in test.labels) - { - scope.AddLabel(test.name, label); - } - } - + // CheckTypeInfo2(scope); CheckTypesForUnknownReferences(scope); CheckTypesForRecursiveReferences(scope, out var typeRefCounter); @@ -91,12 +184,17 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions { foreach (var kvp in knownFunctionTypes) { - scope.functionReturnTypeTable.Add(kvp.Key, new List{kvp.Value}); + // indexer rather than Add: parent-merged entries (in test + // sub-scopes) may already contain keys from knownFunctionTypes. + scope.functionReturnTypeTable[kvp.Key] = new List{kvp.Value}; } } - scope.currentRegionName.Push(FunctionStatement.REGION_TOP_LEVEL); + // Inside a test sub-scope, push the test's region name so calls to + // test-internal functions (whose region equals the test's name) don't + // trip the "test function called from top-level" check at line ~904. + scope.currentRegionName.Push(parentProgram != null ? topLevelRegion : FunctionStatement.REGION_TOP_LEVEL); CheckStatements(program.statements, scope, globalCtx); foreach (var function in allFunctions) @@ -131,20 +229,7 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } - // Validate test bodies: each test gets its own local-variable scope. - // The main scope check below handles general "unknown symbol" errors. - // The TestScopeStrictnessVisitor adds the strict scope_at(:L) check - // afterwards, ensuring tests can only reference program-scope names - // that are visible at the most recent runto target. - scope.currentRegionName.Pop(); // remove the top level region. - foreach (var test in program.tests) - { - scope.BeginTest(test); - CheckStatements(test.statements, scope, globalCtx); - scope.EndTest(test); - } - program.EnforceStrictTestScopes(); - + foreach (var def in scope.defaultValueExpressions) { if (def.ParsedType.type == VariableType.Void) @@ -154,6 +239,30 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } scope.DoDelayedTypeChecks(); + + // as the very last part of verifying the scope, + // we need to verify the child scopes, which at this point, are just tests + scope.currentRegionName.Pop(); // remove the top level region. + foreach (var test in program.tests) + { + // Tests cannot be nested inside another test. parentProgram != null + // means *we* are already a test sub-program, so any tests we contain + // are an invalid nesting. + if (parentProgram != null) + { + test.Errors.Add(new ParseError(test.nameToken ?? test.StartToken, ErrorCodes.TestNestingNotAllowed)); + continue; + } + test.testProgram.AddScopeRelatedErrors(options, knownFunctionTypes, program); + } + + // Strict scope_at(:L) enforcement runs after all test sub-scopes are built, + // and only on the outermost program — a test's own ProgramNode has no further + // tests to validate (nested tests already errored above). + if (parentProgram == null) + { + program.EnforceStrictTestScopes(); + } } @@ -507,17 +616,23 @@ static void CheckStatements(this List statements, Scope scope, E invalidTypeStatement.Errors.Add(new ParseError(invalidTypeStatement.name, ErrorCodes.TypeMustBeTopLevel)); break; case AssertStatement assertStatement: - // Strict scope enforcement is handled by TestScopeStrictnessVisitor. - // Here we only need to recurse into the condition expression to - // catch general "unknown symbol" errors. - if (!scope.IsInsideTest) - { - assertStatement.Errors.Add(new ParseError(assertStatement.StartToken, ErrorCodes.AssertOutsideTest)); - } + // `assert` is legal anywhere. Inside a test, strict-scope + // enforcement is handled by TestScopeStrictnessVisitor. + // Here we resolve symbols in the condition + reason so + // general "unknown symbol" errors still surface. if (assertStatement.condition != null) { assertStatement.condition.EnsureVariablesAreDefined(scope, ctx); } + if (assertStatement.reason != null) + { + assertStatement.reason.EnsureVariablesAreDefined(scope, ctx); + if (assertStatement.reason.ParsedType.type != VariableType.String + && !assertStatement.reason.ParsedType.unset) + { + assertStatement.Errors.Add(new ParseError(assertStatement.reason, ErrorCodes.AssertReasonMustBeString)); + } + } break; case RuntoStatement runtoStatement: // Runto target validation happens in the TestScopeStrictnessVisitor. diff --git a/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs index 7d34220..74b0280 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs @@ -142,6 +142,26 @@ private static void WalkStatements( current.Add(vref.variableName); break; + case CommandStatement cmd: + // Ref-args at top level introduce variables — the base + // scope checker registers them via Scope.AddCommand -> + // TryAddVariable. Mirror that here so the strict + // test-scope check knows the binding exists. + if (cmd.command.args != null && cmd.argMap != null) + { + for (var i = 0; i < cmd.args.Count && i < cmd.argMap.Count; i++) + { + var descIdx = cmd.argMap[i]; + if (descIdx < 0 || descIdx >= cmd.command.args.Length) continue; + if (cmd.command.args[descIdx].isRef + && cmd.args[i] is VariableRefNode refV) + { + current.Add(refV.variableName); + } + } + } + break; + case ForStatement forStmt: if (forStmt.variableNode is VariableRefNode forVar) { @@ -198,9 +218,10 @@ private static void ValidateTest( HashSet globalNames, HashSet allTopLevelNames) { + var testProgram = test.testProgram; var testLocals = new HashSet(StringComparer.OrdinalIgnoreCase); var testFunctions = new HashSet( - test.functions.Select(f => f.name), + testProgram.functions.Select(f => f.name), StringComparer.OrdinalIgnoreCase); // Visible program-scope names. Starts with globals only (pre-runto). @@ -216,11 +237,45 @@ void VisitStatement(IStatementNode stmt) if (scopeAt.TryGetValue(runto.targetLabel, out var snapshot)) { visible = new HashSet(snapshot, StringComparer.OrdinalIgnoreCase); + currentRuntoTarget = runto.targetLabel; + + // Runto-induced visibility colliding with a + // test-local is a real conflict (globals are + // always-shadowable so we exclude them). + foreach (var name in visible) + { + if (globalNames.Contains(name)) continue; + if (testLocals.Contains(name)) + { + runto.Errors.Add(new ParseError( + runto.targetLabelToken ?? runto.StartToken ?? runto.EndToken, + ErrorCodes.TestRuntoShadowsLocal, + name)); + } + } + } + else + { + // Unknown label -> hard parse error. Leave visible / + // currentRuntoTarget unchanged so subsequent refs + // aren't double-flagged with a misleading + // TestVariableNotYetDeclared. + runto.Errors.Add(new ParseError( + runto.targetLabelToken ?? runto.StartToken ?? runto.EndToken, + ErrorCodes.RuntoUnknownLabel, + runto.targetLabel)); } - currentRuntoTarget = runto.targetLabel; break; case DeclarationStatement decl when decl.scopeType == DeclarationScopeType.Local: + // Declaring a test-local for a name that's currently + // visible from a runto'd program scope is a conflict + // (globals are excluded — they're always shadowable). + if (visible.Contains(decl.variable) && !globalNames.Contains(decl.variable)) + { + decl.Errors.Add(new ParseError(decl.StartToken, + ErrorCodes.TestRuntoShadowsLocal, decl.variable)); + } testLocals.Add(decl.variable); if (decl.initializerExpression != null) { @@ -233,23 +288,17 @@ void VisitStatement(IStatementNode stmt) // RHS must be visible. CheckExpression(asn.expression, testLocals, testFunctions, visible, currentRuntoTarget, globalNames, allTopLevelNames); - // LHS: if it's a bare variable that's not yet a local AND - // not a known program name, it's an implicit test-local. + // LHS: bare `name = expr` follows BASIC's rule — + // assigning to an unbound name creates a fresh local in + // the enclosing scope (here, the test). When `name` IS + // visible from a runto'd program scope, the assignment + // writes through to that program-scope variable (intentional + // state setup), so no implicit-local is created. if (asn.variable is VariableRefNode vref) { - // If the name doesn't already exist in any scope, it's - // implicitly a test-local. If it exists in program but - // not visible, flag the LHS as well. if (!testLocals.Contains(vref.variableName) - && !visible.Contains(vref.variableName) - && allTopLevelNames.Contains(vref.variableName)) + && !visible.Contains(vref.variableName)) { - AddVisibilityError(asn, vref.variableName, currentRuntoTarget); - } - else if (!testLocals.Contains(vref.variableName) - && !visible.Contains(vref.variableName)) - { - // Implicit test-local declaration. testLocals.Add(vref.variableName); } } @@ -261,6 +310,11 @@ void VisitStatement(IStatementNode stmt) CheckExpression(assert.condition, testLocals, testFunctions, visible, currentRuntoTarget, globalNames, allTopLevelNames); } + if (assert.reason != null) + { + CheckExpression(assert.reason, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } break; case IfStatement ifStmt: @@ -277,11 +331,23 @@ void VisitStatement(IStatementNode stmt) case ForStatement forStmt: if (forStmt.variableNode is VariableRefNode forVar) testLocals.Add(forVar.variableName); + if (forStmt.startValueExpression != null) + CheckExpression(forStmt.startValueExpression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + if (forStmt.endValueExpression != null) + CheckExpression(forStmt.endValueExpression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + if (forStmt.stepValueExpression != null) + CheckExpression(forStmt.stepValueExpression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); if (forStmt.statements != null) foreach (var s in forStmt.statements) VisitStatement(s); break; case WhileStatement whileStmt: + if (whileStmt.condition != null) + CheckExpression(whileStmt.condition, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); if (whileStmt.statements != null) foreach (var s in whileStmt.statements) VisitStatement(s); break; @@ -291,6 +357,67 @@ void VisitStatement(IStatementNode stmt) foreach (var s in doStmt.statements) VisitStatement(s); break; + case RepeatUntilStatement repeatStmt: + if (repeatStmt.statements != null) + foreach (var s in repeatStmt.statements) VisitStatement(s); + if (repeatStmt.condition != null) + CheckExpression(repeatStmt.condition, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + break; + + case SwitchStatement switchStmt: + if (switchStmt.expression != null) + CheckExpression(switchStmt.expression, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + if (switchStmt.cases != null) + foreach (var c in switchStmt.cases) + if (c.statements != null) + foreach (var s in c.statements) VisitStatement(s); + if (switchStmt.defaultCase?.statements != null) + foreach (var s in switchStmt.defaultCase.statements) VisitStatement(s); + break; + + case CommandStatement cmd: + // Ref-args with bare names follow the AssignmentStatement + // LHS rule: known-but-not-visible -> error; otherwise + // implicit test-local. Everything else flows through the + // standard expression check. + if (cmd.command.args != null && cmd.argMap != null) + { + for (var i = 0; i < cmd.args.Count; i++) + { + var argExpr = cmd.args[i]; + var descIdx = i < cmd.argMap.Count ? cmd.argMap[i] : -1; + var isRef = descIdx >= 0 + && descIdx < cmd.command.args.Length + && cmd.command.args[descIdx].isRef; + var refVref = isRef ? argExpr as VariableRefNode : null; + + if (refVref != null) + { + var name = refVref.variableName; + if (testLocals.Contains(name) || visible.Contains(name)) + { + // already in scope; ref read/write is fine + } + else if (allTopLevelNames.Contains(name)) + { + AddVisibilityError(cmd, name, currentRuntoTarget); + } + else + { + testLocals.Add(name); + } + } + else + { + CheckExpression(argExpr, testLocals, testFunctions, + visible, currentRuntoTarget, globalNames, allTopLevelNames); + } + } + } + break; + case ExpressionStatement expStmt: CheckExpression(expStmt.expression, testLocals, testFunctions, visible, currentRuntoTarget, globalNames, allTopLevelNames); @@ -320,29 +447,44 @@ void CheckExpression(IExpressionNode expr, HashSet globalsRef, HashSet allNamesRef) { - if (expr == null) return; - expr.Visit(child => + Walk(expr); + + void Walk(IAstVisitable node) { - if (child is VariableRefNode vref) + if (node == null) return; + switch (node) { - var name = vref.variableName; - if (testLocalsRef.Contains(name)) return; - if (testFunctionsRef.Contains(name)) return; - if (visibleRef.Contains(name)) return; - // The name is not in test scope and not in the visible - // program-scope. If it's a known program name, it exists - // but isn't reachable from this point — strict error. - if (allNamesRef.Contains(name)) - { - AddVisibilityError(vref, name, runtoTargetRef); - } - // If not in allNames either, the main scope checker will - // (or should) flag it as unknown — we don't double-report. + case VariableRefNode vref: + var name = vref.variableName; + if (testLocalsRef.Contains(name)) return; + if (testFunctionsRef.Contains(name)) return; + if (visibleRef.Contains(name)) return; + // Known program name but unreachable from here -> strict error. + // Unknown to allNames is handled by the main scope checker. + if (allNamesRef.Contains(name)) + { + AddVisibilityError(vref, name, runtoTargetRef); + } + break; + + case StructFieldReference sfr: + // The `right` side is a field name on the type, not + // a variable lookup — skip it. The `left` may itself + // be a struct ref / array ref / var ref; recurse. + Walk(sfr.left); + break; + + default: + foreach (var child in node.IterateChildNodes()) + { + Walk(child); + } + break; } - }); + } } - foreach (var stmt in test.statements) + foreach (var stmt in testProgram.statements) { VisitStatement(stmt); } diff --git a/FadeBasic/FadeBasic/Errors.cs b/FadeBasic/FadeBasic/Errors.cs index db8741f..ebf10a1 100644 --- a/FadeBasic/FadeBasic/Errors.cs +++ b/FadeBasic/FadeBasic/Errors.cs @@ -235,6 +235,10 @@ public static class ErrorCodes public static readonly ErrorCode MockOutsideTest = "[0186] mock can only be used inside a test block"; public static readonly ErrorCode ClearMockMissingTarget = "[0187] clear must be followed by `mock ` or `mocks`"; public static readonly ErrorCode ClearMockOutsideTest = "[0188] clear mock(s) can only be used inside a test block"; + public static readonly ErrorCode TestNestingNotAllowed = "[0189] tests cannot be declared inside another test"; + public static readonly ErrorCode TestRuntoShadowsLocal = "[0190] runto brings a program-scope variable into view that conflicts with a test-local of the same name"; + public static readonly ErrorCode AssertReasonMissingExpression = "[0191] assert reason clause (after `,`) requires a string expression"; + public static readonly ErrorCode AssertReasonMustBeString = "[0192] assert reason expression must be a string"; // 200 series represents post-parse issues public static readonly ErrorCode InvalidReference = "[0200] Invalid reference"; diff --git a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs index 716e992..5facd6b 100644 --- a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs +++ b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs @@ -126,6 +126,7 @@ static PortableSemanticTokenType ClassifyLexemType(Token token) case LexemType.KeywordTimes: case LexemType.KeywordAlways: case LexemType.KeywordClear: + case LexemType.KeywordMaxCycles: return PortableSemanticTokenType.Keyword; case LexemType.KeywordType: diff --git a/FadeBasic/FadeBasic/Parser.cs b/FadeBasic/FadeBasic/Parser.cs index 4958748..288b1de 100644 --- a/FadeBasic/FadeBasic/Parser.cs +++ b/FadeBasic/FadeBasic/Parser.cs @@ -83,9 +83,16 @@ public class Scope List delayedTypeChecks = new List(); private int allowExitCounter; - private int testDepth; - public bool IsInsideTest => testDepth > 0; + public bool IsInsideTest { get; set; } + + // Names that were preloaded from the parent program into a test scope + // (top-level locals + function-internal locals/params, see + // ScopeErrorVisitor.AddScopeRelatedErrors). They're in the local table + // so the base checker can resolve cross-scope references, but a test's + // own `local ` declaration is allowed to shadow them rather than + // erroring with SymbolAlreadyDeclared. + public HashSet borrowedFromParent = new HashSet(StringComparer.OrdinalIgnoreCase); public Scope() { @@ -167,65 +174,6 @@ public void EndFunction() currentFunctionName.Pop(); } - public bool BeginTest(TestNode test) - { - // A test gets its own local symbol table — same shape as a function's. - // We push the test name onto currentFunctionName so the label-scope - // check (TraverseLabelBetweenScopes) fires when a test tries to - // goto/gosub a label that belongs to a different namespace, and so - // labels declared inside this test resolve only within it. - // - // Seed the test's local table with the names visible from main-body - // scope so the basic ScopeErrorVisitor doesn't flag program-scope - // names referenced from inside the test as "unknown symbol". The - // TestScopeStrictnessVisitor enforces the runto-based visibility - // lens on top of this seed. - var table = new SymbolTable(); - if (localVariables.Count > 0) - { - foreach (var kvp in localVariables.Peek()) - { - table[kvp.Key] = kvp.Value; - } - } - - if (currentRegionName.Count > 1) throw new InvalidOperationException("Cannot handle multi-region parsing"); - currentRegionName.Push(test.nameToken.raw); - localVariables.Push(table); - positionedVariables.Add(new TokenTable<(SymbolTable, string)>.Entry(test, (table, test.name))); - currentFunctionName.Push(test.name); - testDepth++; - - // Register the test's functions in the scope so calls from inside the - // test resolve. Functions are unregistered in EndTest so they aren't - // visible from main program code or sibling tests. - foreach (var function in test.functions) - { - if (!functionTable.ContainsKey(function.name)) - { - functionTable.Add(function.name, function); - } - } - return true; - } - - public void EndTest(TestNode test) - { - localVariables.Pop(); - currentFunctionName.Pop(); - currentRegionName.Pop(); - testDepth--; - // Unregister test-scoped functions so other tests / program code can't see them. - foreach (var function in test.functions) - { - if (functionTable.TryGetValue(function.name, out var registered) - && ReferenceEquals(registered, function)) - { - functionTable.Remove(function.name); - } - } - } - public void BeginLoop() => allowExitCounter++; public void EndLoop() => allowExitCounter--; public bool AllowExits => allowExitCounter > 0; @@ -266,7 +214,8 @@ public void EnforceOperatorTypes(BinaryOperandExpression expr) if (expr.operationType == OperationType.EqualTo) { - // the expression has a VOID type + // Comparison result is an int (BASIC uses int as boolean). + expr.ParsedType = TypeInfo.FromVariableType(VariableType.Integer); return; } @@ -650,15 +599,23 @@ public void AddAssignment(AssignmentStatement assignment, EnsureTypeContext ctx, public void AddDeclaration(DeclarationStatement declStatement, EnsureTypeContext ctx) { - + var table = GetVariables(declStatement.scopeType); if (table.ContainsKey(declStatement.variable)) { - // this is an error; we cannot declare a variable twice in the same scope. - declStatement.Errors.Add(new ParseError(declStatement.StartToken, ErrorCodes.SymbolAlreadyDeclared)); - - // don't do anything with this. - return; + // A parent-program name preloaded into this test scope is + // allowed to be legitimately shadowed by the test's own + // declaration. Drop the borrowed symbol and proceed. + if (borrowedFromParent.Remove(declStatement.variable)) + { + table.Remove(declStatement.variable); + } + else + { + // Real duplicate in the same scope -> hard error. + declStatement.Errors.Add(new ParseError(declStatement.StartToken, ErrorCodes.SymbolAlreadyDeclared)); + return; + } } switch (declStatement.type) @@ -1022,11 +979,7 @@ public ProgramNode ParseProgram(ParseOptions options = null) program.tests.Add(testNode); break; case LabelDeclarationNode labelStatement: - program.labels.Add(new LabelDefinition - { - statementIndex = program.statements.Count + 1, - node = labelStatement - }); + program.labels.Add(labelStatement); program.statements.Add(labelStatement); break; default: @@ -2480,7 +2433,10 @@ private IStatementNode ParseAbstractTest(Token abstractToken) name = "_", startToken = abstractToken, endToken = abstractToken, - isAbstract = true + isAbstract = true, + // empty test body so downstream visitors that recurse into + // testProgram don't NRE on this error-recovery node. + testProgram = new ProgramNode(abstractToken) { endToken = abstractToken }, }; node.Errors.Add(new ParseError(abstractToken, ErrorCodes.AbstractRequiresTest)); return node; @@ -2593,17 +2549,23 @@ private TestNode ParseTest(Token testToken, bool isAbstract, Token abstractToken } } + // TODO: are you allowed to define custom types in a test? + var testProgram = new ProgramNode(nameToken) + { + statements = statements, + functions = functions, + labels = labels, + endToken = _stream.Current, + }; return new TestNode { + testProgram = testProgram, Errors = errors, name = nameToken.raw, nameToken = nameToken, isAbstract = isAbstract, fromParent = fromParent, fromParentToken = fromParentToken, - statements = statements, - labels = labels, - functions = functions, startToken = startToken, endToken = _stream.Current }; @@ -3287,7 +3249,25 @@ private AssertStatement ParseAssert(Token assertToken) var endIdx = _stream.Save(); var sourceText = _stream.GetSourceText(startIdx, endIdx); - return new AssertStatement(assertToken, expr.EndToken, expr, sourceText); + var stmtNode = new AssertStatement(assertToken, expr.EndToken, expr, sourceText); + + // Optional second arg: `assert , ` where is a + // string expression (literal or variable). Surfaced in failure reports. + if (_stream.Peek.type == LexemType.ArgSplitter) + { + _stream.Advance(); // consume comma + if (TryParseExpression(out var reasonExpr)) + { + stmtNode.reason = reasonExpr; + stmtNode.endToken = reasonExpr.EndToken; + } + else + { + stmtNode.Errors.Add(new ParseError(assertToken, ErrorCodes.AssertReasonMissingExpression)); + } + } + + return stmtNode; } private RuntoStatement ParseRunto(Token runtoToken) diff --git a/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs b/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs index 4bea22d..93ba4e7 100644 --- a/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs +++ b/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs @@ -30,11 +30,14 @@ public static FadeTestResult RunTest( var sw = Stopwatch.StartNew(); var vm = new VirtualMachine(bytecode, entry.entryPointAddress) { - hostMethods = hostMethods + hostMethods = hostMethods, + // Test-mode: a failed assert (here or in main-program code reached + // via `runto`) records a TestFailure instead of throwing. + isTestExecution = true }; try { - vm.Execute().MoveNext(); + vm.Execute3(0); // infinite budget! } catch (Exception ex) { @@ -51,12 +54,18 @@ public static FadeTestResult RunTest( if (vm.assertionFailure != null) { + var reason = vm.assertionFailure.reason; + var hasReason = !string.IsNullOrEmpty(reason); + var msg = hasReason + ? $"assert failed: {vm.assertionFailure.sourceText} — {reason}" + : $"assert failed: {vm.assertionFailure.sourceText}"; return new FadeTestResult { testName = entry.name, passed = false, - failureMessage = $"assert failed: {vm.assertionFailure.sourceText}", + failureMessage = msg, failureSourceText = vm.assertionFailure.sourceText, + failureReason = reason, failureInstructionIndex = vm.assertionFailure.instructionIndex, duration = sw.Elapsed }; @@ -79,6 +88,9 @@ public class FadeTestResult public string failureMessage; // Captured assertion text from the failing `assert` (when an assert tripped). public string failureSourceText; + // Optional reason string supplied via `assert , ""`. Null + // or empty when the user didn't provide one. + public string failureReason; // IP at the moment of failure; useful for source-mapping when DebugData // is available. -1 if not applicable. public int failureInstructionIndex = -1; diff --git a/FadeBasic/FadeBasic/Virtual/Compiler.cs b/FadeBasic/FadeBasic/Virtual/Compiler.cs index 9aee7dd..1dcf513 100644 --- a/FadeBasic/FadeBasic/Virtual/Compiler.cs +++ b/FadeBasic/FadeBasic/Virtual/Compiler.cs @@ -444,6 +444,12 @@ public class Compiler private List _functionCallReplacements = new List(); private Dictionary _functionTable = new Dictionary(); + + // For each `assert` failure-branch emit site, record the buffer index of + // the placeholder PUSH int that should be patched with the assert-unwind + // trampoline's address. The trampoline is emitted once near program end; + // these get patched after that emission completes. + private List _assertTrampolinePatches = new List(); private InternedData data = new InternedData(); @@ -535,6 +541,11 @@ public void Compile(ProgramNode program) CompileTest(test); } + // Emit the assert-unwind trampoline after tests, before interned + // data. ASSERT_FAIL sites pushed a placeholder for its address; the + // call below patches every site once the real address is known. + CompileAssertUnwindTrampoline(); + { // handle interned data { // replace the jump ptr at index=0 to tell us where the data lives. var internLocationBytes = BitConverter.GetBytes(_buffer.Count); @@ -584,7 +595,7 @@ private void CompileTest(TestNode test) // FunctionStatement nodes — they're emitted separately below — so the // body's own function declarations don't pollute the test's entry-point // bytecode region. - foreach (var statement in test.statements) + foreach (var statement in test.testProgram.statements) { Compile(statement); } @@ -598,7 +609,7 @@ private void CompileTest(TestNode test) // shared _functionTable, which means the test body can call them by // name. (Stage 6 narrows visibility via the from-chain in a follow-up // pass; for v1 they're globally addressable, which is permissive.) - foreach (var function in test.functions) + foreach (var function in test.testProgram.functions) { Compile(function); } @@ -1760,9 +1771,20 @@ private void Compile(LabelDeclarationNode labelStatement) private void Compile(RuntoStatement runtoStatement) { - // Emit `PUSH int ; RUNTO`. The placeholder is patched - // in CompileJumpReplacements with the post-yield address: the byte - // immediately after the label's RUNTO_YIELD (i.e., label_addr + 2). + // Stack at RUNTO dispatch: [..., maxCycles, target]. Target is on top + // so the VM's existing pop order is preserved. Absent `max cycles` + // clause -> push int.MaxValue as the unbounded sentinel. + if (runtoStatement.maxCyclesExpression != null) + { + Compile(runtoStatement.maxCyclesExpression); + } + else + { + AddPushInt(_buffer, int.MaxValue); + } + + // Target placeholder, patched in CompileJumpReplacements with the + // post-yield address (label_addr + 2). _runtoReplacements.Add(new LabelReplacement { InstructionIndex = _buffer.Count, @@ -1778,8 +1800,10 @@ private void Compile(AssertStatement assertStatement) // ; pushes int (0 = false, !0 = true) // PUSH int ; placeholder for skip-on-pass target // JUMP_GT_ZERO ; if value > 0, jump past failure block + // ; short-circuit: only runs on failure // - // ASSERT_FAIL + // PUSH int ; address the VM jumps to in test mode + // ASSERT_FAIL ; pops trampoline addr, source text, reason // :skipAddr (continue normally) Compile(assertStatement.condition); @@ -1790,8 +1814,32 @@ private void Compile(AssertStatement assertStatement) AddPushInt(_buffer, int.MaxValue); _buffer.Add(OpCodes.JUMP_GT_ZERO); - // Failure branch: push the captured source text string and fail. + // Failure branch — only reached when the condition is zero. Because + // this comes after JUMP_GT_ZERO, the reason expression is evaluated + // lazily: a side-effecting reason (a function call, etc.) only runs + // when the assertion actually fails. + // + // Push the optional reason string first so it sits below the source + // text on the stack, then push the source text, then fail. Literal + // strings are interned via the standard literal-string compile path; + // variable references compile to a heap-pointer push. + if (assertStatement.reason != null) + { + Compile(assertStatement.reason); + } + else + { + Compile(new LiteralStringExpression(assertStatement.startToken, "")); + } Compile(new LiteralStringExpression(assertStatement.startToken, assertStatement.sourceText ?? "")); + + // Push the trampoline address as a placeholder; ASSERT_FAIL reads it + // off the stack and (in test mode) jumps there to drain defers. We + // patch the real address after the trampoline is emitted. + var trampolinePatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _assertTrampolinePatches.Add(trampolinePatchIndex); + _buffer.Add(OpCodes.ASSERT_FAIL); // Patch the skip address to point at the byte right after the failure @@ -1804,6 +1852,83 @@ private void Compile(AssertStatement assertStatement) } } + /// + /// Emit the one-time "assert unwind trampoline" used when an assert + /// fails inside a test. The trampoline drains the current scope's + /// defers (LIFO), then walks up the scope stack, draining each scope's + /// defers in turn, until only the global scope is left. Then it halts. + /// + /// All assert failure sites push the trampoline's address onto the + /// stack and ASSERT_FAIL (in test mode) sets instructionIndex to it. + /// In non-test mode ASSERT_FAIL discards the address and crashes the + /// VM via TriggerRuntimeError instead, so the trampoline never runs. + /// + private void CompileAssertUnwindTrampoline() + { + // Skip emission entirely if no assert sites exist. + if (_assertTrampolinePatches.Count == 0) return; + + var trampolineStart = _buffer.Count; + + // ── Drain loop for the current scope's defers ──────────────── + // Mirrors HandleDeferExit, but the return address pushed onto the + // data stack is `trampolineStart` itself, so a deferred body + // returns here and we pop the next defer. + AddPushInt(_buffer, trampolineStart); + _buffer.Add(OpCodes.POP_DEFER); // pushes addr or 0 + _buffer.Add(OpCodes.DUPE); + var drainEndPatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP_ZERO); // if addr==0, jump out of drain + _buffer.Add(OpCodes.JUMP); // else jump to defer body + + // ── after_drain: defer stack for current scope is empty ────── + var afterDrainAddr = _buffer.Count; + // Stack here is [trampolineStart, 0] from the JUMP_ZERO path. + _buffer.Add(OpCodes.DISCARD_TYPED); + _buffer.Add(OpCodes.DISCARD_TYPED); + + // Decide whether to pop another scope. Halt when only global is left. + _buffer.Add(OpCodes.PUSH_SCOPE_DEPTH); // depth + AddPushInt(_buffer, 1); // 1 + _buffer.Add(OpCodes.GT); // pushes (depth > 1) ? 1 : 0 + + var popAndLoopPatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); // address placeholder + _buffer.Add(OpCodes.JUMP_GT_ZERO); // if depth > 1, loop back via pop_and_loop + + // ── halt: only global scope remains; halt VM via overshoot ─── + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP); + + // ── pop_and_loop: pop one scope, jump back to trampolineStart ─ + var popAndLoopAddr = _buffer.Count; + _buffer.Add(OpCodes.POP_SCOPE); + AddPushInt(_buffer, trampolineStart); + _buffer.Add(OpCodes.JUMP); + + // Back-patch the two forward references inside the trampoline. + PatchAddress(drainEndPatchIndex, afterDrainAddr); + PatchAddress(popAndLoopPatchIndex, popAndLoopAddr); + + // Back-patch every assert site's trampoline-address placeholder. + foreach (var siteIndex in _assertTrampolinePatches) + { + PatchAddress(siteIndex, trampolineStart); + } + } + + // Helper: write a 4-byte int into the body of an `AddPushInt(buffer, _)` + // placeholder (which lays out [opcode, typecode, b0, b1, b2, b3]). + private void PatchAddress(int placeholderIndex, int value) + { + var bytes = BitConverter.GetBytes(value); + for (var i = 0; i < bytes.Length; i++) + { + _buffer[placeholderIndex + 2 + i] = bytes[i]; + } + } + private void Compile(ReturnStatement _) { _buffer.Add(OpCodes.RETURN); diff --git a/FadeBasic/FadeBasic/Virtual/OpCodes.cs b/FadeBasic/FadeBasic/Virtual/OpCodes.cs index fe8d9b6..6d10a8d 100644 --- a/FadeBasic/FadeBasic/Virtual/OpCodes.cs +++ b/FadeBasic/FadeBasic/Virtual/OpCodes.cs @@ -447,5 +447,13 @@ public static class OpCodes /// Removes all mock registrations for the current VM. No stack inputs. /// public const byte MOCK_CLEAR_ALL = 71; + + /// + /// Pushes the current scope-stack depth onto the data stack as an int. + /// Depth 1 means only the global scope is live. Used by the assert-unwind + /// trampoline to walk up the scope chain draining defers, but is a + /// general primitive any future "unwind to scope N" feature can reuse. + /// + public const byte PUSH_SCOPE_DEPTH = 72; } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs index cdf73a0..485d8f7 100644 --- a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs +++ b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs @@ -74,7 +74,8 @@ public enum VirtualRuntimeErrorType INVALID_ADDRESS, INVALID_MEMORY_COPY, CANNOT_TOKENIZE_WITHOUT_HIGHER_CONTEXT, - EXPLODE + EXPLODE, + ASSERT_FAILED } public class TokenReplacement @@ -151,10 +152,20 @@ public class VirtualMachine /// public TestFailure assertionFailure; + /// + /// True when this VM is running a test entry point. The test runner sets + /// this before Execute so a failed `assert` (anywhere — even in + /// main-program code reached via runto) records a TestFailure and halts + /// instead of throwing a runtime exception. When false, a failed assert + /// triggers a normal VM runtime error, identical to divide-by-zero etc. + /// + public bool isTestExecution; + public class TestFailure { public string sourceText; // Captured text of the asserted expression. public int instructionIndex; // IP at the moment of failure (for source-mapping). + public string reason; // Optional reason string from `assert , ""`. Empty when not provided. } /// @@ -333,6 +344,25 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp i += incrementer) { cycles++; + + // Runto max-cycles enforcement. Only the topmost frame ticks, + // so nested runtos each get their own independent budget and + // an outer frame's budget pauses while an inner runto is active. + if (runtoStack.Count > 0) + { + ref var runtoTop = ref runtoStack.buffer[runtoStack.ptr - 1]; + if (--runtoTop.cyclesRemaining < 0) + { + assertionFailure = new TestFailure + { + sourceText = "RUNTO exceeded max cycles", + instructionIndex = instructionIndex + }; + instructionIndex = int.MaxValue; + break; + } + } + var ins = Advance(); switch (ins) { @@ -440,6 +470,12 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp } stack.PushSpanAndType(new ReadOnlySpan(BitConverter.GetBytes(jumpSite)), TypeCodes.INT, TypeCodes.GetByteSize(TypeCodes.INT)); break; + case OpCodes.PUSH_SCOPE_DEPTH: + // Push current scope-stack depth as a typed int. Depth 1 = global only. + stack.PushSpanAndType( + new ReadOnlySpan(BitConverter.GetBytes(scopeStack.Count)), + TypeCodes.INT, TypeCodes.GetByteSize(TypeCodes.INT)); + break; case OpCodes.PUSH_DEFER: // read the place we should jump to when the scope is popped. VmUtil.ReadAsInt(ref stack, out var a); @@ -982,14 +1018,16 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp case OpCodes.BREAKPOINT: break; case OpCodes.RUNTO: - // Pop the target address off the data stack. + // Stack at dispatch: [..., maxCycles, target]. Pop target first. VmUtil.ReadAsInt(ref stack, out var runtoTarget); + VmUtil.ReadAsInt(ref stack, out var runtoMaxCycles); // The test-resume IP is the very next instruction after this RUNTO. // (instructionIndex has already been incremented past the RUNTO opcode.) runtoStack.Push(new RuntoFrame { targetAddr = runtoTarget, - testResumeIp = instructionIndex + testResumeIp = instructionIndex, + cyclesRemaining = runtoMaxCycles }); // Switch execution to wherever the program is currently paused. instructionIndex = programResumeIP; @@ -1014,39 +1052,57 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp break; case OpCodes.ASSERT_FAIL: { - // The data stack holds the source-text. The compiler emits - // it via the LiteralStringExpression path which produces - // [8 ptr bytes][STRING type code] (interned strings get - // CAST to STRING after the PTR push). We accept STRING - // and PTR_HEAP type codes here. - var assertTextTypeCode = stack.Pop(); - var ptrBytes = new byte[8]; - for (var b = 7; b >= 0; b--) ptrBytes[b] = stack.Pop(); - var textPtr = VmPtr.FromBytes(ptrBytes); - string text = ""; - try + // Data stack at dispatch (bottom → top): + // reason (string), sourceText (string), trampolineAddr (int) + // Strings come from the LiteralStringExpression path + // ([8 ptr bytes][STRING type code]); interned strings get + // CAST to STRING after the PTR push, variable refs push the + // same shape with a heap ptr. We accept either STRING or + // PTR_HEAP. trampolineAddr is the compiler-baked address of + // the assert-unwind trampoline, used in test mode only. + VmUtil.ReadAsInt(ref stack, out var trampolineAddr); + var text = PopAssertString(); + var reasonText = PopAssertString(); + if (isTestExecution) { - if (heap.TryGetAllocationSize(textPtr, out var len) && len > 0) + // Re-entrancy guard: if a deferred body that we're + // running as part of unwinding contains its own + // failing assert, keep the first failure and halt + // instead of restarting the trampoline. + if (assertionFailure != null) { - heap.Read(textPtr, len, out var bytes); - // Fade strings are stored as 4-bytes-per-char (uint codepoints). - var charCount = len / 4; - var chars = new char[charCount]; - for (var c = 0; c < charCount; c++) - { - chars[c] = (char)BitConverter.ToUInt32(bytes, c * 4); - } - text = new string(chars); + instructionIndex = int.MaxValue; + break; } + // Test-mode: record the failure (this path is also + // taken when a test runtos into main-program code + // that hits an assert) and redirect to the trampoline + // so defers in every live scope get drained. + assertionFailure = new TestFailure + { + sourceText = text, + reason = reasonText, + instructionIndex = instructionIndex + }; + instructionIndex = trampolineAddr; } - catch { /* best-effort recovery; leave text empty */ } - assertionFailure = new TestFailure + else { - sourceText = text, - instructionIndex = instructionIndex - }; - // Halt execution by jumping past program.Length, mirroring CompileEnd. - instructionIndex = int.MaxValue; + // Main-program execution: a failed assert is a + // hard runtime error, on par with divide-by-zero. + // Defers do NOT run; trampolineAddr is ignored. + var hasReason = !string.IsNullOrEmpty(reasonText); + var message = hasReason + ? $"assert failed: {text} — {reasonText}" + : $"assert failed: {text}"; + TriggerRuntimeError(new VirtualRuntimeError + { + insIndex = instructionIndex, + type = VirtualRuntimeErrorType.ASSERT_FAILED, + message = message + }); + instructionIndex = int.MaxValue; + } break; } default: @@ -1077,6 +1133,7 @@ public struct RuntoFrame { public int targetAddr; public int testResumeIp; + public int cyclesRemaining; } void TriggerRuntimeError(VirtualRuntimeError error) @@ -1087,6 +1144,35 @@ void TriggerRuntimeError(VirtualRuntimeError error) throw new VirtualRuntimeException(error); } } + + // Pop one Fade string off the data stack and materialize it as a C# string. + // Used by ASSERT_FAIL. The compiler pushes strings as [8 ptr bytes][type code] + // and either STRING or PTR_HEAP type codes may appear here. Returns "" if + // the pointer is null or the read fails. + private string PopAssertString() + { + stack.Pop(); // type code; accepted unconditionally + var ptrBytes = new byte[8]; + for (var b = 7; b >= 0; b--) ptrBytes[b] = stack.Pop(); + var ptr = VmPtr.FromBytes(ptrBytes); + try + { + if (heap.TryGetAllocationSize(ptr, out var len) && len > 0) + { + heap.Read(ptr, len, out var bytes); + // Fade strings are stored as 4-bytes-per-char (uint codepoints). + var charCount = len / 4; + var chars = new char[charCount]; + for (var c = 0; c < charCount; c++) + { + chars[c] = (char)BitConverter.ToUInt32(bytes, c * 4); + } + return new string(chars); + } + } + catch { /* best-effort recovery; fall through */ } + return ""; + } } public class VirtualRuntimeException : Exception diff --git a/FadeBasic/Tests/AssertMacroTests.cs b/FadeBasic/Tests/AssertMacroTests.cs index 9e17bfb..a7afd93 100644 --- a/FadeBasic/Tests/AssertMacroTests.cs +++ b/FadeBasic/Tests/AssertMacroTests.cs @@ -25,7 +25,19 @@ private VirtualMachine RunTest(string src, string testName) var entry = compiler.TestManifest.First(t => t.name == testName); var vm = new VirtualMachine(program, entry.entryPointAddress); vm.hostMethods = compiler.methodTable; - vm.Execute().MoveNext(); + // Mirror the SDK test runner so assert behavior matches production: + // failures record TestFailure instead of throwing. + vm.isTestExecution = true; + vm.Execute3(); + return vm; + } + + private VirtualMachine RunMain(string src) + { + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program); + vm.hostMethods = compiler.methodTable; + vm.Execute3(); return vm; } @@ -128,18 +140,83 @@ assert 1 } [Test] - public void Assert_OutsideTest_StillCompiles() + public void Assert_OutsideTest_Passing_RunsWithoutCrash() { - // For now, assert outside a test compiles but its semantics are undefined. - // (Stage 6+ should add a parse-time error for this; for now it's permissive.) + // A truthy assert in the main program runs cleanly — no VM crash, no + // assertionFailure recorded. var src = @" assert 1 end "; - Assert.DoesNotThrow(() => - { - var (compiler, _) = Compile(src); - }); + var vm = RunMain(src); + Assert.That(vm.assertionFailure, Is.Null); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + } + + [Test] + public void Assert_OutsideTest_Failing_CrashesVm() + { + // A failing assert in the main program triggers a VM runtime crash + // (VirtualRuntimeException), the same shape as divide-by-zero etc. + var src = @" +assert 0, ""kaboom"" +end +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + var ex = Assert.Throws(() => vm.Execute3()); + Assert.That(ex.Error.type, Is.EqualTo(VirtualRuntimeErrorType.ASSERT_FAILED)); + Assert.That(ex.Error.message, Does.Contain("kaboom"), + "crash message should surface the assert's reason"); + } + + [Test] + public void Assert_OutsideTest_Failing_NoReason_StillCrashesVm() + { + var src = @" +assert 0 +end +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + var ex = Assert.Throws(() => vm.Execute3()); + Assert.That(ex.Error.type, Is.EqualTo(VirtualRuntimeErrorType.ASSERT_FAILED)); + } + + [Test] + public void Assert_InMainProgram_ViaRunto_FailsTheTest() + { + // When a test runtos into main-program code that contains a failing + // assert, the test should record the failure (not crash the VM). + var src = @" +assert 0, ""main-program assert"" +checkpoint: +end + +test runto_test + runto checkpoint +endtest +"; + var vm = RunTest(src, "runto_test"); + Assert.That(vm.assertionFailure, Is.Not.Null, + "main-program assert reached via runto must mark the test as failed"); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("main-program assert")); + } + + [Test] + public void Assert_InMainProgram_ViaRunto_Passing_DoesNotFailTest() + { + var src = @" +assert 1 +checkpoint: +end + +test runto_test + runto checkpoint +endtest +"; + var vm = RunTest(src, "runto_test"); + Assert.That(vm.assertionFailure, Is.Null); } [Test] @@ -155,4 +232,180 @@ assert 1 + 1 var vm = RunTest(src, "foo"); Assert.That(vm.assertionFailure, Is.Null); } + + [Test] + public void Assert_WithReasonLiteral_FailureCapturesReason() + { + var src = @" +test foo + local x as integer = 5 + assert x = 99, ""x should be 99"" +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("x should be 99")); + Assert.That(vm.assertionFailure.sourceText, Does.Contain("x")); + } + + [Test] + public void Assert_WithReasonLiteral_PassDoesNotPopulateReason() + { + // When the assert passes, no failure is recorded — and the reason + // expression must not have run side-effects (no observable way to + // check from a pure-eval literal, but at minimum no failure exists). + var src = @" +test foo + assert 1 = 1, ""never seen"" +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Null); + } + + [Test] + public void Assert_WithReasonVariable_FailureCapturesReason() + { + var src = @" +test foo + local msg as string = ""boom"" + assert 0, msg +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("boom")); + } + + [Test] + public void Assert_WithoutReason_HasEmptyReason() + { + var src = @" +test foo + assert 0 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("")); + } + + [Test] + public void Assert_ReasonMustBeString_NonStringReportsError() + { + // Passing a non-string reason (here, an integer) should fail + // type-checking, surfacing AssertReasonMustBeString. + var src = @" +test foo + assert 1 = 1, 42 +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var allErrors = prog.GetAllErrors(); + Assert.That(allErrors.Any(e => e.errorCode.code == ErrorCodes.AssertReasonMustBeString.code), + "expected AssertReasonMustBeString error; got: " + + string.Join(", ", allErrors.Select(e => e.errorCode.code.ToString()))); + } + + // ── Defer-on-assert-failure tests ────────────────────────────────────── + // These verify the unwind trampoline: on a failed assert inside a test, + // every live scope's defers run (LIFO), then the failure is reported. + // Main-program asserts (running standalone) deliberately skip defers. + + [Test] + public void Assert_TestBodyDefer_RunsOnFailure() + { + TestCommands.staticPrintBuffer.Clear(); + var src = @" +test defer_runs_on_fail + defer static print ""cleanup"" + assert 0, ""boom"" +endtest +"; + var vm = RunTest(src, "defer_runs_on_fail"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("boom")); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "cleanup" }), + "test-body defer must run during assert-unwind"); + } + + [Test] + public void Assert_TestBodyDefers_RunInLifoOrder() + { + TestCommands.staticPrintBuffer.Clear(); + var src = @" +test multi_defer + defer static print ""a"" + defer static print ""b"" + defer static print ""c"" + assert 0 +endtest +"; + var vm = RunTest(src, "multi_defer"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "c", "b", "a" }), + "defers must run in LIFO order during unwind"); + } + + [Test] + public void Assert_FunctionDefer_AndTestDefer_BothRun() + { + TestCommands.staticPrintBuffer.Clear(); + var src = @" +function helper() + defer static print ""func"" + assert 0, ""inside helper"" +endfunction + +test cross_scope + defer static print ""test"" + helper() +endtest +"; + var vm = RunTest(src, "cross_scope"); + Assert.That(vm.assertionFailure, Is.Not.Null); + Assert.That(vm.assertionFailure.reason, Is.EqualTo("inside helper")); + // helper's scope drains first (innermost), then test's scope. + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "func", "test" }), + "function-scope defers must drain before unwinding back to test scope"); + } + + [Test] + public void Assert_PassingTest_StillRunsDefers() + { + // Sanity: defers also run on the success path (existing behavior; + // this just guards against the trampoline accidentally bypassing + // normal scope-exit defer draining for passing tests). + TestCommands.staticPrintBuffer.Clear(); + var src = @" +test passing_with_defer + defer static print ""cleanup"" + assert 1 +endtest +"; + var vm = RunTest(src, "passing_with_defer"); + Assert.That(vm.assertionFailure, Is.Null); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "cleanup" })); + } + + [Test] + public void Assert_MainProgramFailure_SkipsDefers() + { + // Non-test execution: a failed assert is a hard crash; defers do + // NOT run (matches divide-by-zero etc.). + TestCommands.staticPrintBuffer.Clear(); + var src = @" +defer static print ""never seen"" +assert 0, ""crash"" +end +"; + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + Assert.Throws(() => vm.Execute3()); + Assert.That(TestCommands.staticPrintBuffer, Is.Empty, + "main-program assert is a hard crash; defers must not run"); + } } diff --git a/FadeBasic/Tests/MockParserTests.cs b/FadeBasic/Tests/MockParserTests.cs index c79d181..1ba74b8 100644 --- a/FadeBasic/Tests/MockParserTests.cs +++ b/FadeBasic/Tests/MockParserTests.cs @@ -20,7 +20,7 @@ private MockStatement FindFirstMock(ProgramNode prog) { foreach (var t in prog.tests) { - foreach (var stmt in t.statements) + foreach (var stmt in t.testProgram.statements) { if (stmt is MockStatement m) return m; } @@ -32,7 +32,7 @@ private ClearMockStatement FindFirstClearMock(ProgramNode prog) { foreach (var t in prog.tests) { - foreach (var stmt in t.statements) + foreach (var stmt in t.testProgram.statements) { if (stmt is ClearMockStatement c) return c; } @@ -321,14 +321,18 @@ clear mocks } [Test] - public void Assert_OutsideTest_Errors() + public void Assert_OutsideTest_IsAllowed() { + // `assert` is now legal in the main program. When the VM runs the + // program directly and an assert fails, it triggers a runtime crash + // (verified by VM-side tests). Parse should produce no errors here. var src = @" assert 1 = 1 "; - var prog = Parse(src, out var errs); + Parse(src, out var errs); Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.AssertOutsideTest)), - Is.True, - "expected AssertOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + Is.False, + "AssertOutsideTest should no longer be raised; got: " + + string.Join(", ", errs.Select(e => e.Display))); } } diff --git a/FadeBasic/Tests/ParserTests_Erros.cs b/FadeBasic/Tests/ParserTests_Erros.cs index 98020c5..52cb327 100644 --- a/FadeBasic/Tests/ParserTests_Erros.cs +++ b/FadeBasic/Tests/ParserTests_Erros.cs @@ -278,6 +278,22 @@ public void ParseError_Command_InvalidTypes() // Assert.That(errors[0].Display, Is.EqualTo($"[1:0] - {ErrorCodes.GotoMissingLabel}")); } + + [Test] + public void ParseError_TypeCheck_FunctionAnd() + { + var input = @" + FUNCTION evenAndPositive(n) + isEven = n mod 2 = 0 + isPositive = n > 0 + ENDFUNCTION isEven and isPositive + +"; + var parser = MakeParser(input); + var prog = parser.ParseProgram(); + prog.AssertNoParseErrors(); + // Assert.That(errors[0].Display, Is.EqualTo($"[1:0] - {ErrorCodes.GotoMissingLabel}")); + } [Test] public void ParseError_TypeCheck_IntToFloat() diff --git a/FadeBasic/Tests/RuntoCompilerTests.cs b/FadeBasic/Tests/RuntoCompilerTests.cs index d4a3963..e672eab 100644 --- a/FadeBasic/Tests/RuntoCompilerTests.cs +++ b/FadeBasic/Tests/RuntoCompilerTests.cs @@ -32,8 +32,8 @@ runto someLabel var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); prog.AssertNoParseErrors(); Assert.That(prog.tests.Count, Is.EqualTo(1)); - Assert.That(prog.tests[0].statements.Count, Is.EqualTo(1)); - var rt = prog.tests[0].statements[0] as RuntoStatement; + Assert.That(prog.tests[0].testProgram.statements.Count, Is.EqualTo(1)); + var rt = prog.tests[0].testProgram.statements[0] as RuntoStatement; Assert.That(rt, Is.Not.Null); Assert.That(rt.targetLabel, Is.EqualTo("somelabel")); } @@ -52,7 +52,7 @@ max cycles 1000 var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); prog.AssertNoParseErrors(); - var rt = prog.tests[0].statements[0] as RuntoStatement; + var rt = prog.tests[0].testProgram.statements[0] as RuntoStatement; Assert.That(rt, Is.Not.Null); Assert.That(rt.targetLabel, Is.EqualTo("somelabel")); Assert.That(rt.maxCyclesExpression, Is.Not.Null); @@ -202,7 +202,7 @@ runto mylabel max cycles 1000 var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); prog.AssertNoParseErrors(); - var rt = prog.tests[0].statements[0] as RuntoStatement; + var rt = prog.tests[0].testProgram.statements[0] as RuntoStatement; Assert.That(rt, Is.Not.Null); Assert.That(rt.targetLabel, Is.EqualTo("mylabel")); Assert.That(rt.maxCyclesExpression, Is.Not.Null); diff --git a/FadeBasic/Tests/RuntoNavigationTests.cs b/FadeBasic/Tests/RuntoNavigationTests.cs index 7d5a6c8..c438422 100644 --- a/FadeBasic/Tests/RuntoNavigationTests.cs +++ b/FadeBasic/Tests/RuntoNavigationTests.cs @@ -35,7 +35,7 @@ runto checkpoint Assert.That(errs, Is.Empty, "expected clean parse; got: " + string.Join(", ", errs.Select(e => e.Display))); - var runto = prog.tests[0].statements + var runto = prog.tests[0].testProgram.statements .OfType() .First(); Assert.That(runto.DeclaredFromSymbol, Is.Not.Null, @@ -59,7 +59,7 @@ runto does_not_exist endtest "; var prog = Parse(src, out _); - var runto = prog.tests[0].statements + var runto = prog.tests[0].testProgram.statements .OfType() .First(); Assert.That(runto.DeclaredFromSymbol, Is.Null); @@ -83,7 +83,7 @@ runto fnInner endtest "; var prog = Parse(src, out var errs); - var runto = prog.tests[0].statements.OfType().First(); + var runto = prog.tests[0].testProgram.statements.OfType().First(); Assert.That(runto.DeclaredFromSymbol, Is.Not.Null); var label = (LabelDeclarationNode)runto.DeclaredFromSymbol.source; Assert.That(label.label, Is.EqualTo("fnInner").IgnoreCase); diff --git a/FadeBasic/Tests/RuntoOpCodeTests.cs b/FadeBasic/Tests/RuntoOpCodeTests.cs deleted file mode 100644 index fca1387..0000000 --- a/FadeBasic/Tests/RuntoOpCodeTests.cs +++ /dev/null @@ -1,236 +0,0 @@ -using System; -using FadeBasic; -using FadeBasic.Ast; -using FadeBasic.Virtual; - -namespace Tests; - -[TestFixture] -public class RuntoOpCodeTests -{ - /// - /// Produces a minimal valid VM program with a stub interned-data section, - /// then lets the caller specify the code bytes that live between the - /// 4-byte header and the interned-data section. - /// - private byte[] BuildProgram(byte[] code) - { - var src = "x = 0\n"; - var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); - var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); - var prog = parser.ParseProgram(); - var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); - compiler.Compile(prog); - var compiled = compiler.Program.ToArray(); - - var origInterned = BitConverter.ToInt32(compiled, 0); - var internedTail = compiled.AsSpan(origInterned, compiled.Length - origInterned).ToArray(); - - var newInternedStart = 4 + code.Length; - var program = new byte[4 + code.Length + internedTail.Length]; - var headerBytes = BitConverter.GetBytes(newInternedStart); - Array.Copy(headerBytes, 0, program, 0, 4); - Array.Copy(code, 0, program, 4, code.Length); - Array.Copy(internedTail, 0, program, newInternedStart, internedTail.Length); - return program; - } - - private static void EmitPushInt(List code, int value) - { - code.Add(OpCodes.PUSH); - code.Add(TypeCodes.INT); - code.AddRange(BitConverter.GetBytes(value)); - } - - /// - /// Halts execution by jumping past program.Length. Mirrors how the compiler - /// handles an `end` statement (CompileEnd in Compiler.cs). - /// - private static void EmitHalt(List code) - { - EmitPushInt(code, int.MaxValue); - code.Add(OpCodes.JUMP); - } - - [Test] - public void Runto_HitsTarget_YieldsBack() - { - // Layout (byte addresses, code starts at 4): - // 4: NOOP program "label" - // 5: RUNTO_YIELD target_addr if matched = 6 - // 6: NOOP post-yield program resume - // 7: EXPLODE sentinel: must not reach - // 8: PUSH 6 test entry: target = 6 - // 14: RUNTO test_resume_ip = 15 - // 15: NOOP test resumes here after yield - // 16: PUSH int.MaxValue - // 22: JUMP halt - var code = new List(); - code.Add(OpCodes.NOOP); // 4 - code.Add(OpCodes.RUNTO_YIELD); // 5 - code.Add(OpCodes.NOOP); // 6 - code.Add(OpCodes.EXPLODE); // 7 - EmitPushInt(code, 6); // 8-13 - code.Add(OpCodes.RUNTO); // 14 - code.Add(OpCodes.NOOP); // 15 - EmitHalt(code); // 16-22 - - var program = BuildProgram(code.ToArray()); - var vm = new VirtualMachine(program, entryPointAddress: 8); - - vm.Execute().MoveNext(); - - Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); - Assert.That(vm.programResumeIP, Is.EqualTo(6)); - } - - [Test] - public void Runto_PassThroughNonMatchingLabel_NoYield() - { - // First yield at addr 5 has post-ip = 6 (doesn't match target 8). - // Second yield at addr 7 has post-ip = 8 (matches target 8). Yields. - var code = new List(); - code.Add(OpCodes.NOOP); // 4 - code.Add(OpCodes.RUNTO_YIELD); // 5 - code.Add(OpCodes.NOOP); // 6 - code.Add(OpCodes.RUNTO_YIELD); // 7 - code.Add(OpCodes.NOOP); // 8 - code.Add(OpCodes.EXPLODE); // 9 - EmitPushInt(code, 8); // 10-15 - code.Add(OpCodes.RUNTO); // 16 - code.Add(OpCodes.NOOP); // 17 - EmitHalt(code); // 18-24 - - var program = BuildProgram(code.ToArray()); - var vm = new VirtualMachine(program, entryPointAddress: 10); - - vm.Execute().MoveNext(); - - Assert.That(vm.programResumeIP, Is.EqualTo(8)); - Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); - } - - [Test] - public void Runto_EmptyRuntoStack_NoYield() - { - // RUNTO_YIELD with empty runtoStack falls through. No exception. - var code = new List(); - code.Add(OpCodes.RUNTO_YIELD); // 4 - code.Add(OpCodes.NOOP); // 5 - EmitHalt(code); // 6-12 - - var program = BuildProgram(code.ToArray()); - var vm = new VirtualMachine(program); - - vm.Execute().MoveNext(); - - Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); - } - - [Test] - public void Runto_DefaultEntryPoint_PreservesRunBehavior() - { - // A normally-compiled program runs unchanged with the new constructor. - var src = "x = 42\n"; - var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); - var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); - var prog = parser.ParseProgram(); - var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); - compiler.Compile(prog); - - var vm = new VirtualMachine(compiler.Program); - vm.Execute().MoveNext(); - - Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); - Assert.That(vm.programResumeIP, Is.EqualTo(4)); - } - - [Test] - public void Runto_CustomEntryPoint_StartsThere() - { - // EXPLODE at addr 4 (default entry); custom entry at 5 just halts. - var code = new List(); - code.Add(OpCodes.EXPLODE); // 4 - EmitHalt(code); // 5-11 - - var program = BuildProgram(code.ToArray()); - var vm = new VirtualMachine(program, entryPointAddress: 5); - - Assert.DoesNotThrow(() => vm.Execute().MoveNext()); - } - - [Test] - public void Runto_PushesFrameOntoRuntoStack() - { - // Target an address that has no RUNTO_YIELD. The frame should remain on - // the stack after execution stops via halt at the end of the program area. - // We need the program area to halt cleanly (no RUNTO_YIELD), so we put a - // halt at addr 4 — but that'd terminate before the test runs. Instead, - // the program area has a halt that fires when reached after RUNTO. - // - // Layout: - // 4: PUSH int.MaxValue program halt - // 10: JUMP - // 11: PUSH 100 test entry: target = 100 (never matched) - // 17: RUNTO test_resume_ip = 18 - // 18: - var code = new List(); - EmitHalt(code); // 4-10 (program halt) - EmitPushInt(code, 100); // 11-16 - code.Add(OpCodes.RUNTO); // 17 - code.Add(OpCodes.NOOP); // 18 (unreachable but here for clarity) - EmitHalt(code); // 19-25 - - var program = BuildProgram(code.ToArray()); - var vm = new VirtualMachine(program, entryPointAddress: 11); - - vm.Execute().MoveNext(); - - Assert.That(vm.runtoStack.Count, Is.EqualTo(1), "frame should remain on stack (target never hit)"); - var frame = vm.runtoStack.buffer[vm.runtoStack.ptr - 1]; - Assert.That(frame.targetAddr, Is.EqualTo(100)); - Assert.That(frame.testResumeIp, Is.EqualTo(18)); - } - - [Test] - public void Runto_MultipleYields_ProgramResumesFromSavedIP() - { - // Two runtos against two yields. Second runto must resume from the IP - // saved during the first yield, not from __main entry. - // - // 4: NOOP label A - // 5: RUNTO_YIELD target if matched = 6 - // 6: NOOP label B - // 7: RUNTO_YIELD target if matched = 8 - // 8: NOOP program-end resume - // 9: EXPLODE sentinel - // 10: PUSH 6 test: first runto target - // 16: RUNTO test_resume_ip = 17 - // 17: PUSH 8 test: second runto target - // 23: RUNTO test_resume_ip = 24 - // 24: NOOP - // 25: PUSH int.MaxValue - // 31: JUMP - var code = new List(); - code.Add(OpCodes.NOOP); // 4 - code.Add(OpCodes.RUNTO_YIELD); // 5 - code.Add(OpCodes.NOOP); // 6 - code.Add(OpCodes.RUNTO_YIELD); // 7 - code.Add(OpCodes.NOOP); // 8 - code.Add(OpCodes.EXPLODE); // 9 - EmitPushInt(code, 6); // 10-15 - code.Add(OpCodes.RUNTO); // 16 - EmitPushInt(code, 8); // 17-22 - code.Add(OpCodes.RUNTO); // 23 - code.Add(OpCodes.NOOP); // 24 - EmitHalt(code); // 25-31 - - var program = BuildProgram(code.ToArray()); - var vm = new VirtualMachine(program, entryPointAddress: 10); - - vm.Execute().MoveNext(); - - Assert.That(vm.programResumeIP, Is.EqualTo(8)); - Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); - } -} diff --git a/FadeBasic/Tests/TestBlockParserTests.cs b/FadeBasic/Tests/TestBlockParserTests.cs index e5fa5ad..94dc790 100644 --- a/FadeBasic/Tests/TestBlockParserTests.cs +++ b/FadeBasic/Tests/TestBlockParserTests.cs @@ -47,7 +47,7 @@ test foo Assert.That(prog.tests[0].name, Is.EqualTo("foo")); Assert.That(prog.tests[0].isAbstract, Is.False); Assert.That(prog.tests[0].fromParent, Is.Null); - Assert.That(prog.tests[0].statements.Count, Is.EqualTo(0)); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.EqualTo(0)); } [Test] @@ -168,7 +168,7 @@ test foo "; var prog = ParseClean(src); Assert.That(prog.tests.Count, Is.EqualTo(1)); - Assert.That(prog.tests[0].statements.Count, Is.EqualTo(2)); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.EqualTo(2)); } [Test] diff --git a/FadeBasic/Tests/TestExecutionTests.cs b/FadeBasic/Tests/TestExecutionTests.cs index 76143b3..4dbd748 100644 --- a/FadeBasic/Tests/TestExecutionTests.cs +++ b/FadeBasic/Tests/TestExecutionTests.cs @@ -25,7 +25,7 @@ private VirtualMachine RunTest(string src, string testName) var entry = compiler.TestManifest.First(t => t.name == testName); var vm = new VirtualMachine(program, entry.entryPointAddress); vm.hostMethods = compiler.methodTable; - vm.Execute().MoveNext(); + vm.Execute3(); return vm; } @@ -79,7 +79,7 @@ runto start var entry = compiler.TestManifest.First(t => t.name == "foo"); var vm = new VirtualMachine(program, entry.entryPointAddress); vm.hostMethods = compiler.methodTable; - vm.Execute().MoveNext(); + vm.Execute3(); // After yield, runtoStack is empty and programResumeIP is the post-yield IP. Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); @@ -106,7 +106,7 @@ runto second var entry = compiler.TestManifest.First(t => t.name == "foo"); var vm = new VirtualMachine(program, entry.entryPointAddress); vm.hostMethods = compiler.methodTable; - vm.Execute().MoveNext(); + vm.Execute3(); Assert.That(vm.runtoStack.Count, Is.EqualTo(0)); } @@ -128,7 +128,7 @@ runto somewhere var (compiler, program) = Compile(src); var vm = new VirtualMachine(program); // default entry vm.hostMethods = compiler.methodTable; - vm.Execute().MoveNext(); + vm.Execute3(); Assert.That(vm.runtoStack.Count, Is.EqualTo(0), "default entry runs program code only; runtoStack should never get touched"); @@ -152,11 +152,11 @@ test beta var vmA = new VirtualMachine(program, alpha.entryPointAddress); vmA.hostMethods = compiler.methodTable; - vmA.Execute().MoveNext(); + vmA.Execute3(); var vmB = new VirtualMachine(program, beta.entryPointAddress); vmB.hostMethods = compiler.methodTable; - vmB.Execute().MoveNext(); + vmB.Execute3(); // Both halt cleanly. Assert.That(vmA.runtoStack.Count, Is.EqualTo(0)); diff --git a/FadeBasic/Tests/TestFunctionTests.cs b/FadeBasic/Tests/TestFunctionTests.cs index ea772cf..4c815cf 100644 --- a/FadeBasic/Tests/TestFunctionTests.cs +++ b/FadeBasic/Tests/TestFunctionTests.cs @@ -25,7 +25,7 @@ private VirtualMachine RunTest(string src, string testName) var entry = compiler.TestManifest.First(t => t.name == testName); var vm = new VirtualMachine(program, entry.entryPointAddress); vm.hostMethods = compiler.methodTable; - vm.Execute().MoveNext(); + vm.Execute3(); return vm; } @@ -42,8 +42,8 @@ endfunction 5 var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); prog.AssertNoParseErrors(); - Assert.That(prog.tests[0].functions.Count, Is.EqualTo(1)); - Assert.That(prog.tests[0].functions[0].name, Is.EqualTo("helper")); + Assert.That(prog.tests[0].testProgram.functions.Count, Is.EqualTo(1)); + Assert.That(prog.tests[0].testProgram.functions[0].name, Is.EqualTo("helper")); } [Test] @@ -64,6 +64,25 @@ local result as integer } + [Test] + public void MaxCycles_FailsIfNotReached() + { + var src = @" +while 1 + ` loop forever. +endwhile +L1: + +test foo + RUNTO L1 max cycles 500 +endtest +"; + var vm = RunTest(src, "foo"); + Assert.That(vm.assertionFailure.sourceText, Is.EqualTo("RUNTO exceeded max cycles")); + } + + + [Test] public void TestFunction_CalledFromTestBody_DependsOnGlobalState_Works() { @@ -188,6 +207,9 @@ local x as integer public void TestLabel_GotoFromMainProgram_Errors() { // Main program code cannot goto a label declared inside a test. + // Per the "parent never reads into test" rule, the test's labels are + // invisible to main — so the goto fails as UnknownLabel rather than + // TraverseLabelBetweenScopes. Either error proves the goto is rejected. var src = @" goto retry end @@ -200,9 +222,11 @@ test foo var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); var prog = parser.ParseProgram(); var errs = prog.GetAllErrors(); - Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TraverseLabelBetweenScopes)), + Assert.That(errs.Any(e => + e.errorCode.Equals(ErrorCodes.UnknownLabel) + || e.errorCode.Equals(ErrorCodes.TraverseLabelBetweenScopes)), Is.True, - "expected cross-namespace goto error; got: " + string.Join(", ", errs.Select(e => e.Display))); + "expected goto-into-test to be rejected; got: " + string.Join(", ", errs.Select(e => e.Display))); } [Test] diff --git a/FadeBasic/Tests/TestScopeStrictnessTests.cs b/FadeBasic/Tests/TestScopeStrictnessTests.cs index c81ca85..dadea86 100644 --- a/FadeBasic/Tests/TestScopeStrictnessTests.cs +++ b/FadeBasic/Tests/TestScopeStrictnessTests.cs @@ -169,10 +169,14 @@ test foo } [Test] - public void Strictness_AssignmentToProgramVar_PreRunto_Errors() + public void Strictness_AssignmentToProgramVar_PreRunto_CreatesImplicitTestLocal() { - // Writing to a program-scope variable that hasn't been declared yet - // (no runto has reached it) should error. + // Bare `x = 99` in a test (no runto) follows BASIC's normal "assign + // to unbound name creates a local" rule, scoped to the test. The + // program-scope `x` is NOT in `visible`, so the test's write is an + // implicit-test-local declaration — no error. To actually write + // through to program-scope `x`, the test would need a runto past + // its declaration so it lands in `visible`. var src = @" x = 0 end @@ -183,8 +187,8 @@ test foo "; Parse(src, out var errs); Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), - Is.True, - "writing program-scope x pre-runto should error; got: " + Is.False, + "bare assignment in a test should be an implicit test-local, not an error; got: " + string.Join(", ", errs.Select(e => e.Display))); } @@ -366,10 +370,14 @@ runto fnInner } [Test] - public void Strictness_RuntoFunctionInternalLabel_AssignmentToFunctionLocal_AfterLabel_Errors() + public void Strictness_RuntoFunctionInternalLabel_AssignmentToLaterLocal_CreatesImplicitTestLocal() { - // Test writes to a function local declared *after* the runto target. - // LHS visibility is enforced too — should error. + // After `runto fnInner`, visible = scope_at[fnInner] = { seed } + // (the param) — `late` is declared *after* fnInner so it's NOT in + // visible. The test's `late = 7` is bare-assignment-to-unbound, + // which creates an implicit test-local rather than writing through + // to the function's `late`. To actually mutate that function local, + // the runto target would need to be past the `local late` line. var src = @" do_work(1) end @@ -387,8 +395,8 @@ runto fnInner "; Parse(src, out var errs); Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableNotYetDeclared)), - Is.True, - "writing to function local `late` declared after fnInner should error; got: " + Is.False, + "bare assignment in a test should be an implicit test-local; got: " + string.Join(", ", errs.Select(e => e.Display))); } diff --git a/FadeBasic/Tests/TestScopeTests.cs b/FadeBasic/Tests/TestScopeTests.cs index 5e0ea29..6d1f40c 100644 --- a/FadeBasic/Tests/TestScopeTests.cs +++ b/FadeBasic/Tests/TestScopeTests.cs @@ -29,7 +29,7 @@ test foo // For now, scope-check may flag this as something — that's OK; what we want // to verify is parser-level: the statement is recognized. Assert.That(prog.tests.Count, Is.EqualTo(1)); - Assert.That(prog.tests[0].statements.Count, Is.GreaterThan(0)); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.GreaterThan(0)); } [Test] @@ -65,7 +65,7 @@ test foo var program = compiler.Program.ToArray(); var vm = new VirtualMachine(program, entry.entryPointAddress); vm.hostMethods = compiler.methodTable; - Assert.DoesNotThrow(() => vm.Execute().MoveNext()); + Assert.DoesNotThrow(() => vm.Execute3()); } [Test] @@ -101,7 +101,7 @@ runto mylabel "; var prog = Parse(src, out _, checkScope: false); Assert.That(prog.tests.Count, Is.EqualTo(1)); - Assert.That(prog.tests[0].statements.Count, Is.EqualTo(3)); + Assert.That(prog.tests[0].testProgram.statements.Count, Is.EqualTo(3)); } [Test] @@ -157,4 +157,627 @@ test beta "fresh scope per test means same local name in two tests is fine; got: " + string.Join(", ", errs.Select(e => e.Display))); } + + [Test] + public void ProgramLocal_NotVisibleInTest_CommandArg() + { + // `local x` is a top-level declaration. The test has no runto, so its + // visible-program-set is just globals (empty). Referencing `x` inside + // a command arg (`print x`) must be flagged TestVariableUnreachable — + // exposes the missing CommandStatement case in TestScopeStrictnessVisitor. + var src = @" +local x = 42 +_L1: + +test sample + print x +endtest +"; + AssertHasUnreachable(src, "x", "print x"); + } + + [Test] + public void ProgramRefCommandIntroducedVar_NotVisibleInTest() + { + // `inc x` at program top-level introduces `x` as a program variable — + // the base scope checker treats ref-command args as bindings + // (Parser.cs Scope.AddCommand -> TryAddVariable). A test that + // references `x` without a runto past this point must be flagged + // TestVariableUnreachable. + // + // Note: `inc x` *inside* the test body is NOT this scenario — there + // it acts like `x = ...`, implicitly declaring a test-local. That + // case is allowed and should not error. + // + // Exposes two compound gaps in TestScopeStrictnessVisitor: + // 1. WalkStatements has no CommandStatement case, so top-level + // ref-command bindings never enter allTopLevelNames or any + // scope_at snapshot. + // 2. VisitStatement has no CommandStatement case, so the test's + // `print x` arg is never validated. + // Both fixes are needed before this test passes. + var src = @" +inc x +_L1: + +test sample + print x +endtest +"; + AssertHasUnreachable(src, "x", "print x (x introduced by program `inc x`)"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_WhileCondition() + { + // The visitor descends into `while` body statements but never visits + // `whileStmt.condition`. A reference there slips past validation. + var src = @" +local x = 42 +_L1: + +test sample + while x > 0 + endwhile +endtest +"; + AssertHasUnreachable(src, "x", "while x > 0"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_ForBounds() + { + // ForStatement case adds the iterator to test-locals and walks the + // body, but doesn't check startValue/endValue/stepValue expressions. + var src = @" +local x = 42 +_L1: + +test sample + for i = 1 to x + next +endtest +"; + AssertHasUnreachable(src, "x", "for i = 1 to x"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_RepeatUntilCondition() + { + // `RepeatUntilStatement` is handled in WalkStatements but completely + // absent from VisitStatement — body and `until` condition both skipped. + var src = @" +local x = 42 +_L1: + +test sample + repeat + until x > 0 +endtest +"; + AssertHasUnreachable(src, "x", "until x > 0"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_SwitchExpression() + { + // `SwitchStatement` is in WalkStatements but absent from VisitStatement. + // The `select` expression and case bodies are both unchecked. + var src = @" +local x = 42 +_L1: + +test sample + select x + case 1 + endcase + endselect +endtest +"; + AssertHasUnreachable(src, "x", "select x"); + } + + [Test] + public void ProgramLocal_NotVisibleInTest_NestedExpressionInCommand() + { + // Even nested inside arithmetic + a function-call arg, the reference + // must be caught. Same root cause (no CommandStatement case) but + // exercises deeper expression walking once that case is added. + var src = @" +local x = 42 +_L1: + +test sample + print add(x + 1, 2) +endtest +"; + AssertHasUnreachable(src, "x", "print add(x + 1, 2)"); + } + + [Test] + public void ProgramStructLocal_FieldAccess_NotVisibleInTest() + { + // A struct local declared at program top-level. The test references + // a field via `p.x`. CheckExpression walks the StructFieldReference + // and finds `p` as a VariableRefNode — same visibility rule applies, + // so referencing it without a runto must flag TestVariableUnreachable. + var src = @" +type pt + x + y +endtype +local p as pt +_L1: + +test sample + print p.x +endtest +"; + AssertHasUnreachable(src, "p", "print p.x"); + } + + [Test] + public void TestLocalStruct_FieldAccess_NoError() + { + // Sanity counterpart: when the struct is declared inside the test, + // field access is fine — `p` is a test-local, so the visibility rule + // doesn't fire. + var src = @" +type pt + x + y +endtype + +test sample + local p as pt + p.x = 5 + print p.x +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestVariableUnreachable.code + || e.errorCode.code == ErrorCodes.TestVariableNotYetDeclared.code), + Is.False, + "expected no strict-test scope errors; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void StructFieldName_CollidesWithTopLevelVar_FalsePositive() + { + // BUG DEMO (currently failing): `p.foo` is a StructFieldReference + // whose `right` side is a VariableRefNode("foo"). CheckExpression + // walks every VariableRefNode in the tree and treats `foo` as if it + // were a variable lookup. When a top-level `local foo` happens to + // share the field's name, the visitor flags the field side as + // TestVariableUnreachable even though it's just a struct member. + // + // The fix is roughly: skip the `right` side of a StructFieldReference + // when walking (or only walk the left chain). Until then, this test + // documents the false positive. + var src = @" +type pt + foo +endtype + +local foo = 7 + +test sample + local p as pt + print p.foo +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestVariableUnreachable.code), + Is.False, + "field name `foo` on test-local `p` must not be treated as a variable lookup; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void ProgramStructLocal_FieldAccess_VisibleAfterRunto() + { + // With a runto past the declaration, the struct local is in scope_at + // and `p.x` should validate cleanly. Proves the runto -> scope_at + // path composes with StructFieldReference walking. + var src = @" +type pt + x + y +endtype +local p as pt +_L1: + +test sample + runto _L1 + print p.x +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestVariableUnreachable.code + || e.errorCode.code == ErrorCodes.TestVariableNotYetDeclared.code), + Is.False, + "expected no strict-test scope errors after runto past declaration; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_AcrossFunctionBoundaries_VisibilityReplacesNotUnions() + { + // Documents how `visible` shifts as a test runtos across function + // boundaries. Today the visitor REPLACES the visible set on each + // runto (no union), so each scope sees only its own snapshot plus + // globals. + // + // Stages: + // 1. runto _main1 visible = scope_at[_main1] = { top1 } + // 2. runto _inA visible = scope_at[_inA] = { a1 } + // (top1 from earlier runto is gone) + // 3. runto _inB visible = scope_at[_inB] = { b1 } + // (a1 gone) + // 4. runto _inA again visible reverts to { a1 } + // (b1 gone) + // + // Each `print X` after a runto is checked against the snapshot at + // that moment; references that aren't visible become + // TestVariableNotYetDeclared (runtoTarget != null after the first + // runto). The expected set of error names captures exactly which + // references the visitor flags. + var src = @" +local top1 = 1 +_main1: +local top2 = 2 +helper_a() +end + +function helper_a() + local a1 = 10 + _inA: + local a2 = 20 + helper_b() +endfunction + +function helper_b() + local b1 = 100 + _inB: + local b2 = 200 +endfunction + +test sample + runto _main1 + print top1 + print top2 + + runto _inA + print a1 + print top1 + print a2 + + runto _inB + print b1 + print a1 + + runto _inA + print a1 + print b1 +endtest +"; + Parse(src, out var errs); + + var visibilityErrs = errs + .Where(e => e.errorCode.code == ErrorCodes.TestVariableNotYetDeclared.code + || e.errorCode.code == ErrorCodes.TestVariableUnreachable.code) + .Select(e => e.message) + .OrderBy(s => s) + .ToList(); + + // Stage 1: top2 (declared after _main1) + // Stage 2: top1 (not in fnA scope), a2 (declared after _inA) + // Stage 3: a1 (not in fnB scope) + // Stage 4: b1 (not in fnA scope after re-entry) + var expected = new[] { "a1", "a2", "b1", "top1", "top2" }; + + Assert.That(visibilityErrs, Is.EquivalentTo(expected), + "expected exactly these visibility errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_IntoFunction_SeesImplicitAssignmentBeforeLabel() + { + // After `runto _L2`, execution is inside `tuna()` and `y = 24` has + // already run, so the test's `print y` should be clean. The base + // scope checker inserts an implicit `local y` declaration when it + // encounters the bare assignment, which means the strict visitor's + // fnState should pick up `y` before it snapshots `_L2`. + var src = @" +local x = 42 +_L1: +helper() + +function helper() + y = 24 + _L2: +endfunction + +test sample + runto _L1 + print x + runto _L2 + print y +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors of any kind; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_IntoFunction_ParamVisibleAfterRunto() + { + // ComputeFunctionInternalScopeAts adds function parameters to fnState + // before walking the body, so scope_at[_inside] includes `p`. + // After `runto _inside`, the test should see `p`. + var src = @" +helper(5) +end + +function helper(p) + _inside: + local q = p + 1 +endfunction + +test sample + runto _inside + print p +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_IntoFunction_ParamNotVisibleWithoutRunto() + { + // Mirror of the previous test: without a runto into helper, the param + // `p` is in allTopLevelNames (so the base check resolves it via our + // parent-fn copy) but not in visible -> TestVariableUnreachable. + var src = @" +helper(5) +end + +function helper(p) +endfunction + +test sample + print p +endtest +"; + AssertHasUnreachable(src, "p", "print p without runto into helper"); + } + + [Test] + public void FunctionInternalLocal_NotVisibleWithoutRunto() + { + // Negative counterpart to Runto_IntoFunction_SeesImplicitAssignmentBeforeLabel. + // The base scope checker now resolves `y` (we copy parent fn locals into + // the test scope), but the strict visitor must still flag it as + // unreachable when no runto reaches the function-internal label. + var src = @" +helper() +end + +function helper() + y = 24 + _L2: +endfunction + +test sample + print y +endtest +"; + AssertHasUnreachable(src, "y", "print y without runto into helper"); + } + + [Test] + public void TwoFunctions_SameLocalName_EachRuntoSeesItsOwn() + { + // fnA and fnB both declare `local result`. After `runto _inA`, the + // visible set is scope_at[_inA] = {result}; same after `runto _inB`. + // Strict visitor validates clean for both. The base checker resolves + // `result` to fnA's symbol (first-source-wins) for both references, + // which is fine for visibility-only purposes. + var src = @" +fnA() +fnB() +end + +function fnA() + local result = 1 + _inA: +endfunction + +function fnB() + local result = 2 + _inB: +endfunction + +test sample + runto _inA + print result + runto _inB + print result +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLocal_ShadowsParentFunctionInternal() + { + // Test body declares `local y = 99`. Parent fn `helper` also has an + // internal `y`. The test's `y` should win — VisitStatement's + // CheckExpression checks testLocals first and short-circuits before + // allTopLevelNames. Reference to `y` in the test must be clean. + var src = @" +helper() +end + +function helper() + y = 24 + _L2: +endfunction + +test sample + local y = 99 + print y +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLocal_ConflictsWithRuntoExposedName_DeclThenRunto() + { + // Declaration first, then runto brings the conflicting name into + // view -> TestRuntoShadowsLocal at the runto. + var src = @" +helper() +end + +function helper() + y = 24 + _L2: +endfunction + +test sample + local y = 99 + runto _L2 +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestRuntoShadowsLocal.code + && e.message == "y"), + Is.True, + "expected TestRuntoShadowsLocal at runto site for `y`; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLocal_ConflictsWithRuntoExposedName_RuntoThenDecl() + { + // Runto brings `y` into view first, then the test declares a local + // of the same name -> TestRuntoShadowsLocal at the declaration. + var src = @" +helper() +end + +function helper() + y = 24 + _L2: +endfunction + +test sample + runto _L2 + local y = 99 +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestRuntoShadowsLocal.code + && e.message == "y"), + Is.True, + "expected TestRuntoShadowsLocal at declaration for `y`; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void BareAssignment_InTest_ImplicitTestLocal_NoError() + { + // `x = 4` at top level introduces `x` as a program top-level local + // (via the base checker's implicit-decl). The test then does its + // own bare `x = 12` with no runto -> should be a fresh implicit + // test-local, same way `x = 12` inside a function body would be + // an implicit function-local. The strict visitor must NOT flag it + // as TestVariableUnreachable. + var src = @" +x = 4 +_L1: +test sample + x = 12 +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "expected no errors; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void TestLocal_ShadowsGlobal_NoConflict() + { + // Globals are always-shadowable: a test-local with the same name as + // a global must not fire TestRuntoShadowsLocal. + var src = @" +global g = 5 + +test sample + local g = 10 +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestRuntoShadowsLocal.code), + Is.False, + "shadowing a global should not flag TestRuntoShadowsLocal; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + [Test] + public void Runto_UnknownLabel_EmitsParseError() + { + // A runto whose target label doesn't exist anywhere in the program + // should be a hard parse error (RuntoUnknownLabel is already defined + // in Errors.cs but currently never emitted). Until that's wired up, + // the visitor silently sets currentRuntoTarget and produces + // confusing "not yet declared" errors for *every* subsequent + // reference in the test. + var src = @" +local x = 1 +_real: + +test sample + runto _does_not_exist +endtest +"; + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.RuntoUnknownLabel.code), + Is.True, + "expected RuntoUnknownLabel for `_does_not_exist`; got: " + + string.Join(" | ", errs.Select(e => e.Display))); + } + + private void AssertHasUnreachable(string src, string varName, string context) + { + Parse(src, out var errs); + Assert.That( + errs.Any(e => e.errorCode.code == ErrorCodes.TestVariableUnreachable.code), + Is.True, + $"expected TestVariableUnreachable for `{varName}` in `{context}`; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } } diff --git a/FadeBasic/Tests/TokenVm.cs b/FadeBasic/Tests/TokenVm.cs index bfce192..6a4d46d 100644 --- a/FadeBasic/Tests/TokenVm.cs +++ b/FadeBasic/Tests/TokenVm.cs @@ -439,7 +439,7 @@ public void Expression_Conditionals_Literal_Ints(string src, int expected) { Setup(src, out _, out var prog); var vm = new VirtualMachine(prog); - vm.Execute().MoveNext(); + vm.Execute3(); Assert.That(vm.dataRegisters[0], Is.EqualTo(expected)); Assert.That(vm.typeRegisters[0], Is.EqualTo(TypeCodes.INT)); } diff --git a/FadeBasic/book/FadeBook/Language.md b/FadeBasic/book/FadeBook/Language.md index 15a88d1..34cba97 100644 --- a/FadeBasic/book/FadeBook/Language.md +++ b/FadeBasic/book/FadeBook/Language.md @@ -934,6 +934,8 @@ IF x > 0 THEN GOTO tuna `GOTO` statements can be used to escape from looping control structures, or indeed from any control statements. However, `GOTO` statements _cannot_ be used to jump the code between [#scopes](#scopes). +---- +#### Labels _Labels_ are defined as any valid variable name, with a `:` symbol immediately following the name. Labels cannot be redeclared. ---- @@ -1361,3 +1363,341 @@ blueFish as TUNA `a second TUNA is allocated redFish = blueFish `the first TUNA is no longer being reference, and is garbage collected. ``` +## Testing + +_Fade Basic_ has a software testing framework built into the language itself. A program may define a series of test blocks using the `TEST` and `ENDTEST` keywords. + +```basic +TEST sample + `this is an example test block called `sample` +ENDTEST +``` + +The code inside a test block is never executed as part of the main program execution. +```basic +print "a" +TEST sample + print "b" +ENDTEST + +`runtime output: +` a +``` + +Instead, test blocks are run as distinct top level programs using the `dotnet test` command. #TODO, insert guide to this. `--logger "console;verbosity=detailed"` +When `dotnet test` is run, all of the test blocks will run in sequence, in the order they are defined in the program. +```basic +TEST tuna + print "a" +ENDTEST +TEST fish + print "b" +ENDTEST + +`test output +` a +` b +``` + +Test blocks have a unique access to the scope of the main program. They can _access_ variables defined in the global [scope](#scopes) of the program, but by default, they do not have access to local scoped variables. However, the test program does not run the main program, so global values are _declared_, but they will not have their actual assigned values. +```basic +GLOBAL x = 42 +TEST sample + print x +ENDTEST + +`test output +` 0 +``` + +---- +#### `RUNTO` + +Test programs are allowed to control the flow of the main program by using the `RUNTO` syntax. The `RUNTO` keyword must be followed by a [label](#labels) name. + +When the test program executes a `RUNTO` statement, the execution will switch from the test program _into_ the main program, and execution will continue until the given label is reached. When the execution reaches the given label, execution returns after the `RUNTO` statement in the test program. +```basic + +print "hello" +_L1: + +TEST sample + print "start" + RUNTO _L1 + print "end" +ENDTEST + +`test output +` start +` hello +` end +``` + +The `RUNTO` statement can take an optional `MAX CYCLES` cluase followed by an integer expression. The value represents the max number of instructions that the _Fade Basic_ virtual machine will allow before causing the test to _fail_. For example, if the main program would never reach the desired label, the `MAX CYCLES` clause can be used to prevent the test from running forever. +```basic +WHILE 1 + `loop forever +ENDWHILE + +print "hello" +_L1: + +TEST sample + print "start" + RUNTO _L1 MAX CYCLES 1000 +ENDTEST + +`test output +` hello +` +``` + +---- +#### Test Scope + +By default, test blocks can only reference global scope from the main program. However, anytime a `RUNTO` statement resumes execution into the test program, the current available scope in the test block is equivilent to the scope from where the main program was paused. +```basic +x = 42 +_L1: + +TEST sample + RUNTO _L1 + print x +ENDTEST + +`test output +` 42 +``` + +In the example, it would be invalid to access `x` before the `RUNTO`, because the main program has not declared `x` yet. +```basic +x = 42 +_L1: + +TEST sample + print x `invalid; x is not defined yet. +ENDTEST +``` + +Variables can come in and out of scope. +```basic +LOCAL x = 42 +_L1: +tuna() + + +FUNCTION tuna() + y = 24 + _L2: +ENDFUNCTION + +TEST sample + RUNTO _L1 + print x + RUNTO _L2 + print y + ` note, x is no longer valid +ENDTEST +``` + +Sadly, it is possible to introduce a variable name collision. +```basic +x = 4 `x is declared as a variable in the main program +_L1: +TEST sample + x = 12 `x is declared as a variable in the test program + RUNTO _L1 `this RUNTO is invalid, because it introduces a conflict on x. +ENDTEST +``` + +State can be mutated from within a test. +``` +x = 1 +_L1: + +print x +_L2: + +TEST sample + RUNTO _L1 + x = 42 `mutate the program state + RUNTO _L2 +ENDTEST + +`test output +` 42 +``` + +Functions are always global in _Fade Basic_, which means a test block may invoke a function defined in the main program. +```basic +FUNCTION add(a, b) +ENDFUNCTION a + b + +TEST sample + sum = add(1,2) +ENDTEST +``` + +However, remember that if a function references variables that have not been set yet, their values will be zero'd. +```basic +GLOBAL x = 100 + +FUNCTION greaterThanX(a) +ENDFUNCTION a > x + +TEST sample + n = greaterThanX(5) `5 is less than 100 + print n +ENDTEST + +`test output +` 1 +``` + +---- +#### Asserts + +A test block can use the `ASSERT` statement to cause a test to pass or fail. An `ASSERT` statement must be followed by an expression that resolves to a boolean. When the expresion resolves to a truthy value, the assert statement is valid. Otherwise, the assert statement is considered _invalid_. The test block will stop executing and be marked as a failure at the first invalid `ASSERT` statement. +```basic +TEST sample + x = 1 + ASSERT x = 1 `this assert passes. + ASSERT x = 0 `this assert fails, and causes the test to fail. +ENDTEST +``` + +The `ASSERT` statement can be used to validate the output of a function. +```basic +FUNCTION add(a, b) +ENDFUNCTION a + b + +TEST sample + ASSERT add(1,2) = 3 +ENDTEST +``` + +The `ASSERT` statement can be used to validate the state of the program in conjunction with the [`RUNTO`](#runto) statement. +```basic +x = 1 + 2 +_L1: + +TEST sample + RUNTO _L1 + ASSERT x = 3 +ENDTEST +``` + +`ASSERT` statements can optionally include a _reason_ phrase after the condition. The _reason_ phrase will be included the output if the assert ever fails. These are useful for adding documentation to failed assertions. +```basic +TEST sample + x = 12 + ASSERT x > 100, "x should be greater than zero" +ENDTEST +``` + +Similar to [`short circuits`](#short-circuiting), if an `ASSERT` does _not_ fail, then the _reason_ phrase is not evaluated. +```basic +FUNCTION message(x) + msg$ = "failed " + str$(x) + print msg$ +ENDFUNCTION msg$ + +TEST sample + ASSERT 1 = 1, message(1) `message(1) is never evaluated, because the assert is valid +ENDTEST +``` + +The `ASSERT` statement may also be used outside of a test block. + +When the assertion happens as part the execution of a test block, any failure will cause the current test block to fail. +```basic +FUNCTION ex(x) + ASSERT x > 0 `the test will fail here + print x +ENDFUNCTION + +TEST sample + ex(0) +ENDTEST +``` + +When the assertion happens as part of the main program, any failure will crash the entire program. +The `ASSERT` statement will halt the entire program if it fails. +```basic +FUNCTION ex(x) + ASSERT x > 0 `the program will crash here + print x +ENDFUNCTION + +ex(0) +``` + + +Any [`DEFERRED`](#defer-statements) statements will execute when an assertion causes a test block to fail. +```basic +DEFER print "a" +_L1: + +TEST sample + DEFER print "b" + RUNTO _L1 + + ASSERT 0 +ENDTEST + +`test output +` a +` b +``` + +However, when an assertion crashes a program without a test block, deferred statements **are not** executed. In this scenario, the `ASSERT` acts as a fatal exception, causing the program to crash. +```basic +DEFER print "a" +ASSERT 0 +` the assert throws a fatal exception, and the deferred statement is not run +``` + +---- +#### Test Macros + +Test blocks can be combined with [`#MACRO`](#compile-time-execution) statements. The following example will result in 3 unique tests. +```basic +FUNCTION evenAndPositive(n) + isEven = n mod 2 = 0 + isPositive = n > 0 + x = 1 and isEven and isPositive +ENDFUNCTION x + +#MACRO + DIM cases(3) + cases(0) = 42 + cases(1) = 888 + cases(2) = 36 + + for n = 0 to 2 + v = cases(n) + #tokenize + TEST sample_[v] + ASSERT evenAndPositive([v]) = 1 + ENDTEST + #endtokenize + next +#ENDMACRO +``` + +---- +#### Mocks + + +---- +#### Child Tests + +---- +#### Testing Edge Cases +- defer statements? +- type definitions? +- + +---- +#### Calling Tests +- quirks with `dotnet test` diff --git a/Rider/fade-basic-rider/README.md b/Rider/fade-basic-rider/README.md index a56e2a5..9f440f2 100644 --- a/Rider/fade-basic-rider/README.md +++ b/Rider/fade-basic-rider/README.md @@ -28,7 +28,7 @@ You need **either** LSP.dll **or** LSP.csproj for the language server to start w From this directory: ```bash -export JAVA_HOME="" +export JAVA_HOME="/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home" ./gradlew runIde ``` diff --git a/Rider/fade-basic-rider/build.gradle.kts b/Rider/fade-basic-rider/build.gradle.kts index 54afc8d..566c4cd 100644 --- a/Rider/fade-basic-rider/build.gradle.kts +++ b/Rider/fade-basic-rider/build.gradle.kts @@ -31,8 +31,14 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +// Toolchain language version is overridable via -PfadeJdkVersion=21 so contributors +// who only have JDK 21 (e.g. Rider's bundled JBR) can compile without installing +// JDK 17 explicitly. The compiled bytecode level (jvmTarget below) stays at JVM 17 +// regardless, keeping the plugin runnable on the IntelliJ Platform's minimum runtime. +val fadeJdkVersion: String = (project.findProperty("fadeJdkVersion") as String?) ?: "17" + kotlin { - jvmToolchain(17) + jvmToolchain(fadeJdkVersion.toInt()) sourceSets.named("main") { kotlin.srcDir(layout.buildDirectory.dir("generated/kotlin/main")) } @@ -74,14 +80,31 @@ val generateFadeDevPaths by tasks.registering { } } +// Match Kotlin's jvmTarget to the toolchain language version so JVM-target +// compatibility validation accepts the build (Kotlin and Java tasks must agree). +// Default is 17 to match the platform; opt-up to 21 with -PfadeJdkVersion=21 when +// JDK 17 isn't installed. +val fadeJvmTarget: JvmTarget = when (fadeJdkVersion) { + "21" -> JvmTarget.JVM_21 + "20" -> JvmTarget.JVM_20 + "19" -> JvmTarget.JVM_19 + "18" -> JvmTarget.JVM_18 + else -> JvmTarget.JVM_17 +} + tasks.withType().configureEach { dependsOn(generateFadeDevPaths) compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(fadeJvmTarget) freeCompilerArgs.add("-Xjvm-default=all") } } +tasks.withType().configureEach { + sourceCompatibility = fadeJdkVersion + targetCompatibility = fadeJdkVersion +} + tasks.test { useJUnitPlatform() } diff --git a/VsCode/basicscript/package.json b/VsCode/basicscript/package.json index 5785bef..12aaf5e 100644 --- a/VsCode/basicscript/package.json +++ b/VsCode/basicscript/package.json @@ -118,6 +118,12 @@ "type": "string", "description": "when provided, the DAP session will overwrite logs to this file. These logs are internal debug logs of the dap program, not the STDOUT from the actual Fade Application", "default": "" + }, + "dotnetCommand": { + "type": "string", + "enum": ["run", "test"], + "description": "Which `dotnet` subcommand the DAP should invoke for the launched program. Use `run` for a normal Fade application, or `test` to attach the debugger to a Fade test session.", + "default": "run" } } }, diff --git a/VsCode/basicscript/src/extension.ts b/VsCode/basicscript/src/extension.ts index 8850495..a156ea2 100644 --- a/VsCode/basicscript/src/extension.ts +++ b/VsCode/basicscript/src/extension.ts @@ -152,7 +152,7 @@ export async function activate(context: vscode.ExtensionContext) { transport: TransportKind.stdio } - // // run directly from src + // run directly from src // config = { // command: '/usr/local/share/dotnet/dotnet', // args: [ @@ -163,6 +163,18 @@ export async function activate(context: vscode.ExtensionContext) { // transport: TransportKind.pipe // } + config = { + command: '/usr/local/share/dotnet/dotnet', + args: [ + 'run', + '--project', + '/Users/chrishanna/Documents/Github/dby/FadeBasic/LSP', + '--', + '--use-log-path' + ], + transport: TransportKind.pipe + } + logMessage('fade LSP config', config) const serverOptions: ServerOptions = { @@ -311,11 +323,15 @@ class FadeBasicDebugger implements DebugAdapterDescriptorFactory var program = _session.configuration.program; var debuggerLogPath = _session.configuration.debuggerLogPath ?? ""; var dapLogPath = _session.configuration.dapLogPath ?? ""; + // "run" launches the program normally; "test" routes through + // `dotnet test` so the DAP attaches to a Fade test host. + var dotnetCommand = _session.configuration.dotnetCommand ?? "run"; var env: any = { "FADE_PROGRAM": program, "FADE_WAIT_FOR_DEBUG": waitForDebugger, - "FADE_DOTNET_PATH": this.dotnetPath + "FADE_DOTNET_PATH": this.dotnetPath, + "FADE_BASIC_DEBUG_DOTNET_COMMAND": dotnetCommand } if (debuggerLogPath){ env["FADE_DEBUGGER_LOG_PATH"] = debuggerLogPath From 9d412bb7f2ab2284bcd7f4db9aa07fb6527b7bb0 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Fri, 15 May 2026 21:04:42 -0400 Subject: [PATCH 05/30] mocks --- .../FadeTestExecutorAdapter.cs | 60 +++- .../FadeBasic.Testing/FadeTestFramework.cs | 10 +- FadeBasic/FadeBasic/Ast/ExpressionNode.cs | 31 ++ FadeBasic/FadeBasic/Ast/ProgramNode.cs | 4 + FadeBasic/FadeBasic/Ast/StatementNode.cs | 75 ++-- .../Ast/Visitors/ScopeErrorVisitor.cs | 146 +++++++- FadeBasic/FadeBasic/Errors.cs | 12 +- FadeBasic/FadeBasic/Lexer.cs | 11 +- FadeBasic/FadeBasic/Lsp/LSPUtil.cs | 4 +- FadeBasic/FadeBasic/Parser.cs | 280 ++++++--------- FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs | 133 ++++++- .../FadeBasic/Sdk/Testing/IFadeTestHost.cs | 3 +- FadeBasic/FadeBasic/Virtual/Compiler.cs | 128 ++++--- FadeBasic/FadeBasic/Virtual/OpCodes.cs | 10 + FadeBasic/FadeBasic/Virtual/VirtualMachine.cs | 121 ++++++- FadeBasic/Tests/AssertMacroTests.cs | 116 +++++- FadeBasic/Tests/FadeTestAdapterTests.cs | 32 +- FadeBasic/Tests/MockExecutionTests.cs | 186 ++++++++-- FadeBasic/Tests/MockParserTests.cs | 330 ++++++++++++------ FadeBasic/Tests/MusicFbasicReproTests.cs | 1 - 20 files changed, 1270 insertions(+), 423 deletions(-) diff --git a/FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs b/FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs index 5a51ae7..d82c0c1 100644 --- a/FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs +++ b/FadeBasic/FadeBasic.TestAdapter/FadeTestExecutorAdapter.cs @@ -232,7 +232,7 @@ private static void RunOne( if (!result.passed) { vsResult.ErrorMessage = BuildErrorMessage(result, sourceFile, entry); - vsResult.ErrorStackTrace = BuildErrorStackTrace(sourceFile, entry); + vsResult.ErrorStackTrace = BuildErrorStackTrace(result, sourceFile, entry); } handle.RecordResult(vsResult); @@ -285,9 +285,10 @@ private static string ResolveSourceFile(TestCase tc, TestManifestEntry entry) /// /// Format the failure message in a Fade-flavored shape. Surfaces the - /// captured assertion source text and the originating .fbasic - /// line so the Test Explorer "failure" pane reads as a Fade error, - /// not a generic .NET exception dump. + /// 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) { @@ -297,7 +298,22 @@ internal static string BuildErrorMessage(FadeTestResult r, string fbasicPath, Te { sb.Append("\n source: ").Append(r.failureSourceText); } - if (entry.sourceLine > 0 && !string.IsNullOrEmpty(fbasicPath)) + // 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)) @@ -308,13 +324,37 @@ internal static string BuildErrorMessage(FadeTestResult r, string fbasicPath, Te } /// - /// Synthesize a single stack-trace frame in the canonical at <name> - /// in <file>:line N format. Both VS Code and Rider parse this - /// regex and turn it into a clickable source link in the failure pane. + /// 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(string fbasicPath, TestManifestEntry entry) + internal static string BuildErrorStackTrace(FadeTestResult r, string fbasicPath, TestManifestEntry entry) { - if (entry.sourceLine <= 0 || string.IsNullOrEmpty(fbasicPath)) return string.Empty; + 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}"; } diff --git a/FadeBasic/FadeBasic.Testing/FadeTestFramework.cs b/FadeBasic/FadeBasic.Testing/FadeTestFramework.cs index 7a0f517..3c3bf1e 100644 --- a/FadeBasic/FadeBasic.Testing/FadeTestFramework.cs +++ b/FadeBasic/FadeBasic.Testing/FadeTestFramework.cs @@ -358,7 +358,15 @@ private static string BuildMessage(FadeTestResult r) { msg += $"\n source: {r.failureSourceText}"; } - if (r.failureInstructionIndex >= 0) + 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}"; } diff --git a/FadeBasic/FadeBasic/Ast/ExpressionNode.cs b/FadeBasic/FadeBasic/Ast/ExpressionNode.cs index c171865..d869804 100644 --- a/FadeBasic/FadeBasic/Ast/ExpressionNode.cs +++ b/FadeBasic/FadeBasic/Ast/ExpressionNode.cs @@ -391,4 +391,35 @@ public override IEnumerable IterateChildNodes() yield break; } } + + /// + /// `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}"; + } + + public override IEnumerable IterateChildNodes() + { + yield break; + } + } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Ast/ProgramNode.cs b/FadeBasic/FadeBasic/Ast/ProgramNode.cs index f1e17b5..c439215 100644 --- a/FadeBasic/FadeBasic/Ast/ProgramNode.cs +++ b/FadeBasic/FadeBasic/Ast/ProgramNode.cs @@ -16,6 +16,10 @@ public ProgramNode(Token start) : base(start) public List functions = 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(); diff --git a/FadeBasic/FadeBasic/Ast/StatementNode.cs b/FadeBasic/FadeBasic/Ast/StatementNode.cs index a553e40..c848fd4 100644 --- a/FadeBasic/FadeBasic/Ast/StatementNode.cs +++ b/FadeBasic/FadeBasic/Ast/StatementNode.cs @@ -171,56 +171,51 @@ public override IEnumerable IterateChildNodes() } } - public enum MockEntryKind + /// + /// `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 MockReturnsStatement : AstNode, IStatementNode { - Returns, - Forbid, - // Bare-form mock (`mock ` with no body). Used for void - // commands the test wants to suppress, like `mock wait ms` to skip - // the actual sleep during test execution. Args are still consumed - // from the stack at dispatch time. - Void - } + public IExpressionNode expression; - public enum MockFrequencyKind - { - Always, // default — applies to every call until exhausted (forbid: every call) - Once, // applies to one call, then the entry is consumed - NTimes // applies to N calls, then the entry is consumed (N from countExpression) + public MockReturnsStatement(Token startToken, Token endToken) : base(startToken, endToken) + { + } + + protected override string GetString() + { + return $"returns {expression}"; + } + + public override IEnumerable IterateChildNodes() + { + if (expression != null) yield return expression; + } } - public class MockEntry : AstNode + /// + /// `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 MockEntryKind kind; - public MockFrequencyKind frequency = MockFrequencyKind.Always; - // Only set when kind == Returns. The expression evaluated to produce - // the mocked return value at dispatch time. - public IExpressionNode returnExpression; - // Only set when frequency == NTimes. Evaluated once when the mock is - // installed; the result is the count of remaining calls for this entry. - public IExpressionNode countExpression; + public IExpressionNode reason; // null when no reason was supplied - public MockEntry(Token startToken, Token endToken) : base(startToken, endToken) + public MockForbidStatement(Token startToken, Token endToken) : base(startToken, endToken) { } protected override string GetString() { - var freqStr = frequency switch - { - MockFrequencyKind.Once => " once", - MockFrequencyKind.NTimes => $" {countExpression} times", - _ => "" // always is the default, omit for brevity - }; - return kind == MockEntryKind.Forbid - ? $"forbid{freqStr}" - : $"returns {returnExpression}{freqStr}"; + return reason != null ? $"forbid {reason}" : "forbid"; } public override IEnumerable IterateChildNodes() { - if (returnExpression != null) yield return returnExpression; - if (countExpression != null) yield return countExpression; + if (reason != null) yield return reason; } } @@ -231,7 +226,11 @@ public class MockStatement : AstNode, IStatementNode // CommandNameTree pass). public string commandName; public Token commandNameToken; - public List entries = new List(); + // Body of the mock block. In Phase A this holds MockReturnsStatement + // and MockForbidStatement only; Phase B will broaden it to any + // statement so the body acts as a mini-function the VM dispatches. + // An empty body means "void mock" — calls are suppressed. + public List body = new List(); public MockStatement(Token startToken, Token endToken) : base(startToken, endToken) { @@ -239,12 +238,12 @@ public MockStatement(Token startToken, Token endToken) : base(startToken, endTok protected override string GetString() { - return $"mock {commandName} ({string.Join(",", entries.Select(e => e.ToString()))})"; + return $"mock {commandName} ({string.Join(",", body.Select(s => s.ToString()))})"; } public override IEnumerable IterateChildNodes() { - foreach (var entry in entries) yield return entry; + foreach (var stmt in body) yield return stmt; } } diff --git a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs index da5cbdc..abfe5e1 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs @@ -20,6 +20,11 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions var scope = program.scope = new Scope(); + // Plumb the program's CommandCollection onto the scope so visitors + // can look up command metadata (return type, args) without taking + // it as a parameter. Test sub-programs inherit from the parent. + scope.commands = program.commands ?? parentProgram?.commands; + // Region name used to tag this program's top-level labels and to seed // GetCurrentFunctionName() so EnsureLabel can detect cross-scope gotos // between a test and its parent. Null for the outermost program (matches @@ -675,33 +680,136 @@ static void CheckStatements(this List statements, Scope scope, E } // mock and clear-mock validation. Command-existence is enforced by the - // lexer's CommandNameTree pass (an unknown command name doesn't tokenize - // as CommandWord, so the parser already errors). Here we walk the entry - // expressions to catch general scope errors (unknown variable refs in - // `returns` expressions, etc.) and emit unreachable-entry warnings. + // lexer's CommandNameTree pass (an unknown command name doesn't + // tokenize as CommandWord, so the parser already errors). Here we: + // - Walk body expressions for unknown-symbol errors. + // - Enforce body structure: at most one `returns`, at most one + // `forbid`, never both. + // - Type-check the `forbid` reason expression (string). + // - Validate the `returns` expression against the command's + // declared return type. Multi-overload commands must accept the + // same expression for every overload, so we intersect: if any + // overload would reject the expression, error. static void ValidateMockStatement(MockStatement mock, Scope scope, EnsureTypeContext ctx) { - var sawAlways = false; - for (var i = 0; i < mock.entries.Count; i++) + MockReturnsStatement seenReturns = null; + MockForbidStatement seenForbid = null; + + foreach (var stmt in mock.body) { - var entry = mock.entries[i]; - if (sawAlways) + switch (stmt) { - entry.Errors.Add(new ParseError(entry.StartToken, ErrorCodes.MockUnreachableEntry)); - } - if (entry.frequency == MockFrequencyKind.Always) sawAlways = true; + case MockReturnsStatement rs: + if (seenReturns != null) + { + rs.Errors.Add(new ParseError(rs.StartToken, ErrorCodes.MockMultipleReturns)); + } + seenReturns = rs; + if (rs.expression != null) + { + rs.expression.EnsureVariablesAreDefined(scope, ctx); + } + break; - if (entry.returnExpression != null) - { - entry.returnExpression.EnsureVariablesAreDefined(scope, ctx); + case MockForbidStatement fs: + if (seenForbid != null) + { + fs.Errors.Add(new ParseError(fs.StartToken, ErrorCodes.MockMultipleForbid)); + } + seenForbid = fs; + if (fs.reason != null) + { + fs.reason.EnsureVariablesAreDefined(scope, ctx); + if (fs.reason.ParsedType.type != VariableType.String + && !fs.reason.ParsedType.unset) + { + fs.Errors.Add(new ParseError(fs.reason, ErrorCodes.MockForbidReasonMustBeString)); + } + } + break; } - if (entry.countExpression != null) + } + + if (seenReturns != null && seenForbid != null) + { + // `returns` + `forbid` in the same body is nonsensical — the + // forbid prevents the return path from being reached. + seenForbid.Errors.Add(new ParseError(seenForbid.StartToken, ErrorCodes.MockReturnsAndForbid)); + } + + // Look up the command in the scope's CommandCollection to validate + // `returns` against the command's declared return type. We need + // ALL overloads — a mock applies to every overload of the same + // name, so the returns expression must satisfy every one of them. + // + // Type compatibility uses EnforceTypeAssignment so the same numeric + // coercion rules that apply to `local n as long = 5` apply here: + // an int literal is fine as a `returns` value for a long-returning + // command, etc. Anything else surfaces an InvalidCast/InvalidType + // error on the expression — we then translate the first such error + // into a clearer MockReturnsTypeMismatch and stop. + if (scope.commands != null && mock.commandName != null && seenReturns != null) + { + if (scope.commands.Lookup.TryGetValue(mock.commandName, out var overloads) + && overloads.Count > 0) { - entry.countExpression.EnsureVariablesAreDefined(scope, ctx); + var reportedTypeMismatch = false; + foreach (var overload in overloads) + { + var isVoid = overload.returnType == TypeCodes.VOID; + + if (isVoid) + { + // `returns` against any void overload is illegal. + // One error per mock is enough — break out. + seenReturns.Errors.Add(new ParseError(seenReturns.StartToken, + ErrorCodes.MockReturnsOnVoidCommand)); + break; + } + + if (seenReturns.expression == null + || seenReturns.expression.ParsedType.unset) continue; + + if (!TypeInfo.TryGetFromTypeCode(overload.returnType, out var expectedType)) + { + continue; + } + + // Probe assignability without committing errors to + // the real expression node — call into the same path + // the declaration init uses, on a throwaway node. If + // it flags any errors, the types are incompatible. + var probe = new ProbeNode(); + scope.EnforceTypeAssignment(probe, + seenReturns.expression.ParsedType, expectedType, + softLeft: false, out _); + if (probe.Errors.Count > 0 && !reportedTypeMismatch) + { + seenReturns.expression.Errors.Add(new ParseError( + seenReturns.expression, + ErrorCodes.MockReturnsTypeMismatch)); + reportedTypeMismatch = true; + break; + } + } } } } + // Throwaway IAstNode used to capture errors from EnforceTypeAssignment + // without polluting a real source node. EnforceTypeAssignment adds + // ParseErrors to whatever node is passed in; we want to test assignment + // legality without committing those errors to the user's expression. + sealed class ProbeNode : IAstNode + { + public List Errors { get; } = new List(); + public Token StartToken => null; + public Token EndToken => null; + public TypeInfo ParsedType => TypeInfo.Unset; + public TransitiveTypeFlags TransitiveFlags { get; set; } + public Symbol DeclaredFromSymbol { get; set; } + } + static void ValidateClearMockStatement(ClearMockStatement clear) { // Nothing to validate at the scope level — the parser already @@ -1104,6 +1212,12 @@ public static void EnsureVariablesAreDefined(this IExpressionNode expr, Scope sc case LiteralRealExpression literalReal: literalReal.ParsedType = TypeInfo.Real; break; + case CallCountExpression callCountExpr: + // `call count ` always evaluates to an int. The + // command name was already validated by the lexer's + // CommandNameTree pass; nothing further to do. + callCountExpr.ParsedType = TypeInfo.Int; + break; default: break; } diff --git a/FadeBasic/FadeBasic/Errors.cs b/FadeBasic/FadeBasic/Errors.cs index ebf10a1..573356d 100644 --- a/FadeBasic/FadeBasic/Errors.cs +++ b/FadeBasic/FadeBasic/Errors.cs @@ -229,9 +229,7 @@ public static class ErrorCodes public static readonly ErrorCode MockMissingCommandName = "[0180] mock requires a command name"; public static readonly ErrorCode MockUnknownCommand = "[0181] mock target is not a known command"; public static readonly ErrorCode MockMissingEndMock = "[0182] mock block missing endmock clause"; - public static readonly ErrorCode MockEntryRequiresReturnsOrForbid = "[0183] mock entry must be `returns ` or `forbid`"; - public static readonly ErrorCode MockUnreachableEntry = "[0184] mock entry is unreachable; a previous entry has frequency `always`"; - public static readonly ErrorCode MockNTimesRequiresCount = "[0185] mock frequency `times` requires an integer count expression"; + public static readonly ErrorCode MockEntryRequiresReturnsOrForbid = "[0183] mock body statement must be `returns ` or `forbid`"; public static readonly ErrorCode MockOutsideTest = "[0186] mock can only be used inside a test block"; public static readonly ErrorCode ClearMockMissingTarget = "[0187] clear must be followed by `mock ` or `mocks`"; public static readonly ErrorCode ClearMockOutsideTest = "[0188] clear mock(s) can only be used inside a test block"; @@ -239,6 +237,14 @@ public static class ErrorCodes public static readonly ErrorCode TestRuntoShadowsLocal = "[0190] runto brings a program-scope variable into view that conflicts with a test-local of the same name"; public static readonly ErrorCode AssertReasonMissingExpression = "[0191] assert reason clause (after `,`) requires a string expression"; public static readonly ErrorCode AssertReasonMustBeString = "[0192] assert reason expression must be a string"; + public static readonly ErrorCode MockReturnsMissingExpression = "[0193] `returns` in a mock body requires an expression"; + public static readonly ErrorCode MockForbidReasonMustBeString = "[0194] forbid reason expression must be a string"; + public static readonly ErrorCode MockReturnsOnVoidCommand = "[0195] `returns` in a mock body is not allowed when the command has no return value"; + public static readonly ErrorCode MockReturnsTypeMismatch = "[0196] mock `returns` expression type does not match the command's return type"; + public static readonly ErrorCode MockMultipleReturns = "[0197] mock body has multiple `returns` statements; only one is allowed"; + public static readonly ErrorCode MockMultipleForbid = "[0198] mock body has multiple `forbid` statements; only one is allowed"; + public static readonly ErrorCode MockReturnsAndForbid = "[0199] mock body cannot mix `returns` and `forbid`"; + public static readonly ErrorCode CallCountMissingCommand = "[0251] `call count` must be followed by a command name"; // 200 series represents post-parse issues public static readonly ErrorCode InvalidReference = "[0200] Invalid reference"; diff --git a/FadeBasic/FadeBasic/Lexer.cs b/FadeBasic/FadeBasic/Lexer.cs index a5849a6..64b5e6a 100644 --- a/FadeBasic/FadeBasic/Lexer.cs +++ b/FadeBasic/FadeBasic/Lexer.cs @@ -78,11 +78,9 @@ public enum LexemType KeywordEndMock, KeywordReturns, KeywordForbid, - KeywordOnce, - KeywordTimes, - KeywordAlways, KeywordClear, KeywordMocks, + KeywordCallCount, KeywordAs, KeywordTypeInteger, @@ -266,10 +264,11 @@ public class Lexer new Lexem(LexemType.KeywordMock, new Regex("^mock\\b")), new Lexem(LexemType.KeywordReturns, new Regex("^returns\\b")), new Lexem(LexemType.KeywordForbid, new Regex("^forbid\\b")), - new Lexem(LexemType.KeywordOnce, new Regex("^once\\b")), - new Lexem(LexemType.KeywordTimes, new Regex("^times\\b")), - new Lexem(LexemType.KeywordAlways, new Regex("^always\\b")), new Lexem(LexemType.KeywordClear, new Regex("^clear\\b")), + // Multi-word keyword: `call count`. Higher priority (-2) so it + // matches before VariableGeneral; users who write `call` alone + // (or `call somethingElse`) still get a VariableGeneral token. + new Lexem(-2, LexemType.KeywordCallCount, new Regex("^call[ \\t]+count\\b")), new Lexem(LexemType.KeywordGoto, new Regex("^goto")), new Lexem(LexemType.KeywordGoSub, new Regex("^gosub")), diff --git a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs index 5facd6b..bb6841a 100644 --- a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs +++ b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs @@ -122,10 +122,8 @@ static PortableSemanticTokenType ClassifyLexemType(Token token) case LexemType.KeywordMocks: case LexemType.KeywordReturns: case LexemType.KeywordForbid: - case LexemType.KeywordOnce: - case LexemType.KeywordTimes: - case LexemType.KeywordAlways: case LexemType.KeywordClear: + case LexemType.KeywordCallCount: case LexemType.KeywordMaxCycles: return PortableSemanticTokenType.Keyword; diff --git a/FadeBasic/FadeBasic/Parser.cs b/FadeBasic/FadeBasic/Parser.cs index 288b1de..c81faca 100644 --- a/FadeBasic/FadeBasic/Parser.cs +++ b/FadeBasic/FadeBasic/Parser.cs @@ -86,6 +86,12 @@ public class Scope public bool IsInsideTest { get; set; } + // Reference to the program's CommandCollection, plumbed in by + // AddScopeRelatedErrors so visitors like the mock-body type-check + // can look up command metadata without re-threading commands + // through every signature. + public CommandCollection commands; + // Names that were preloaded from the parent program into a test scope // (top-level locals + function-internal locals/params, see // ScopeErrorVisitor.AddScopeRelatedErrors). They're in the local table @@ -963,6 +969,7 @@ public ProgramNode ParseProgram(ParseOptions options = null) if (options == null) options = ParseOptions.Default; var program = new ProgramNode(_stream.Current); + program.commands = _commands; program.Errors.AddRange(_stream.Errors); while (!_stream.IsEof) { @@ -3394,16 +3401,20 @@ private void ParseRuntoClause(RuntoStatement statement, ref Token endToken) private MockStatement ParseMock(Token mockToken) { - // Forms: - // inline: `mock returns []` - // `mock forbid []` - // inline-stack: `mock returns 1 once: returns 2 once` - // (multiple entries on the same line, no endmock) - // block: `mock \n returns \n ... \n endmock` - // bare: `mock ` alone — void intercept + // Block-only form: + // mock + // returns ' optional, sets return value + // forbid [] ' optional, fails the test if called + // endmock // - // The lexer's command-name pass merges multi-word command names into a - // single CommandWord token, so we can grab the next token as the name. + // An empty body (`mock cmd\nendmock`) installs a void mock — the + // real command is suppressed. There is no inline / stacked / bare + // form: every mock requires its `endmock`. Frequency clauses + // (`once`, `times`, `always`) are gone — a mock stays installed + // until `clear mock ` (or `clear mocks`) removes it. + // + // The lexer's command-name pass merges multi-word command names + // into a single CommandWord token, so the name is one token. var stmt = new MockStatement(mockToken, mockToken); var nameToken = _stream.Peek; @@ -3420,88 +3431,13 @@ private MockStatement ParseMock(Token mockToken) return stmt; } - var hadEnd = _stream.Peek.type == LexemType.EndStatement; - - // Phase 1: parse inline entries on the same line. After the first - // entry, additional `returns`/`forbid` clauses can stack via the - // colon separator (DEFER-style) without requiring `endmock`. The - // distinction between `:` and a real newline is preserved in - // `Token.raw` (newline-synthesized EndStatements have raw != ":"). - if (!hadEnd && IsMockEntryStart(_stream.Peek.type)) - { - while (true) - { - if (!IsMockEntryStart(_stream.Peek.type)) break; - var entry = ParseMockEntry(); - if (entry != null) - { - stmt.entries.Add(entry); - stmt.endToken = entry.EndToken; - } - - // Consume colon-separators between entries, but stop at a - // newline (which switches to block form). - while (IsColonEndStatement(_stream.Peek)) _stream.Advance(); - } - - // After same-line entries, check whether more entries (or `endmock`) - // appear past a newline — that promotes this into block form. - var afterInline = PeekPastEndStatements(); - if (afterInline.type != LexemType.KeywordEndMock - && !IsMockEntryStart(afterInline.type)) - { - return stmt; - } - // fall through to block-form loop - while (_stream.Peek.type == LexemType.EndStatement) _stream.Advance(); - ParseMockBlockBody(stmt, mockToken); - return stmt; - } - - // Bare-form: after the command name + newline, the next non-EndStatement - // token isn't a recognized entry start or `endmock`. Synthesize a - // single Void entry and return without consuming further tokens. - var lookAhead = PeekPastEndStatements(); - if (hadEnd - && !IsMockEntryStart(lookAhead.type) - && lookAhead.type != LexemType.KeywordEndMock) - { - var voidEntry = new MockEntry(nameToken, nameToken) - { - kind = MockEntryKind.Void, - frequency = MockFrequencyKind.Always - }; - stmt.entries.Add(voidEntry); - return stmt; - } - - // Block form: consume EndStatements before entries. + // Drain end-of-statement separators between the name and the body. while (_stream.Peek.type == LexemType.EndStatement) _stream.Advance(); - ParseMockBlockBody(stmt, mockToken); - return stmt; - } - private static bool IsMockEntryStart(LexemType type) - { - return type == LexemType.KeywordReturns || type == LexemType.KeywordForbid; - } - - // True when the token is a colon-induced EndStatement (same line) rather - // than a newline-induced one. The lexer synthesizes newline EndStatements - // with empty/null `raw`; colon ones carry `raw = ":"`. - private static bool IsColonEndStatement(Token token) - { - return token != null - && token.type == LexemType.EndStatement - && token.raw == ":"; - } - - private void ParseMockBlockBody(MockStatement stmt, Token mockToken) - { - // Block-form mock body: zero or more entries terminated by `endmock`. - // `endtest`, `test`, `abstract`, and EOF are hard boundaries that - // emit `MockMissingEndMock` without consuming the boundary token — - // this lets the surrounding test-body parser still see its `endtest`. + // Body: `endtest`, `test`, `abstract`, and EOF are hard boundaries + // that emit MockMissingEndMock without consuming the boundary + // token — the surrounding test-body parser still sees its + // `endtest`. var looking = true; while (looking) { @@ -3527,111 +3463,84 @@ private void ParseMockBlockBody(MockStatement stmt, Token mockToken) break; case LexemType.KeywordReturns: + { + var head = _stream.Advance(); + var rs = new MockReturnsStatement(head, head); + if (TryParseExpression(out var expr)) + { + rs.expression = expr; + rs.endToken = expr.EndToken; + } + else + { + rs.Errors.Add(new ParseError(head, ErrorCodes.MockReturnsMissingExpression)); + } + stmt.body.Add(rs); + break; + } + case LexemType.KeywordForbid: { - var entry = ParseMockEntry(); - if (entry != null) stmt.entries.Add(entry); + var head = _stream.Advance(); + var fs = new MockForbidStatement(head, head); + // Optional reason expression (string-typed). Same + // shape as `assert , "reason"`. We attempt + // to parse it; if the next token isn't expression- + // shaped, fall through with no reason. + if (!IsMockBodyTerminator(_stream.Peek.type)) + { + var saved = _stream.Save(); + if (TryParseExpression(out var reasonExpr)) + { + fs.reason = reasonExpr; + fs.endToken = reasonExpr.EndToken; + } + else + { + _stream.Restore(saved); + } + } + stmt.body.Add(fs); break; } default: { - // Unknown token in mock body — flag and skip to recover. + // Unknown token in mock body — flag and skip one + // token to recover. Phase B will broaden what's + // accepted here (arbitrary test-block statements). var bad = _stream.Advance(); stmt.Errors.Add(new ParseError(bad, ErrorCodes.MockEntryRequiresReturnsOrForbid)); break; } } } + + return stmt; } - private MockEntry ParseMockEntry() + // True for tokens that can't start a `forbid` reason expression — + // used to decide whether to try parsing one or fall through. + private static bool IsMockBodyTerminator(LexemType type) { - var head = _stream.Peek; - if (head.type == LexemType.KeywordReturns) - { - _stream.Advance(); - var entry = new MockEntry(head, head); - entry.kind = MockEntryKind.Returns; - - if (TryParseExpression(out var returnExpr)) - { - entry.returnExpression = returnExpr; - entry.endToken = returnExpr.EndToken; - } - else - { - entry.Errors.Add(new ParseError(head, ErrorCodes.MockEntryRequiresReturnsOrForbid)); - return entry; - } - - ParseMockFrequency(entry); - return entry; - } - if (head.type == LexemType.KeywordForbid) - { - _stream.Advance(); - var entry = new MockEntry(head, head); - entry.kind = MockEntryKind.Forbid; - ParseMockFrequency(entry); - return entry; - } - - // Caller invariants should prevent this, but be defensive. - var bad = _stream.Advance(); - var entryErr = new MockEntry(bad, bad); - entryErr.Errors.Add(new ParseError(bad, ErrorCodes.MockEntryRequiresReturnsOrForbid)); - return entryErr; + return type == LexemType.EndStatement + || type == LexemType.KeywordEndMock + || type == LexemType.KeywordReturns + || type == LexemType.KeywordForbid + || type == LexemType.EOF + || type == LexemType.KeywordEndTest + || type == LexemType.KeywordTest; } - // Optional trailing frequency words: `once`, ` times`, `always`. - // Default frequency (no clause) is `always`. - private void ParseMockFrequency(MockEntry entry) + // True when the token is a colon-induced EndStatement (same line) + // rather than a newline-induced one. The lexer synthesizes newline + // EndStatements with empty/null `raw`; colon ones carry `raw = ":"`. + // Used by ParseRunto for its inline-clause form. + private static bool IsColonEndStatement(Token token) { - var next = _stream.Peek; - switch (next.type) - { - case LexemType.KeywordOnce: - _stream.Advance(); - entry.frequency = MockFrequencyKind.Once; - entry.endToken = next; - break; - - case LexemType.KeywordAlways: - _stream.Advance(); - entry.frequency = MockFrequencyKind.Always; - entry.endToken = next; - break; - - case LexemType.KeywordTimes: - // `times` here means the count expression came BEFORE — but - // we already consumed `returns ` as the return value, - // so this is malformed. Flag and consume. - _stream.Advance(); - entry.Errors.Add(new ParseError(next, ErrorCodes.MockNTimesRequiresCount)); - break; - - default: - { - // Try to parse ` times`. If the count expression - // succeeds and is followed by `times`, accept it as the - // frequency. Otherwise restore — there is no frequency - // clause, leave it default `always`. - var saved = _stream.Save(); - if (TryParseExpression(out var countExpr) && _stream.Peek.type == LexemType.KeywordTimes) - { - var timesToken = _stream.Advance(); // consume `times` - entry.frequency = MockFrequencyKind.NTimes; - entry.countExpression = countExpr; - entry.endToken = timesToken; - } - else - { - _stream.Restore(saved); - } - break; - } - } + return token != null + && token.type == LexemType.EndStatement + && token.raw == ":"; } private ClearMockStatement ParseClearMock(Token clearToken) @@ -4089,6 +3998,27 @@ private bool TryParseWikiTerm(out IExpressionNode outputExpression, out ProgramR recovery = null; switch (token.type) { + case LexemType.KeywordCallCount: + { + // `call count ` is a single keyword followed by + // a command name. The lexer matches `call[ \t]+count` as + // one token — so `call` alone (e.g., a user variable + // named `call`) stays a VariableGeneral. + var ccTok = _stream.Advance(); + var cmdTok = _stream.Peek; + if (cmdTok.type != LexemType.CommandWord) + { + var bad = new CallCountExpression(ccTok, ccTok, null); + bad.Errors.Add(new ParseError(ccTok, ErrorCodes.CallCountMissingCommand)); + outputExpression = bad; + break; + } + _stream.Advance(); // consume command name + var ccExpr = new CallCountExpression(ccTok, cmdTok, cmdTok); + ccExpr.ParsedType = TypeInfo.Int; + outputExpression = ccExpr; + break; + } case LexemType.ConstantBracketOpen: var open = _stream.Advance(); outputExpression = ParseSubstitution(open, withinTokenization); diff --git a/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs b/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs index 93ba4e7..c057284 100644 --- a/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs +++ b/FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs @@ -16,6 +16,19 @@ public static FadeTestResult RunTest( byte[] bytecode, HostMethodTable hostMethods, TestManifestEntry entry) + { + return RunTest(bytecode, hostMethods, entry, debugData: null); + } + + // DebugData-aware overload: when supplied, the failure result includes + // source-located stack frames built from the VM's methodStack snapshot + // at the moment of failure. Call this overload from any caller that + // has the program's DebugData (e.g., ILaunchable.DebugData). + public static FadeTestResult RunTest( + byte[] bytecode, + HostMethodTable hostMethods, + TestManifestEntry entry, + DebugData debugData) { if (entry.isAbstract) { @@ -39,6 +52,23 @@ public static FadeTestResult RunTest( { vm.Execute3(0); // infinite budget! } + catch (VirtualRuntimeException rex) + { + // The VM threw a structured runtime error. Resolve the + // call-stack snapshot it carries into source-located frames + // when DebugData is available, so the failure pane shows + // where the crash actually happened (not just "VM threw"). + sw.Stop(); + return new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "VM threw: " + rex.Message, + failureInstructionIndex = rex.Error.insIndex, + failureFrames = BuildFrames(rex.Error.insIndex, rex.Error.callStack, debugData), + duration = sw.Elapsed + }; + } catch (Exception ex) { sw.Stop(); @@ -67,6 +97,10 @@ public static FadeTestResult RunTest( failureSourceText = vm.assertionFailure.sourceText, failureReason = reason, failureInstructionIndex = vm.assertionFailure.instructionIndex, + failureFrames = BuildFrames( + vm.assertionFailure.instructionIndex, + vm.assertionFailure.callStack, + debugData), duration = sw.Elapsed }; } @@ -78,6 +112,95 @@ public static FadeTestResult RunTest( duration = sw.Elapsed }; } + + // Resolve a VM call-stack snapshot into source-located frames using + // DebugData. Returns an empty list when DebugData is null (best-effort: + // callers fall back to entry.sourceLine in that case). + // + // Generic over the error source: both TestFailure (assert in test mode) + // and VirtualRuntimeError (any runtime crash) carry the same shape + // (instructionIndex + callStack), so this helper takes the two raw + // pieces rather than either struct. + // + // Walk strategy mirrors DebugSession.GetFrames2: + // 1. The "innermost" frame's source location is the IP at failure. + // 2. For each entry in methodStack (top-down), the function name + // comes from insToFunction[toIns], and the NEXT frame's source + // location comes from the call site (fromIns - 1). + public static List BuildFrames( + int instructionIndex, + JumpHistoryData[] callStack, + DebugData debugData) + { + var frames = new List(); + if (debugData == null) return frames; + callStack = callStack ?? System.Array.Empty(); + + var indexMap = new IndexCollection(debugData.statementTokens); + + // Start with the failure site itself. + if (!indexMap.TryFindClosestTokenBeforeIndex(instructionIndex, out var currentToken)) + { + return frames; + } + + // Walk the snapshotted methodStack. callStack[0] is innermost. + for (var i = 0; i < callStack.Length; i++) + { + var frame = callStack[i]; + var functionName = ""; + if (debugData.insToFunction.TryGetValue(frame.toIns, out var fnToken)) + { + functionName = fnToken.token?.raw ?? functionName; + } + frames.Add(new FadeStackFrame + { + functionName = functionName, + lineNumber = currentToken.token.lineNumber, + charNumber = currentToken.token.charNumber, + instructionIndex = instructionIndex + }); + // Resolve the next frame's location to the call site of this + // frame (fromIns - 1, matching DebugSession.GetFrames2). + if (!indexMap.TryFindClosestTokenBeforeIndex(frame.fromIns - 1, out currentToken)) + { + return frames; + } + } + + // Outermost frame: code that wasn't inside any function call — + // either the test body itself or main-program code reached via + // runto. Function name is left empty; consumers can substitute + // their own label (e.g., the test name). + frames.Add(new FadeStackFrame + { + functionName = string.Empty, + lineNumber = currentToken.token.lineNumber, + charNumber = currentToken.token.charNumber, + instructionIndex = callStack.Length > 0 + ? callStack[callStack.Length - 1].fromIns - 1 + : instructionIndex + }); + return frames; + } + } + + /// + /// A single source-located frame in an assertion-failure stack trace. + /// Built from the VM's methodStack snapshot + DebugData by the test runner. + /// + public class FadeStackFrame + { + // Name of the function the frame is inside, or "" for the outermost + // (test body / main-program) frame. + public string functionName; + // Source line in the same coordinate space the rest of the compiler + // uses (0-based, as emitted by the lexer). Consumers that need to + // display 1-based line numbers should add 1. Source-map resolution + // for multi-file projects happens upstream of the runner. + public int lineNumber; + public int charNumber; + public int instructionIndex; } public class FadeTestResult @@ -94,6 +217,10 @@ public class FadeTestResult // IP at the moment of failure; useful for source-mapping when DebugData // is available. -1 if not applicable. public int failureInstructionIndex = -1; + // Source-located stack frames at the moment of failure (innermost first, + // outermost last). Empty when DebugData wasn't available at run time; + // callers should fall back to entry.sourceLine in that case. + public List failureFrames = new List(); public TimeSpan duration; } @@ -148,7 +275,11 @@ public FadeTestResult RunTest(string testName) public FadeTestResult RunTest(TestManifestEntry entry) { - return FadeTestExecutor.RunTest(Machine.program, Compiler.methodTable, entry); + return FadeTestExecutor.RunTest( + Machine.program, + Compiler.methodTable, + entry, + Compiler.DebugData); } public FadeTestRunResult RunAllTests() diff --git a/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs b/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs index 833cdf9..43bbc0a 100644 --- a/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs +++ b/FadeBasic/FadeBasic/Sdk/Testing/IFadeTestHost.cs @@ -90,7 +90,8 @@ public Task RunDefaultAsync(CancellationToken ct) // cooperative cancellation lands inside the VM, this becomes an // actual async call. ct.ThrowIfCancellationRequested(); - var result = FadeTestExecutor.RunTest(Launchable.Bytecode, HostMethods, Entry); + var result = FadeTestExecutor.RunTest( + Launchable.Bytecode, HostMethods, Entry, Launchable.DebugData); return Task.FromResult(result); } } diff --git a/FadeBasic/FadeBasic/Virtual/Compiler.cs b/FadeBasic/FadeBasic/Virtual/Compiler.cs index 1dcf513..3f37926 100644 --- a/FadeBasic/FadeBasic/Virtual/Compiler.cs +++ b/FadeBasic/FadeBasic/Virtual/Compiler.cs @@ -1929,6 +1929,18 @@ private void PatchAddress(int placeholderIndex, int value) } } + // Emit CALL_COUNT with an inline 4-byte command id, pushing that + // command's invocation count onto the data stack. + private void EmitCallCountInline(int commandId) + { + _buffer.Add(OpCodes.CALL_COUNT); + var bytes = BitConverter.GetBytes(commandId); + for (var i = 0; i < bytes.Length; i++) + { + _buffer.Add(bytes[i]); + } + } + private void Compile(ReturnStatement _) { _buffer.Add(OpCodes.RETURN); @@ -1962,52 +1974,63 @@ private void Compile(MockStatement mockStatement) return; } - // For each entry in source order, emit one install opcode per - // overload. The VM keys mocks by host method id, so each overload - // gets its own registration. - foreach (var entry in mockStatement.entries) + // Inspect the body: a mock has at most one `returns` and at most + // one `forbid` (the scope visitor enforces this). Pick the install + // opcode based on what's present: + // - forbid → MOCK_FORBID + // - returns → MOCK_RETURNS + // - empty body → MOCK_VOID (suppress the call) + // Phase B will replace this with full mock-body compilation. + MockReturnsStatement returnsStmt = null; + MockForbidStatement forbidStmt = null; + foreach (var stmt in mockStatement.body) + { + if (stmt is MockReturnsStatement rs && returnsStmt == null) returnsStmt = rs; + else if (stmt is MockForbidStatement fs && forbidStmt == null) forbidStmt = fs; + } + + foreach (var commandId in commandIds) { - foreach (var commandId in commandIds) + if (forbidStmt != null) { - var commandReturnType = methodTable.methods[commandId].returnType; - var isVoidCommand = commandReturnType == TypeCodes.VOID; - - // `returns ` on a void command is silently degraded - // to a Void mock. The caller doesn't read a return value, - // so pushing one would just leak onto the stack. Users - // sometimes write `mock wait ms returns 0` thinking they - // need a body — make that DWIM. - var effectiveKind = entry.kind; - if (effectiveKind == MockEntryKind.Returns && isVoidCommand) + // Stack at MOCK_FORBID dispatch (bottom→top): + // reason (string), trampolineAddr (int), commandId (int) + // The reason is an empty literal when the user didn't + // supply one. The trampoline address is patched after the + // assert-unwind trampoline is emitted (same patch list). + if (forbidStmt.reason != null) { - effectiveKind = MockEntryKind.Void; + Compile(forbidStmt.reason); } - - switch (effectiveKind) + else { - case MockEntryKind.Void: - AddPushInt(_buffer, commandId); - _buffer.Add(OpCodes.MOCK_VOID); - break; - - case MockEntryKind.Returns: - AddPushInt(_buffer, commandId); - if (entry.returnExpression != null) - { - Compile(entry.returnExpression); - } - else - { - AddPushInt(_buffer, 0); - } - _buffer.Add(OpCodes.MOCK_RETURNS); - break; + Compile(new LiteralStringExpression(forbidStmt.startToken, "")); + } + var trampolinePatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _assertTrampolinePatches.Add(trampolinePatchIndex); - case MockEntryKind.Forbid: - AddPushInt(_buffer, commandId); - _buffer.Add(OpCodes.MOCK_FORBID); - break; + AddPushInt(_buffer, commandId); + _buffer.Add(OpCodes.MOCK_FORBID); + } + else if (returnsStmt != null) + { + AddPushInt(_buffer, commandId); + if (returnsStmt.expression != null) + { + Compile(returnsStmt.expression); } + else + { + AddPushInt(_buffer, 0); + } + _buffer.Add(OpCodes.MOCK_RETURNS); + } + else + { + // Empty body → suppress the real call entirely. + AddPushInt(_buffer, commandId); + _buffer.Add(OpCodes.MOCK_VOID); } } } @@ -3035,6 +3058,33 @@ public void Compile(IExpressionNode expr) argMap = commandExpr.argMap }); break; + case CallCountExpression callCountExpr: + { + // Resolve the command name to all overload ids; the count + // is per-id, but `call count ` means "across all + // overloads of ." Sum the per-id counts at runtime + // by emitting CALL_COUNT for each id and adding the + // results. For the common single-overload case this is + // just one CALL_COUNT instruction. If the name doesn't + // resolve to any command, push 0. + var ids = callCountExpr.commandName != null + ? ResolveMockCommandIds(callCountExpr.commandName) + : new List(); + if (ids.Count == 0) + { + AddPushInt(_buffer, 0); + } + else + { + EmitCallCountInline(ids[0]); + for (var i = 1; i < ids.Count; i++) + { + EmitCallCountInline(ids[i]); + _buffer.Add(OpCodes.ADD); + } + } + break; + } case LiteralStringExpression literalString: // allocate some memory for a string... var str = literalString.value; diff --git a/FadeBasic/FadeBasic/Virtual/OpCodes.cs b/FadeBasic/FadeBasic/Virtual/OpCodes.cs index 6d10a8d..099a899 100644 --- a/FadeBasic/FadeBasic/Virtual/OpCodes.cs +++ b/FadeBasic/FadeBasic/Virtual/OpCodes.cs @@ -455,5 +455,15 @@ public static class OpCodes /// general primitive any future "unwind to scope N" feature can reuse. /// public const byte PUSH_SCOPE_DEPTH = 72; + + /// + /// Reads a 4-byte command id inline from the bytecode (the next 4 + /// bytes after the opcode) and pushes the current invocation count + /// for that host command onto the data stack as an int. Counts are + /// maintained on the VM regardless of whether a mock is installed, + /// so `call count cmd` works for unmocked commands too — returns 0 + /// when `cmd` was never called. + /// + public const byte CALL_COUNT = 73; } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs index 485d8f7..92adb3f 100644 --- a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs +++ b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs @@ -64,6 +64,11 @@ public struct VirtualRuntimeError public VirtualRuntimeErrorType type; public int insIndex; public string message; + // Snapshot of the VM's methodStack at the moment the error was raised + // (innermost frame first). Resolution to source locations is done by + // a downstream consumer that has access to DebugData. Empty when the + // error happened with no function calls in flight. + public JumpHistoryData[] callStack; } public enum VirtualRuntimeErrorType @@ -166,6 +171,13 @@ public class TestFailure public string sourceText; // Captured text of the asserted expression. public int instructionIndex; // IP at the moment of failure (for source-mapping). public string reason; // Optional reason string from `assert , ""`. Empty when not provided. + // Snapshot of methodStack at the moment of failure. Innermost frame + // first (top of stack). Each entry's fromIns is the call site of + // that frame; toIns is the function's entry address. Empty when the + // assert fired at the test entry level with no function calls in + // between. Used to build a source-mapped call stack for the failure + // report; resolution happens in the test runner, not the VM. + public JumpHistoryData[] callStack = System.Array.Empty(); } /// @@ -177,6 +189,15 @@ public class TestFailure /// public Dictionary mockTable; + /// + /// Per-VM host-call counter. Incremented on every CALL_HOST (mocked or + /// not) when is true. Read by the + /// call count <command> expression so tests can assert + /// how often a command was invoked. Keyed by host method id, same as + /// . Null until the first increment. + /// + public Dictionary hostCallCounts; + public class MockBehavior { // 0 = void (skip), 1 = returns (push value), 2 = forbid (assert-fail). @@ -184,6 +205,12 @@ public class MockBehavior // For kind = Returns: the typed return value to push. public byte returnTypeCode; public byte[] returnBytes; + // For kind = Forbid: optional user-supplied reason text (empty + // when the user wrote `forbid` with no reason) and the address + // of the assert-unwind trampoline so a forbid failure can drain + // defers the same way an assert failure does. + public string forbidReason; + public int forbidTrampolineAddr; } public VirtualMachine(IEnumerable program) : this(program.ToArray()) @@ -476,6 +503,20 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp new ReadOnlySpan(BitConverter.GetBytes(scopeStack.Count)), TypeCodes.INT, TypeCodes.GetByteSize(TypeCodes.INT)); break; + case OpCodes.CALL_COUNT: + { + // Inline 4-byte command id; push that command's + // host-call count as a typed int. Unknown command + // ids (never invoked) push 0. + var cmdId = BitConverter.ToInt32(program, instructionIndex); + instructionIndex += 4; + var count = 0; + hostCallCounts?.TryGetValue(cmdId, out count); + stack.PushSpanAndType( + new ReadOnlySpan(BitConverter.GetBytes(count)), + TypeCodes.INT, TypeCodes.GetByteSize(TypeCodes.INT)); + break; + } case OpCodes.PUSH_DEFER: // read the place we should jump to when the scope is popped. VmUtil.ReadAsInt(ref stack, out var a); @@ -868,6 +909,18 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp VmUtil.ReadAsInt(ref stack, out var hostMethodPtr); hostMethods.FindMethod(hostMethodPtr, out var method); + // Per-command invocation counting. We tally every + // CALL_HOST in test mode regardless of mock state + // so `call count ` works even before any + // mock is installed. Outside test mode the count + // is unused, so skip the dictionary work. + if (isTestExecution) + { + hostCallCounts ??= new Dictionary(); + hostCallCounts.TryGetValue(hostMethodPtr, out var prevCount); + hostCallCounts[hostMethodPtr] = prevCount + 1; + } + if (mockTable != null && mockTable.TryGetValue(hostMethodPtr, out var mock)) { // Mocked: pop the args off the stack as the real @@ -888,13 +941,31 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp } else if (mock.kind == 2) { - // forbid: record an assertion failure naming the command + // Forbid: same shape as a failing assert. + // Capture the call stack, build a TestFailure + // carrying the user's reason (if supplied), + // and redirect to the unwind trampoline so + // defers in every live scope drain before + // the test runner reports the result. + // Re-entrancy guard: if a prior failure is + // already recorded (e.g., a deferred body + // re-fires forbid or assert), just halt + // and keep the first failure. + if (assertionFailure != null) + { + instructionIndex = int.MaxValue; + break; + } assertionFailure = new TestFailure { sourceText = "forbidden command was called: " + method.name, - instructionIndex = instructionIndex + reason = mock.forbidReason ?? "", + instructionIndex = instructionIndex, + callStack = CaptureCallStack() }; - instructionIndex = int.MaxValue; + instructionIndex = mock.forbidTrampolineAddr > 0 + ? mock.forbidTrampolineAddr + : int.MaxValue; } // kind == 0 (void): nothing else to do; args are gone } @@ -929,9 +1000,18 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp } case OpCodes.MOCK_FORBID: { + // Stack at dispatch (bottom→top): + // reason (string), trampolineAddr (int), commandId (int) VmUtil.ReadAsInt(ref stack, out var forbidId); + VmUtil.ReadAsInt(ref stack, out var forbidTrampoline); + var forbidReason = PopAssertString(); mockTable ??= new Dictionary(); - mockTable[forbidId] = new MockBehavior { kind = 2 }; + mockTable[forbidId] = new MockBehavior + { + kind = 2, + forbidReason = forbidReason, + forbidTrampolineAddr = forbidTrampoline + }; break; } case OpCodes.MOCK_CLEAR: @@ -1077,12 +1157,16 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp // Test-mode: record the failure (this path is also // taken when a test runtos into main-program code // that hits an assert) and redirect to the trampoline - // so defers in every live scope get drained. + // so defers in every live scope get drained. Capture + // the call chain now; the trampoline doesn't pop + // methodStack, but a stable snapshot decouples + // downstream consumers from VM state. assertionFailure = new TestFailure { sourceText = text, reason = reasonText, - instructionIndex = instructionIndex + instructionIndex = instructionIndex, + callStack = CaptureCallStack() }; instructionIndex = trampolineAddr; } @@ -1138,6 +1222,14 @@ public struct RuntoFrame void TriggerRuntimeError(VirtualRuntimeError error) { + // Stamp a call-stack snapshot onto the error unless the caller + // already provided one. This gives every runtime-error consumer + // (test runner, future crash reporter, DAP) the same shape used + // for assert-mode failures. + if (error.callStack == null) + { + error.callStack = CaptureCallStack(); + } this.error = error; if (shouldThrowRuntimeException) { @@ -1145,6 +1237,23 @@ void TriggerRuntimeError(VirtualRuntimeError error) } } + /// + /// Snapshot the current methodStack into a stable array. Index 0 is the + /// innermost (most recent) call; the last entry is the outermost. + /// Used by ASSERT_FAIL test-mode and TriggerRuntimeError to attach a + /// call-chain to the error, decoupled from later VM state changes. + /// + public JumpHistoryData[] CaptureCallStack() + { + var depth = methodStack.Count; + var copy = new JumpHistoryData[depth]; + for (var i = 0; i < depth; i++) + { + copy[i] = methodStack.buffer[depth - 1 - i]; + } + return copy; + } + // Pop one Fade string off the data stack and materialize it as a C# string. // Used by ASSERT_FAIL. The compiler pushes strings as [8 ptr bytes][type code] // and either STRING or PTR_HEAP type codes may appear here. Returns "" if diff --git a/FadeBasic/Tests/AssertMacroTests.cs b/FadeBasic/Tests/AssertMacroTests.cs index a7afd93..4ad9316 100644 --- a/FadeBasic/Tests/AssertMacroTests.cs +++ b/FadeBasic/Tests/AssertMacroTests.cs @@ -1,5 +1,6 @@ using FadeBasic; using FadeBasic.Ast; +using FadeBasic.Sdk; using FadeBasic.Virtual; namespace Tests; @@ -14,7 +15,10 @@ public class AssertMacroTests var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); prog.AssertNoParseErrors(); - var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + // Generate debug data so stack-frame resolution works in tests that + // exercise FadeTestExecutor.BuildFrames; the SDK enables this by default. + var compiler = new Compiler(TestCommands.CommandsForTesting, + new CompilerOptions { GenerateDebugData = true }); compiler.Compile(prog); return (compiler, compiler.Program.ToArray()); } @@ -408,4 +412,114 @@ defer static print ""never seen"" Assert.That(TestCommands.staticPrintBuffer, Is.Empty, "main-program assert is a hard crash; defers must not run"); } + + // ── Call-stack capture & source-location resolution ──────────────────── + // These exercise BuildFrames against a real compile+run so we know the + // VM's methodStack snapshot survives the unwind and that DebugData + // resolves it to the expected lines. + + private FadeTestResult RunTestThroughExecutor(string src, string testName) + { + var (compiler, program) = Compile(src); + var entry = compiler.TestManifest.First(t => t.name == testName); + return FadeTestExecutor.RunTest(program, compiler.methodTable, entry, compiler.DebugData); + } + + [Test] + public void Assert_StackTrace_ReportsAssertLine_NotTestLine() + { + // Mirrors the user-reported scenario: assert lives inside a function + // called from the main program, which is reached via runto from a + // test. The innermost frame must point at the assert's actual line, + // not the test entry's line. Line numbers are 0-based (lexer's + // coordinate space); displayed as 1-based by adapters that add 1. + var src = @"function ex(x) + assert x > 0, ""x must be positive"" +endfunction +ex(0) +checkpoint: +end + +test sample + runto checkpoint +endtest +"; + var result = RunTestThroughExecutor(src, "sample"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureFrames, Is.Not.Empty, + "DebugData was enabled — frames should resolve"); + + // Innermost frame = the assert inside `ex`. Source line 1 (0-based), + // which is line 2 when displayed. + var innermost = result.failureFrames[0]; + Assert.That(innermost.functionName, Is.EqualTo("ex")); + Assert.That(innermost.lineNumber, Is.EqualTo(1)); + } + + [Test] + public void Assert_StackTrace_IncludesCallerOfFunction() + { + // The frame above the assert is the caller (`ex(0)`), on 0-based + // line 3 (displayed as line 4). + var src = @"function ex(x) + assert x > 0, ""x must be positive"" +endfunction +ex(0) +checkpoint: +end + +test sample + runto checkpoint +endtest +"; + var result = RunTestThroughExecutor(src, "sample"); + Assert.That(result.failureFrames.Count, Is.GreaterThanOrEqualTo(2)); + + var outermost = result.failureFrames[^1]; + Assert.That(outermost.functionName, Is.Empty, + "outermost frame has no function name (it's the main program / test entry)"); + Assert.That(outermost.lineNumber, Is.EqualTo(3)); + } + + [Test] + public void Assert_StackTrace_AssertInTestBody_OneFrame() + { + // No function calls — the entire failure is at the test entry level. + // We still get one frame (the assert site at 0-based line 1). + var src = @"test sample + assert 0, ""boom"" +endtest +"; + var result = RunTestThroughExecutor(src, "sample"); + Assert.That(result.failureFrames, Is.Not.Empty); + Assert.That(result.failureFrames.Count, Is.EqualTo(1)); + Assert.That(result.failureFrames[0].functionName, Is.Empty); + Assert.That(result.failureFrames[0].lineNumber, Is.EqualTo(1)); + } + + [Test] + public void Assert_StackTrace_EmptyWhenNoDebugData() + { + // Without DebugData the runner can't resolve frames; failureFrames + // stays empty and the adapter falls back to entry.sourceLine. + var src = @"test sample + assert 0 +endtest +"; + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(new ParseOptions { ignoreChecks = true }); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, + new CompilerOptions { GenerateDebugData = false }); + compiler.Compile(prog); + + var entry = compiler.TestManifest.First(t => t.name == "sample"); + var result = FadeTestExecutor.RunTest( + compiler.Program.ToArray(), compiler.methodTable, entry, debugData: null); + + Assert.That(result.passed, Is.False); + Assert.That(result.failureFrames, Is.Empty); + } } diff --git a/FadeBasic/Tests/FadeTestAdapterTests.cs b/FadeBasic/Tests/FadeTestAdapterTests.cs index e87a680..6680f33 100644 --- a/FadeBasic/Tests/FadeTestAdapterTests.cs +++ b/FadeBasic/Tests/FadeTestAdapterTests.cs @@ -311,23 +311,49 @@ public void Executor_BuildErrorStackTrace_ProducesClickableFormat() { // The exact format ("at NAME in FILE:line N") is the contract that // both Rider and VS Code parse to make stack lines clickable. + // Legacy fallback path: no resolved frames, uses entry.sourceLine. var entry = new TestManifestEntry { name = "wraps_at_right_edge", sourceLine = 42 }; - var stack = FadeTestExecutorAdapter.BuildErrorStackTrace("/proj/fish.fbasic", entry); + var result = new FadeTestResult(); + var stack = FadeTestExecutorAdapter.BuildErrorStackTrace(result, "/proj/fish.fbasic", entry); Assert.That(stack, Does.Match(@"\s+at wraps_at_right_edge in /proj/fish\.fbasic:line 42")); } + [Test] + public void Executor_BuildErrorStackTrace_RendersResolvedFrames() + { + // When the runner supplied resolved frames, the adapter emits one + // "at NAME in FILE:line N" line per frame, innermost first. The + // outermost frame uses the test name as its label. + var entry = new TestManifestEntry { name = "sample", sourceLine = 7 }; + var result = new FadeTestResult + { + failureFrames = new List + { + new FadeStackFrame { functionName = "ex", lineNumber = 2 }, + new FadeStackFrame { functionName = string.Empty, lineNumber = 5 }, + } + }; + var stack = FadeTestExecutorAdapter.BuildErrorStackTrace(result, "/proj/fish.fbasic", entry); + + Assert.That(stack, Does.Match(@"\s+at ex in /proj/fish\.fbasic:line 2")); + Assert.That(stack, Does.Match(@"\s+at sample in /proj/fish\.fbasic:line 5")); + // Innermost (ex) must precede outermost (sample). + Assert.That(stack.IndexOf("ex"), Is.LessThan(stack.IndexOf("sample"))); + } + [Test] public void Executor_BuildErrorStackTrace_EmptyWhenSourceUnknown() { // Without source info we can't synthesize a useful frame; emit // empty rather than a half-frame the IDE will mis-parse. var entry = new TestManifestEntry { name = "x", sourceLine = 0 }; - var stack = FadeTestExecutorAdapter.BuildErrorStackTrace("/p/main.fbasic", entry); + var result = new FadeTestResult(); + var stack = FadeTestExecutorAdapter.BuildErrorStackTrace(result, "/p/main.fbasic", entry); Assert.That(stack, Is.Empty); var entry2 = new TestManifestEntry { name = "x", sourceLine = 5 }; - var stack2 = FadeTestExecutorAdapter.BuildErrorStackTrace(string.Empty, entry2); + var stack2 = FadeTestExecutorAdapter.BuildErrorStackTrace(result, string.Empty, entry2); Assert.That(stack2, Is.Empty); } diff --git a/FadeBasic/Tests/MockExecutionTests.cs b/FadeBasic/Tests/MockExecutionTests.cs index b841149..74c035a 100644 --- a/FadeBasic/Tests/MockExecutionTests.cs +++ b/FadeBasic/Tests/MockExecutionTests.cs @@ -22,9 +22,9 @@ private FadeRuntimeContext CreateContext(string src) } [Test] - public void MockVoid_BareForm_SuppressesRealCall() + public void MockEmpty_SuppressesRealCall() { - // `mock wait ms` (no body) installs a void mock. The C# WiatMs + // `mock wait ms / endmock` installs a void mock. The C# WaitMs // method should NOT be called, so waitMsCallCount stays at 0. var src = @" checkpoint: @@ -33,6 +33,7 @@ wait ms 50 test no_real_wait mock wait ms + endmock runto checkpoint wait ms 100 endtest @@ -51,7 +52,9 @@ public void MockReturns_OverridesReturnValue() end test mocked_screen_width - mock screen width returns 42 + mock screen width + returns 42 + endmock assert screen width() = 42 endtest "; @@ -84,7 +87,9 @@ public void MockForbid_FailsTestWhenCommandCalled() end test forbidden - mock wait ms forbid + mock wait ms + forbid + endmock wait ms 1 endtest "; @@ -107,7 +112,9 @@ local w as integer end test mocked_via_runto - mock screen width returns 99 + mock screen width + returns 99 + endmock runto checkpoint assert w = 99 endtest @@ -126,7 +133,9 @@ public void ClearMock_RestoresRealBehavior() end test clear_mock - mock screen width returns 42 + mock screen width + returns 42 + endmock assert screen width() = 42 clear mock screen width assert screen width() = 5 @@ -146,8 +155,11 @@ public void ClearMocks_RemovesAllRegistrations() end test clear_all - mock screen width returns 42 + mock screen width + returns 42 + endmock mock wait ms + endmock clear mocks assert screen width() = 5 wait ms 1 @@ -161,30 +173,162 @@ wait ms 1 } [Test] - public void MockReturns_OnVoidCommand_DegradesToVoid() + public void MockForbid_WithReason_CapturesReason() + { + var src = @" +end + +test forbid_with_reason + mock wait ms + forbid ""no waiting in tests"" + endmock + wait ms 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("forbid_with_reason"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureReason, Is.EqualTo("no waiting in tests")); + Assert.That(result.failureMessage, Does.Contain("no waiting in tests"), + "user-supplied reason should surface in the failure message"); + } + + [Test] + public void MockForbid_RunsDefersOnFailure() + { + // Forbid now goes through the assert-unwind trampoline, so defers + // in every live scope drain before the test runner sees the result. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test forbid_drains_defers + defer static print ""cleanup"" + mock wait ms + forbid + endmock + wait ms 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("forbid_drains_defers"); + Assert.That(result.passed, Is.False); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "cleanup" }), + "forbid failure must drain test-scope defers"); + } + + [Test] + public void MockForbid_CapturesCallStack() { - // A user mocking a void command sometimes writes a `returns` body - // thinking it's required. The compiler should silently treat that - // as a void mock (no value pushed) rather than corrupting the stack - // and falling through to the real implementation. + // Forbid carries a source-located stack like an assert does. var src = @" +function trigger() + wait ms 1 +endfunction +trigger() checkpoint: -wait ms 50 end -test mocked_with_returns +test forbid_stack mock wait ms - returns 0 + forbid ""nope"" endmock runto checkpoint - wait ms 100 endtest "; var ctx = CreateContext(src); - var result = ctx.RunTest("mocked_with_returns"); + var result = ctx.RunTest("forbid_stack"); + Assert.That(result.passed, Is.False); + Assert.That(result.failureFrames, Is.Not.Empty, + "forbid failure should resolve to source frames when DebugData is present"); + // Innermost frame is inside `trigger()` (where wait ms was called). + Assert.That(result.failureFrames[0].functionName, Is.EqualTo("trigger")); + } + + // ── call count ─────────────────────────────────────────────── + + [Test] + public void CallCount_CountsHostInvocations() + { + // No mock installed — the real command runs and gets counted. The + // counter increments on every CALL_HOST in test mode. + var src = @" +end + +test count_real_calls + wait ms 1 + wait ms 1 + wait ms 1 + assert call count wait ms = 3 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("count_real_calls"); + Assert.That(result.passed, Is.True, + "expected count=3; failure: " + result.failureMessage); + } + + [Test] + public void CallCount_ZeroForUncalledCommand() + { + // A command that's never called returns 0. No mock needed; the + // counter starts empty and the runtime treats missing keys as 0. + var src = @" +end + +test never_called + assert call count wait ms = 0 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("never_called"); Assert.That(result.passed, Is.True, result.failureMessage); - Assert.That(TestCommands.waitMsCallCount, Is.EqualTo(0), - "wait ms should be fully suppressed even when written as `returns 0`"); + } + + [Test] + public void CallCount_CountsMockedCalls() + { + // Mocking doesn't suppress counting — the count includes calls that + // hit a mock too. + var src = @" +end + +test count_mocked + mock wait ms + endmock + wait ms 1 + wait ms 2 + assert call count wait ms = 2 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("count_mocked"); + Assert.That(result.passed, Is.True, + "mocked calls should still be counted; failure: " + result.failureMessage); + } + + [Test] + public void CallCount_IsolatedBetweenTests() + { + // Counts reset per test (each test gets a fresh VM). + var src = @" +end + +test first + wait ms 1 + assert call count wait ms = 1 +endtest + +test second + assert call count wait ms = 0 +endtest +"; + var ctx = CreateContext(src); + var first = ctx.RunTest("first"); + var second = ctx.RunTest("second"); + Assert.That(first.passed, Is.True, first.failureMessage); + Assert.That(second.passed, Is.True, + "second test must see count=0; failure: " + second.failureMessage); } [Test] @@ -196,7 +340,9 @@ public void MockIsolation_BetweenTestRuns() end test installs_mock - mock screen width returns 42 + mock screen width + returns 42 + endmock assert screen width() = 42 endtest diff --git a/FadeBasic/Tests/MockParserTests.cs b/FadeBasic/Tests/MockParserTests.cs index 1ba74b8..9fd0ce1 100644 --- a/FadeBasic/Tests/MockParserTests.cs +++ b/FadeBasic/Tests/MockParserTests.cs @@ -40,107 +40,121 @@ private ClearMockStatement FindFirstClearMock(ProgramNode prog) return null; } + // ── Block-form shape ─────────────────────────────────────────────────── + [Test] - public void Mock_InlineReturns_ParsesAsAlwaysFrequency() + public void Mock_Empty_Body_ParsesAsVoidMock() { + // Empty block = suppress the call. No inline form: `endmock` is + // required even for void mocks. var src = @" test foo - mock screen width returns 10 + mock wait ms + endmock endtest "; var prog = Parse(src, out var errs); Assert.That(errs, Is.Empty, "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); - var mock = FindFirstMock(prog); Assert.That(mock, Is.Not.Null); - Assert.That(mock.commandName, Is.EqualTo("screen width")); - Assert.That(mock.entries.Count, Is.EqualTo(1)); - Assert.That(mock.entries[0].kind, Is.EqualTo(MockEntryKind.Returns)); - Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Always)); - Assert.That(mock.entries[0].returnExpression, Is.Not.Null); + Assert.That(mock.commandName, Is.EqualTo("wait ms")); + Assert.That(mock.body, Is.Empty); } [Test] - public void Mock_InlineForbid_ParsesAsAlwaysFrequency() + public void Mock_Returns_ParsesAsReturnsStatement() { var src = @" test foo - mock screen width forbid + mock screen width + returns 10 + endmock endtest "; var prog = Parse(src, out var errs); - Assert.That(errs, Is.Empty); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); var mock = FindFirstMock(prog); - Assert.That(mock.entries.Count, Is.EqualTo(1)); - Assert.That(mock.entries[0].kind, Is.EqualTo(MockEntryKind.Forbid)); - Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Always)); + Assert.That(mock.body.Count, Is.EqualTo(1)); + Assert.That(mock.body[0], Is.TypeOf()); + var rs = (MockReturnsStatement)mock.body[0]; + Assert.That(rs.expression, Is.Not.Null); } [Test] - public void Mock_FrequencyOnce_Parses() + public void Mock_Forbid_ParsesAsForbidStatement() { var src = @" test foo - mock screen width returns 10 once + mock screen width + forbid + endmock endtest "; var prog = Parse(src, out var errs); Assert.That(errs, Is.Empty); var mock = FindFirstMock(prog); - Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Once)); + Assert.That(mock.body.Count, Is.EqualTo(1)); + Assert.That(mock.body[0], Is.TypeOf()); + var fs = (MockForbidStatement)mock.body[0]; + Assert.That(fs.reason, Is.Null); } [Test] - public void Mock_FrequencyNTimes_Parses() + public void Mock_ForbidWithReason_ParsesReason() { var src = @" test foo - mock screen width returns 10 3 times + mock wait ms + forbid ""no waiting in tests"" + endmock endtest "; var prog = Parse(src, out var errs); Assert.That(errs, Is.Empty, - "expected clean parse; got: " + string.Join(", ", errs.Select(e => e.Display))); + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); var mock = FindFirstMock(prog); - Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.NTimes)); - Assert.That(mock.entries[0].countExpression, Is.Not.Null); + var fs = (MockForbidStatement)mock.body[0]; + Assert.That(fs.reason, Is.Not.Null); } + // ── Error paths ──────────────────────────────────────────────────────── + [Test] - public void Mock_FrequencyAlwaysExplicit_Parses() + public void Mock_InlineForm_NoLongerSupported_Errors() { + // `mock cmd returns X` on one line is no longer valid — the parser + // expects a newline and `endmock`. The `returns 10` token sequence + // now sits in an empty mock body, awaiting `endmock`; eventually + // the surrounding `endtest` is hit and MockMissingEndMock fires. var src = @" test foo - mock screen width returns 10 always + mock screen width returns 10 endtest "; - var prog = Parse(src, out var errs); - Assert.That(errs, Is.Empty); - var mock = FindFirstMock(prog); - Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Always)); + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), + Is.True, + "inline mock should now require endmock; got: " + + string.Join(", ", errs.Select(e => e.Display))); } [Test] - public void Mock_BlockForm_MultipleEntries_Parses() + public void Mock_BareForm_NoLongerSupported_Errors() { + // `mock cmd` with no `endmock` used to install a void mock. Now + // every mock requires `endmock`. var src = @" test foo - mock screen width - returns 10 once - returns 20 once - returns 5 always - endmock + mock wait ms endtest "; - var prog = Parse(src, out var errs); - Assert.That(errs, Is.Empty, - "expected clean parse; got: " + string.Join(", ", errs.Select(e => e.Display))); - var mock = FindFirstMock(prog); - Assert.That(mock.entries.Count, Is.EqualTo(3)); - Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Once)); - Assert.That(mock.entries[1].frequency, Is.EqualTo(MockFrequencyKind.Once)); - Assert.That(mock.entries[2].frequency, Is.EqualTo(MockFrequencyKind.Always)); + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), + Is.True, + "bare mock should now require endmock; got: " + + string.Join(", ", errs.Select(e => e.Display))); } [Test] @@ -149,7 +163,7 @@ public void Mock_MissingEndMock_Errors() var src = @" test foo mock screen width - returns 10 once + returns 10 endtest "; Parse(src, out var errs); @@ -165,7 +179,9 @@ public void Mock_UnknownCommand_Errors() // parser sees a missing command name. var src = @" test foo - mock not_a_real_command returns 10 + mock not_a_real_command + returns 10 + endmock endtest "; Parse(src, out var errs); @@ -175,22 +191,98 @@ mock not_a_real_command returns 10 } [Test] - public void Mock_UnreachableEntry_AfterAlways_Warns() + public void Mock_MultipleReturns_Errors() + { + // A body may have at most one `returns`. (Frequency is gone, so the + // old "stacked returns with different frequencies" use case is too.) + var src = @" +test foo + mock screen width + returns 10 + returns 20 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMultipleReturns)), + Is.True, + "expected MockMultipleReturns; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_MultipleForbid_Errors() { var src = @" test foo mock screen width - returns 10 always - returns 20 once + forbid + forbid endmock endtest "; Parse(src, out var errs); - Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockUnreachableEntry)), + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMultipleForbid)), + Is.True, + "expected MockMultipleForbid; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ReturnsAndForbid_Errors() + { + // `returns` + `forbid` together is nonsensical: forbid prevents the + // return path from ever running. + var src = @" +test foo + mock screen width + returns 10 + forbid + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsAndForbid)), + Is.True, + "expected MockReturnsAndForbid; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ForbidReasonMustBeString() + { + var src = @" +test foo + mock wait ms + forbid 42 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockForbidReasonMustBeString)), + Is.True, + "expected MockForbidReasonMustBeString; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_BlockForm_MissingEndMock_DoesNotConsumeEndTest() + { + // When `endmock` is missing, the mock parser must NOT consume the + // surrounding `endtest`; the missing-endmock error is reported and + // the test parser still terminates correctly. + var src = @" +test foo + mock screen width + returns 10 +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), Is.True, - "expected MockUnreachableEntry; got: " + string.Join(", ", errs.Select(e => e.Display))); + "expected MockMissingEndMock; got: " + string.Join(", ", errs.Select(e => e.Display))); + // The test should still be properly closed. + Assert.That(prog.tests.Count, Is.EqualTo(1)); } + // ── ClearMock ────────────────────────────────────────────────────────── + [Test] public void ClearMock_SingleCommand_Parses() { @@ -236,96 +328,136 @@ clear something "expected ClearMockMissingTarget; got: " + string.Join(", ", errs.Select(e => e.Display))); } + // ── Scope enforcement ────────────────────────────────────────────────── + [Test] - public void Mock_BlockForm_MixedReturnsAndForbid_Parses() + public void Mock_OutsideTest_Errors() { var src = @" +mock screen width + returns 10 +endmock +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockOutsideTest)), + Is.True, + "expected MockOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void ClearMock_OutsideTest_Errors() + { + var src = @" +clear mocks +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ClearMockOutsideTest)), + Is.True, + "expected ClearMockOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + // ── Type validation (Phase C) ────────────────────────────────────────── + + [Test] + public void Mock_ReturnsOnVoidCommand_Errors() + { + // `wait ms` is void — `returns 0` against it must error rather than + // silently degrade (the old behavior). + var src = @" test foo - mock screen width - returns 10 once - forbid always + mock wait ms + returns 0 endmock endtest "; - var prog = Parse(src, out var errs); - Assert.That(errs, Is.Empty, - "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); - var mock = FindFirstMock(prog); - Assert.That(mock.entries.Count, Is.EqualTo(2)); - Assert.That(mock.entries[0].kind, Is.EqualTo(MockEntryKind.Returns)); - Assert.That(mock.entries[1].kind, Is.EqualTo(MockEntryKind.Forbid)); + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsOnVoidCommand)), + Is.True, + "expected MockReturnsOnVoidCommand; got: " + string.Join(", ", errs.Select(e => e.Display))); } [Test] - public void Mock_StackedInline_StopsAtNewline_NoEndMockNeeded() + public void Mock_ReturnsTypeMismatch_Errors() { - // Stacked inline form via colon — DEFER-style. No endmock required; - // the statement ends at the first newline. + // `screen width` returns an int — returning a string should error. var src = @" test foo - mock screen width returns 10 once: returns 20 once + mock screen width + returns ""nope"" + endmock endtest "; - var prog = Parse(src, out var errs); - Assert.That(errs, Is.Empty, - "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); - var mock = FindFirstMock(prog); - Assert.That(mock.entries.Count, Is.EqualTo(2)); - Assert.That(mock.entries[0].frequency, Is.EqualTo(MockFrequencyKind.Once)); - Assert.That(mock.entries[1].frequency, Is.EqualTo(MockFrequencyKind.Once)); + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsTypeMismatch)), + Is.True, + "expected MockReturnsTypeMismatch; got: " + string.Join(", ", errs.Select(e => e.Display))); } [Test] - public void Mock_BlockForm_MissingEndMock_DoesNotConsumeEndTest() + public void Mock_ReturnsNumericCoercion_Ok() { - // When `endmock` is missing, the mock parser must NOT consume the - // surrounding `endtest`; the missing-endmock error is reported and - // the test parser still terminates correctly. + // `now` returns a long. `returns 5` (int literal) should coerce + // cleanly — same rule that lets `local n as long = 5` work. We use + // EnforceTypeAssignment so the coercion semantics stay consistent + // with the rest of the language. var src = @" test foo - mock screen width - returns 10 once + mock now + returns 5 + endmock endtest "; - var prog = Parse(src, out var errs); - Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), - Is.True, - "expected MockMissingEndMock; got: " + string.Join(", ", errs.Select(e => e.Display))); - // The test should still be properly closed (the test node exists with - // the mock as its only top-level statement). - Assert.That(prog.tests.Count, Is.EqualTo(1)); + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsTypeMismatch)), + Is.False, + "int → long coercion should be allowed in mock returns; got: " + + string.Join(", ", errs.Select(e => e.Display))); } [Test] - public void Mock_OutsideTest_Errors() + public void Mock_ReturnsMatchingType_Ok() { + // `screen width` returns int; `returns 42` should be fine. var src = @" -mock screen width returns 10 +test foo + mock screen width + returns 42 + endmock +endtest "; - var prog = Parse(src, out var errs); - Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockOutsideTest)), - Is.True, - "expected MockOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + Parse(src, out var errs); + Assert.That(errs.Any(e => + e.errorCode.Equals(ErrorCodes.MockReturnsOnVoidCommand) + || e.errorCode.Equals(ErrorCodes.MockReturnsTypeMismatch)), + Is.False, + "no type errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); } [Test] - public void ClearMock_OutsideTest_Errors() + public void Mock_EmptyBodyOnAnyCommand_Ok() { + // Empty mock body = suppress the call. Always valid regardless of + // whether the command returns a value (the caller of a value- + // returning command gets a stack-leak if it reads the return — but + // that's a separate runtime concern; the parser accepts it). var src = @" -clear mocks +test foo + mock screen width + endmock +endtest "; - var prog = Parse(src, out var errs); - Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ClearMockOutsideTest)), - Is.True, - "expected ClearMockOutsideTest; got: " + string.Join(", ", errs.Select(e => e.Display))); + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "empty mock body should parse cleanly; got: " + + string.Join(", ", errs.Select(e => e.Display))); } [Test] public void Assert_OutsideTest_IsAllowed() { - // `assert` is now legal in the main program. When the VM runs the - // program directly and an assert fails, it triggers a runtime crash - // (verified by VM-side tests). Parse should produce no errors here. + // Unrelated to mock but lives in this fixture historically. + // `assert` is legal in the main program; the VM crashes at runtime + // when one fails outside a test. var src = @" assert 1 = 1 "; diff --git a/FadeBasic/Tests/MusicFbasicReproTests.cs b/FadeBasic/Tests/MusicFbasicReproTests.cs index d0a2b2e..ae61a51 100644 --- a/FadeBasic/Tests/MusicFbasicReproTests.cs +++ b/FadeBasic/Tests/MusicFbasicReproTests.cs @@ -28,7 +28,6 @@ endfunction sum test abc print ""running test"" mock wait ms - returns 0 endmock runto lbl1 From 64fc8fae59f4b7a51c25d1b481f94d14deb13592 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Sun, 17 May 2026 13:45:12 -0400 Subject: [PATCH 06/30] more testing stuff --- .../Commands/Standard/StringCommands.cs | 5 - FadeBasic/FadeBasic/Ast/ExpressionNode.cs | 28 + FadeBasic/FadeBasic/Ast/StatementNode.cs | 33 +- .../Ast/Visitors/ScopeErrorVisitor.cs | 657 ++++++++++++-- .../Visitors/TestScopeStrictnessVisitor.cs | 212 ++++- FadeBasic/FadeBasic/Errors.cs | 25 +- FadeBasic/FadeBasic/Lexer.cs | 10 +- FadeBasic/FadeBasic/Lsp/LSPUtil.cs | 3 +- FadeBasic/FadeBasic/Parser.cs | 200 ++++- FadeBasic/FadeBasic/Virtual/Compiler.cs | 816 ++++++++++++++++-- FadeBasic/FadeBasic/Virtual/OpCodes.cs | 101 ++- FadeBasic/FadeBasic/Virtual/VirtualMachine.cs | 307 ++++++- FadeBasic/Tests/ArraySpreadParamsTests.cs | 165 ++++ FadeBasic/Tests/ExpressionTests.cs | 2 +- FadeBasic/Tests/LenKeywordTests.cs | 120 +++ FadeBasic/Tests/MockExecutionTests.cs | 568 +++++++++++- FadeBasic/Tests/MockParserTests.cs | 269 +++++- FadeBasic/Tests/MusicFbasicReproTests.cs | 62 ++ FadeBasic/Tests/ParserTests.cs | 2 +- FadeBasic/Tests/TestFromChainTests.cs | 557 ++++++++++++ FadeBasic/Tests/TestScopeStrictnessTests.cs | 36 + FadeBasic/book/FadeBook/Language.md | 433 +++++++++- 22 files changed, 4392 insertions(+), 219 deletions(-) create mode 100644 FadeBasic/Tests/ArraySpreadParamsTests.cs create mode 100644 FadeBasic/Tests/LenKeywordTests.cs create mode 100644 FadeBasic/Tests/TestFromChainTests.cs diff --git a/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/StringCommands.cs b/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/StringCommands.cs index e9ef366..dae12cf 100644 --- a/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/StringCommands.cs +++ b/FadeBasic/FadeBasic.Lib.Standard/Commands/Standard/StringCommands.cs @@ -74,11 +74,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/Ast/ExpressionNode.cs b/FadeBasic/FadeBasic/Ast/ExpressionNode.cs index d869804..d7abe89 100644 --- a/FadeBasic/FadeBasic/Ast/ExpressionNode.cs +++ b/FadeBasic/FadeBasic/Ast/ExpressionNode.cs @@ -392,6 +392,33 @@ public override IEnumerable IterateChildNodes() } } + /// + /// `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}"; + } + + public override IEnumerable IterateChildNodes() + { + if (inner != null) yield return inner; + } + } + /// /// `call count ` — integer expression returning the number of /// times the host command was invoked during the current VM execution. @@ -422,4 +449,5 @@ public override IEnumerable IterateChildNodes() yield break; } } + } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Ast/StatementNode.cs b/FadeBasic/FadeBasic/Ast/StatementNode.cs index c848fd4..c8575e4 100644 --- a/FadeBasic/FadeBasic/Ast/StatementNode.cs +++ b/FadeBasic/FadeBasic/Ast/StatementNode.cs @@ -176,11 +176,11 @@ public override IEnumerable IterateChildNodes() /// command produces when called. Only valid inside a mock block; the /// scope-error visitor enforces that. /// - public class MockReturnsStatement : AstNode, IStatementNode + public class MockExitMockStatement : AstNode, IStatementNode { public IExpressionNode expression; - public MockReturnsStatement(Token startToken, Token endToken) : base(startToken, endToken) + public MockExitMockStatement(Token startToken, Token endToken) : base(startToken, endToken) { } @@ -226,10 +226,24 @@ public class MockStatement : AstNode, IStatementNode // CommandNameTree pass). public string commandName; public Token commandNameToken; - // Body of the mock block. In Phase A this holds MockReturnsStatement - // and MockForbidStatement only; Phase B will broaden it to any - // statement so the body acts as a mini-function the VM dispatches. - // An empty body means "void mock" — calls are suppressed. + // Optional parameter names — `mock find pattern, list` binds the + // command's args to locals named `pattern` and `list` inside the body. + // Empty means anonymous (args are popped off the stack but not + // accessible). The count must match the command's non-VmArg arg count + // when names are given; the visitor enforces that. + public List parameters = new List(); + // Optional fall-through return expression on `endmock ` — the + // value the body produces when execution reaches the closing + // `endmock` without an earlier `exitmock`. Mirrors `endfunction + // ` for functions. Null when the user wrote bare `endmock`. + public IExpressionNode endmockExpression; + // Body of the mock block. Compiled as a mini-function the VM + // dispatches to at CALL_HOST time: a scope is pushed, parameters + // bound from the call's args, then body statements run. `returns` + // (MockExitMockStatement) sets the return value; `forbid` + // (MockForbidStatement) fails the test. Other test-block statements + // (static print, local, if/then, assert) are legal here too. + // An empty body on a void command means "suppress the call." public List body = new List(); public MockStatement(Token startToken, Token endToken) : base(startToken, endToken) @@ -238,12 +252,17 @@ public MockStatement(Token startToken, Token endToken) : base(startToken, endTok protected override string GetString() { - return $"mock {commandName} ({string.Join(",", body.Select(s => s.ToString()))})"; + var paramStr = parameters.Count > 0 + ? " " + string.Join(",", parameters.Select(p => p.variableName)) + : ""; + return $"mock {commandName}{paramStr} ({string.Join(",", body.Select(s => s.ToString()))})"; } public override IEnumerable IterateChildNodes() { + foreach (var p in parameters) yield return p; foreach (var stmt in body) yield return stmt; + if (endmockExpression != null) yield return endmockExpression; } } diff --git a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs index abfe5e1..cee7f77 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/ScopeErrorVisitor.cs @@ -245,20 +245,71 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions scope.DoDelayedTypeChecks(); - // as the very last part of verifying the scope, - // we need to verify the child scopes, which at this point, are just tests + // as the very last part of verifying the scope, + // we need to verify the child scopes, which at this point, are just tests scope.currentRegionName.Pop(); // remove the top level region. - foreach (var test in program.tests) + + // Flag duplicate test names (case-insensitive — matches the + // lookup semantics used by FindTestByName + the runner's + // manifest lookup). The first occurrence keeps the name; every + // later sibling with the same name gets an error pinned on its + // own name token. We don't drop them from validation — the + // user might want to fix one at a time, and downstream checks + // still produce useful errors for both bodies. + { + var seenTestNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var t in program.tests) + { + if (t.name == null) continue; + if (!seenTestNames.Add(t.name)) + { + Token dupTok = t.nameToken ?? t.StartToken; + t.Errors.Add(new ParseError(dupTok, + ErrorCodes.TestDuplicateName, t.name)); + } + } + } + // + // A test with `from ` must validate AFTER its parent so we can + // pass the parent's testProgram as the scope baseline — the same + // program→test copy logic above then folds parent's locals/functions + // into the child's fresh scope. Order tests Kahn-style by from-chain; + // anything still unordered after a full pass is in a cycle (the strict + // visitor will flag it) and falls back to the program baseline so the + // child still validates against globals and doesn't lose unrelated + // errors. + var orderedTests = OrderTestsByFromChain(program.tests); + foreach (var test in orderedTests) { // Tests cannot be nested inside another test. parentProgram != null // means *we* are already a test sub-program, so any tests we contain // are an invalid nesting. if (parentProgram != null) { - test.Errors.Add(new ParseError(test.nameToken ?? test.StartToken, ErrorCodes.TestNestingNotAllowed)); + Token nestingTok = test.nameToken ?? test.StartToken; + test.Errors.Add(new ParseError(nestingTok, ErrorCodes.TestNestingNotAllowed)); continue; } - test.testProgram.AddScopeRelatedErrors(options, knownFunctionTypes, program); + + // Default baseline = the outer program (program-level globals, + // labels, types, functions). If this test has a resolvable, + // already-validated parent test, use the parent's testProgram + // instead so child picks up parent's locals/functions on top + // of the program baseline (parent's own validation already + // folded the program-level state into its scope, so the + // baselines compose transitively). + ProgramNode baseline = program; + if (test.fromParent != null) + { + var parentTest = FindTestByName(program.tests, test.fromParent); + if (parentTest != null + && parentTest != test + && parentTest.testProgram.scope != null) + { + baseline = parentTest.testProgram; + } + } + test.testProgram.AddScopeRelatedErrors(options, knownFunctionTypes, baseline); } // Strict scope_at(:L) enforcement runs after all test sub-scopes are built, @@ -271,6 +322,85 @@ public static void AddScopeRelatedErrors(this ProgramNode program, ParseOptions } + // Locate a sibling test by name (case-insensitive). Used by the + // test-iteration loop to resolve `from ` references. Returns + // null when the parent name doesn't match any test — the strict + // visitor handles that with a clean TestFromParentUnknown error; + // here we just fall back to using the outer program as the baseline. + static TestNode FindTestByName(List tests, string name) + { + if (name == null || tests == null) return null; + foreach (var t in tests) + { + if (t.name != null + && string.Equals(t.name, name, StringComparison.OrdinalIgnoreCase)) + { + return t; + } + } + return null; + } + + // Order tests so each child appears after its `from`-parent. Anything + // unreachable (cycle members, tests whose chain hits an unknown name) + // appends at the end and gets validated against the outer program + // baseline — preserves error coverage without infinite-recursing. + // Kahn-style: repeatedly emit any test whose parent has been emitted + // (or whose parent is missing/null), until no progress is possible. + static List OrderTestsByFromChain(List tests) + { + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var t in tests) + { + if (t.name != null) byName[t.name] = t; + } + + var emitted = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(tests.Count); + var pending = new List(tests); + + bool TryEmit(TestNode t) + { + if (t.name == null) return false; + if (emitted.Contains(t.name)) return true; + // Tests with no parent, an unknown parent, or a parent that + // resolves to themselves can emit immediately — there's + // nothing to wait for in the inheritance chain. + if (t.fromParent == null + || !byName.TryGetValue(t.fromParent, out var parent) + || parent == t) + { + result.Add(t); + emitted.Add(t.name); + return true; + } + if (!emitted.Contains(parent.name)) return false; + result.Add(t); + emitted.Add(t.name); + return true; + } + + var madeProgress = true; + while (pending.Count > 0 && madeProgress) + { + madeProgress = false; + for (var i = pending.Count - 1; i >= 0; i--) + { + if (TryEmit(pending[i])) + { + pending.RemoveAt(i); + madeProgress = true; + } + } + } + // Anything still pending is in a cycle. Append them in source + // order with no parent-state inheritance available — they'll + // validate against the program baseline. The strict visitor + // has already flagged the cycle. + foreach (var t in pending) result.Add(t); + return result; + } + static void CheckTypesForUnknownReferences(Scope scope) { foreach (var namedType in scope.typeNameToTypeMembers) @@ -692,23 +822,23 @@ static void CheckStatements(this List statements, Scope scope, E // overload would reject the expression, error. static void ValidateMockStatement(MockStatement mock, Scope scope, EnsureTypeContext ctx) { - MockReturnsStatement seenReturns = null; + MockExitMockStatement seenReturns = null; MockForbidStatement seenForbid = null; + // Collect structure-validation findings up front (multiple returns, + // multiple forbids, returns+forbid). We still need to walk the + // body with the full visitor so locals/ifs/etc. type-check, but + // we can detect these duplicates by scanning the body shallowly. foreach (var stmt in mock.body) { switch (stmt) { - case MockReturnsStatement rs: + case MockExitMockStatement rs: if (seenReturns != null) { rs.Errors.Add(new ParseError(rs.StartToken, ErrorCodes.MockMultipleReturns)); } seenReturns = rs; - if (rs.expression != null) - { - rs.expression.EnsureVariablesAreDefined(scope, ctx); - } break; case MockForbidStatement fs: @@ -717,6 +847,168 @@ static void ValidateMockStatement(MockStatement mock, Scope scope, EnsureTypeCon fs.Errors.Add(new ParseError(fs.StartToken, ErrorCodes.MockMultipleForbid)); } seenForbid = fs; + if (fs.reason != null + && fs.reason.ParsedType.type != VariableType.String + && !fs.reason.ParsedType.unset) + { + // Type check happens after we visit the body + // (so the reason expression's ParsedType is set). + // We re-check after CheckStatements below. + } + break; + } + } + + // Push a body scope. Parameters become locals with types derived + // from the command's arg metadata. This mirrors BeginFunction: + // the body's local symbol table is independent of the test's, + // and `local` declarations inside the body add to it. + // + // Pick the overload that MATCHES the user's named param count. + // Falling back to overloads[0] would mis-type params and skip + // the ref-assignment check when overloads have different arg + // counts (e.g. `input(ref string)` vs `input(string, ref string)`). + CommandInfo? bodyOverload = null; + if (scope.commands != null + && mock.commandName != null + && scope.commands.Lookup.TryGetValue(mock.commandName, out var bodyOverloads) + && bodyOverloads.Count > 0) + { + if (mock.parameters.Count == 0) + { + bodyOverload = bodyOverloads[0]; + } + else + { + foreach (var ov in bodyOverloads) + { + var ovArgs = ov.args ?? System.Array.Empty(); + var realCount = 0; + foreach (var a in ovArgs) if (!a.isVmArg) realCount++; + if (realCount == mock.parameters.Count) + { + bodyOverload = ov; + break; + } + } + } + } + + var bodyTable = new SymbolTable(); + scope.localVariables.Push(bodyTable); + if (bodyOverload.HasValue && mock.parameters.Count > 0) + { + var args = bodyOverload.Value.args ?? System.Array.Empty(); + var realArgIndices = new List(); + for (var ai = 0; ai < args.Length; ai++) + { + if (!args[ai].isVmArg) realArgIndices.Add(ai); + } + for (var pi = 0; pi < mock.parameters.Count && pi < realArgIndices.Count; pi++) + { + var p = mock.parameters[pi]; + var argDesc = args[realArgIndices[pi]]; + var typeCode = argDesc.typeCode; + if (argDesc.isParams && typeCode == TypeCodes.ANY) + { + // `params object[]` — TypeCodes.ANY has no Fade + // variable-type mapping, and the body's gathered + // array would need per-element type storage that + // the current array model doesn't support. Surface + // a clean error here instead of letting the compiler + // crash on SIZE_TABLE[ANY]. The user can still mock + // the command; they just can't reference the args. + var cmdName = mock.commandName ?? ""; + var detail = + $"`{p.variableName}` is bound to the `params object[]` parameter of `{cmdName}`. " + + $"That parameter accepts a mix of element types at runtime, so there's no single Fade " + + $"element type the body's array could have. Rewrite as `mock {cmdName}` (no parameter name) " + + $"to install the mock without naming the args."; + p.Errors.Add(new ParseError(p, + ErrorCodes.MockParamsObjectArrayUnnamable, detail)); + } + else if (VmUtil.TryGetVariableType(typeCode, out var varType)) + { + // A params arg is bound as a rank-1 array of the + // element type so the body can `len(p)` and `p(i)`. + var paramTypeInfo = argDesc.isParams + ? TypeInfo.FromVariableType(varType, new IExpressionNode[1]) + : TypeInfo.FromVariableType(varType); + bodyTable.Add(p.variableName, new Symbol + { + text = p.variableName, + typeInfo = paramTypeInfo, + source = p + }); + } + } + } + + // Set active-mock context so any PassthroughExpression we + // encounter in the body knows what return type to wear and + // doesn't trip its outside-mock-body error. + var prevInsideMock = ctx.insideMockBody; + var prevMockReturnTc = ctx.activeMockReturnTypeCode; + var prevMockReturnInfo = ctx.activeMockReturnTypeInfo; + var prevMockArgInfos = ctx.activeMockArgInfos; + var prevMockBoundRefs = ctx.activeMockBoundRefParamNames; + ctx.insideMockBody = true; + ctx.activeMockReturnTypeCode = bodyOverload?.returnType ?? TypeCodes.VOID; + if (bodyOverload.HasValue + && bodyOverload.Value.returnType != TypeCodes.VOID + && VmUtil.TryGetVariableType(bodyOverload.Value.returnType, out var mockRetVarType)) + { + ctx.activeMockReturnTypeInfo = TypeInfo.FromVariableType(mockRetVarType); + } + else + { + ctx.activeMockReturnTypeInfo = TypeInfo.Void; + } + // Build the real-arg list (in declaration order) and the + // set of names bound to a ref param. PassthroughExpression's + // validator reads these. + if (bodyOverload.HasValue) + { + var ovArgs = bodyOverload.Value.args ?? System.Array.Empty(); + var realArgsOrdered = new List(); + var boundRefs = new HashSet(StringComparer.OrdinalIgnoreCase); + var paramIdx = 0; + for (var ai = 0; ai < ovArgs.Length; ai++) + { + if (ovArgs[ai].isVmArg) continue; + realArgsOrdered.Add(ovArgs[ai]); + if (ovArgs[ai].isRef && paramIdx < mock.parameters.Count) + { + boundRefs.Add(mock.parameters[paramIdx].variableName); + } + paramIdx++; + } + ctx.activeMockArgInfos = realArgsOrdered.ToArray(); + ctx.activeMockBoundRefParamNames = boundRefs; + } + else + { + ctx.activeMockArgInfos = System.Array.Empty(); + ctx.activeMockBoundRefParamNames = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + // Walk the body with the standard statement checker so locals, + // ifs, expression statements, asserts, etc. all get proper + // type-checking and symbol resolution. The dedicated + // MockExitMockStatement / MockForbidStatement / MockRefStatement + // cases below resolve their internal expressions; the generic + // dispatch handles the rest. + foreach (var stmt in mock.body) + { + switch (stmt) + { + case MockExitMockStatement rs: + if (rs.expression != null) + { + rs.expression.EnsureVariablesAreDefined(scope, ctx); + } + break; + case MockForbidStatement fs: if (fs.reason != null) { fs.reason.EnsureVariablesAreDefined(scope, ctx); @@ -727,9 +1019,160 @@ static void ValidateMockStatement(MockStatement mock, Scope scope, EnsureTypeCon } } break; + default: + // Send single-statement lists through CheckStatements + // so it shares all the path-aware infrastructure + // (loops, defers, declarations, etc.). + var oneShot = new List { stmt }; + oneShot.CheckStatements(scope, ctx); + break; + } + } + + // `runto` is a test-flow primitive — it switches execution + // between the test body and main-program code. It has no + // sensible meaning inside a mock body (which is run when a + // command is invoked, not when the test is navigating). Catch + // any occurrence anywhere in the body tree, not just top-level, + // so wrapping in `if`/`while` doesn't sneak past the check. + foreach (var stmt in mock.body) + { + stmt.Visit(node => + { + if (node is RuntoStatement runtoNode) + { + runtoNode.Errors.Add(new ParseError(runtoNode.StartToken, + ErrorCodes.RuntoInsideMockBody)); + } + }); + } + + // Validate self-recursive calls to the mocked command — these + // get rewritten to CALL_HOST_REAL with a scope swap, so any + // ref arg's address must point into the caller's scope. The + // only body-level names that satisfy that are the mock's own + // bound ref params (their hidden ptr targets the caller). + // Any other expression at a ref slot is a hard error. + if (mock.commandName != null + && ctx.activeMockBoundRefParamNames != null) + { + var boundRefNames = ctx.activeMockBoundRefParamNames; + foreach (var stmt in mock.body) + { + stmt.Visit(node => + { + if (node is CommandStatement cs + && cs.command.name != null + && string.Equals(cs.command.name, mock.commandName, + StringComparison.OrdinalIgnoreCase)) + { + ValidateSelfRecursiveRefArgs(cs.command, cs.args, + cs.argMap, boundRefNames); + } + else if (node is CommandExpression ce + && ce.command.name != null + && string.Equals(ce.command.name, mock.commandName, + StringComparison.OrdinalIgnoreCase)) + { + ValidateSelfRecursiveRefArgs(ce.command, ce.args, + ce.argMap, boundRefNames); + } + }); + } + } + + // Resolve symbols + type on `endmock ` while the body + // scope (with params) is still pushed. + if (mock.endmockExpression != null) + { + mock.endmockExpression.EnsureVariablesAreDefined(scope, ctx); + } + + // Strict ref-arg validation: every ref param must have at least + // one top-level assignment in the body. Otherwise the caller's + // variable is left in an undefined state when the mock runs. + // `forbid` short-circuits this — the test halts before the + // caller observes anything, so no writes are needed. + if (bodyOverload.HasValue && seenForbid == null && mock.parameters.Count > 0) + { + var args = bodyOverload.Value.args ?? System.Array.Empty(); + var realArgIndices = new List(); + for (var ai = 0; ai < args.Length; ai++) + { + if (!args[ai].isVmArg) realArgIndices.Add(ai); + } + + // A self-recursive call to the mocked command inside the + // body invokes the real host, which writes through every + // ref it's passed. If the body contains such a call (at + // any nesting level) we treat every ref param as assigned + // — the user delegated the writes to the real host. The + // compiler still enforces, per call, that each ref arg + // names a bound ref param (MockBodyRefArgMustBeBoundRefParam). + var hasSelfCall = false; + var mockedName = mock.commandName; + if (mockedName != null) + { + foreach (var stmt in mock.body) + { + stmt.Visit(node => + { + if (node is CommandStatement cs + && cs.command.name != null + && string.Equals(cs.command.name, mockedName, + StringComparison.OrdinalIgnoreCase)) + { + hasSelfCall = true; + } + if (node is CommandExpression ce + && ce.command.name != null + && string.Equals(ce.command.name, mockedName, + StringComparison.OrdinalIgnoreCase)) + { + hasSelfCall = true; + } + }); + if (hasSelfCall) break; + } + } + + for (var pi = 0; pi < mock.parameters.Count && pi < realArgIndices.Count; pi++) + { + if (!args[realArgIndices[pi]].isRef) continue; + if (hasSelfCall) continue; + var paramName = mock.parameters[pi].variableName; + var assigned = false; + foreach (var stmt in mock.body) + { + if (stmt is AssignmentStatement asn + && asn.variable is VariableRefNode lhs + && string.Equals(lhs.variableName, paramName, + StringComparison.OrdinalIgnoreCase)) + { + assigned = true; + break; + } + } + if (!assigned) + { + mock.parameters[pi].Errors.Add(new ParseError(mock.parameters[pi], + ErrorCodes.MockRefParamNotAssigned)); + } } } + scope.localVariables.Pop(); + + // Restore the outer ctx now that we're done walking this + // mock body. Nested mocks aren't legal (mock is block-only at + // the top level of a test), but this still keeps the stack + // discipline tidy in case a future change allows them. + ctx.insideMockBody = prevInsideMock; + ctx.activeMockReturnTypeCode = prevMockReturnTc; + ctx.activeMockReturnTypeInfo = prevMockReturnInfo; + ctx.activeMockArgInfos = prevMockArgInfos; + ctx.activeMockBoundRefParamNames = prevMockBoundRefs; + if (seenReturns != null && seenForbid != null) { // `returns` + `forbid` in the same body is nonsensical — the @@ -748,51 +1191,129 @@ static void ValidateMockStatement(MockStatement mock, Scope scope, EnsureTypeCon // command, etc. Anything else surfaces an InvalidCast/InvalidType // error on the expression — we then translate the first such error // into a clearer MockReturnsTypeMismatch and stop. - if (scope.commands != null && mock.commandName != null && seenReturns != null) + if (scope.commands != null && mock.commandName != null + && scope.commands.Lookup.TryGetValue(mock.commandName, out var overloads) + && overloads.Count > 0) { - if (scope.commands.Lookup.TryGetValue(mock.commandName, out var overloads) - && overloads.Count > 0) + // When the user names params, at least one overload must + // have a matching non-VmArg arg count. Otherwise the mock + // can't bind cleanly and the compiler will refuse to emit + // any body (silent no-op without an error). + if (mock.parameters.Count > 0) { - var reportedTypeMismatch = false; - foreach (var overload in overloads) + var hasMatchingOverload = false; + foreach (var ov in overloads) { - var isVoid = overload.returnType == TypeCodes.VOID; - - if (isVoid) - { - // `returns` against any void overload is illegal. - // One error per mock is enough — break out. - seenReturns.Errors.Add(new ParseError(seenReturns.StartToken, - ErrorCodes.MockReturnsOnVoidCommand)); - break; - } + var ovArgs = ov.args ?? System.Array.Empty(); + var realCount = 0; + foreach (var a in ovArgs) if (!a.isVmArg) realCount++; + if (realCount == mock.parameters.Count) { hasMatchingOverload = true; break; } + } + if (!hasMatchingOverload) + { + mock.Errors.Add(new ParseError( + mock.commandNameToken ?? mock.StartToken, + ErrorCodes.MockParamCountNoMatchingOverload)); + } + } - if (seenReturns.expression == null - || seenReturns.expression.ParsedType.unset) continue; + // Strict body validation: a value-returning command's mock + // body must produce a return value via one of three paths: + // - exitmock somewhere in the body (top level) + // - endmock as the closing form (fall-through) + // - forbid (the test halts before the caller observes the + // missing return) + // Without one of these the caller pops a return value that + // was never pushed → stack corruption at the call site. + if (seenReturns == null && seenForbid == null && mock.endmockExpression == null) + { + var anyValueReturning = false; + foreach (var ov in overloads) + { + if (ov.returnType != TypeCodes.VOID) { anyValueReturning = true; break; } + } + if (anyValueReturning) + { + mock.Errors.Add(new ParseError(mock.StartToken ?? mock.commandNameToken, + ErrorCodes.MockValueCommandMissingReturns)); + } + } - if (!TypeInfo.TryGetFromTypeCode(overload.returnType, out var expectedType)) - { - continue; - } + // Return-type checks against each overload. Apply to both + // `exitmock ` (seenReturns) and `endmock ` + // (mock.endmockExpression). Each one must satisfy the + // command's return type or — if the command is void — not + // appear at all. + CheckMockReturnAgainstOverloads(seenReturns?.expression, + seenReturns?.StartToken, overloads, scope); + CheckMockReturnAgainstOverloads(mock.endmockExpression, + mock.endmockExpression?.StartToken, overloads, scope); + } + } - // Probe assignability without committing errors to - // the real expression node — call into the same path - // the declaration init uses, on a throwaway node. If - // it flags any errors, the types are incompatible. - var probe = new ProbeNode(); - scope.EnforceTypeAssignment(probe, - seenReturns.expression.ParsedType, expectedType, - softLeft: false, out _); - if (probe.Errors.Count > 0 && !reportedTypeMismatch) - { - seenReturns.expression.Errors.Add(new ParseError( - seenReturns.expression, - ErrorCodes.MockReturnsTypeMismatch)); - reportedTypeMismatch = true; - break; - } + // For a self-recursive call inside a mock body, each ref-position + // user arg must be a VariableRefNode naming one of the mock's + // bound ref params. Anything else would push a pointer that the + // CALL_HOST_REAL scope swap can't make sense of (a body-local + // address means "register N in body scope"; after the swap that + // address indexes the wrong scope and would clobber unrelated + // data). Skip non-ref slots — they're plain expressions and the + // standard command-arg checks cover them. + static void ValidateSelfRecursiveRefArgs(CommandInfo command, + List args, List argMap, + HashSet boundRefNames) + { + if (command.args == null) return; + var argCounter = 0; + for (var i = 0; i < command.args.Length; i++) + { + if (command.args[i].isVmArg) continue; + if (command.args[i].isParams) break; + if (argCounter >= args.Count) break; + if (command.args[i].isRef) + { + var userExpr = args[argCounter]; + if (!(userExpr is VariableRefNode vn) + || boundRefNames == null + || !boundRefNames.Contains(vn.variableName)) + { + userExpr.Errors.Add(new ParseError(userExpr, + ErrorCodes.MockBodyRefArgMustBeBoundRefParam)); } } + argCounter++; + } + } + + // Helper: validate a single return-expression (from exitmock or + // endmock) against every overload of the mocked command. Adds the + // appropriate error to the expression node on the first mismatch. + static void CheckMockReturnAgainstOverloads( + IExpressionNode returnExpr, Token reportToken, + List overloads, Scope scope) + { + if (returnExpr == null) return; + foreach (var overload in overloads) + { + if (overload.returnType == TypeCodes.VOID) + { + returnExpr.Errors.Add(new ParseError(reportToken ?? returnExpr.StartToken, + ErrorCodes.MockReturnsOnVoidCommand)); + return; + } + if (returnExpr.ParsedType.unset) continue; + if (!TypeInfo.TryGetFromTypeCode(overload.returnType, out var expectedType)) continue; + + var probe = new ProbeNode(); + scope.EnforceTypeAssignment(probe, + returnExpr.ParsedType, expectedType, + softLeft: false, out _); + if (probe.Errors.Count > 0) + { + returnExpr.Errors.Add(new ParseError(returnExpr, + ErrorCodes.MockReturnsTypeMismatch)); + return; + } } } @@ -1218,6 +1739,24 @@ public static void EnsureVariablesAreDefined(this IExpressionNode expr, Scope sc // CommandNameTree pass; nothing further to do. callCountExpr.ParsedType = TypeInfo.Int; break; + case LenExpression lenExpr: + // `len(...)` always evaluates to an int. Resolve inner + // expression first so its ParsedType is set, then + // validate it's array- or string-typed. + lenExpr.ParsedType = TypeInfo.Int; + if (lenExpr.inner != null) + { + lenExpr.inner.EnsureVariablesAreDefined(scope, ctx); + var innerType = lenExpr.inner.ParsedType; + if (!innerType.unset + && !innerType.IsArray + && innerType.type != VariableType.String) + { + lenExpr.inner.Errors.Add(new ParseError(lenExpr.inner, + ErrorCodes.LenInvalidType)); + } + } + break; default: break; } @@ -1230,6 +1769,28 @@ public class EnsureTypeContext public HashSet functionHistory = new HashSet(); public bool HasLoop { get; private set; } + // Set while walking the body of a `mock` statement. Drives: + // - PassthroughExpression validation: passthrough outside a mock + // body is an error. + // - PassthroughExpression ParsedType: set to the active mock + // command's return type so callers like `r = passthrough` get + // the right type info. + public bool insideMockBody; + public byte activeMockReturnTypeCode; + public TypeInfo activeMockReturnTypeInfo; + + // Active mock command's full (non-VmArg) arg metadata, in + // declaration order. PassthroughExpression uses this to validate + // explicit `passthrough(...)` arg count + per-position kind + // (value / ref / params). + public CommandArgInfo[] activeMockArgInfos; + + // Names of the mock's bound REF parameters. A ref argument in + // `passthrough(...)` must name one of these — otherwise we'd + // hand the real command a ptr that doesn't actually target the + // caller's scope and the writeback would land in the wrong place. + public HashSet activeMockBoundRefParamNames; + public EnsureTypeContext WithFunction(FunctionStatement function) { var names = new HashSet(functionHistory); diff --git a/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs b/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs index 74b0280..998cb77 100644 --- a/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs +++ b/FadeBasic/FadeBasic/Ast/Visitors/TestScopeStrictnessVisitor.cs @@ -32,12 +32,179 @@ public static void EnforceStrictTestScopes(this ProgramNode program) // Users who want test-visibility for shared variables should use `global`.) ComputeFunctionInternalScopeAts(program, globalNames, scopeAt, allTopLevelNames); + // Build a lookup from test name to TestNode (case-insensitive). + // Used to resolve `from ` and to walk the from-chain when + // computing scope inheritance. + var testsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var test in program.tests) { - ValidateTest(test, scopeAt, globalNames, allTopLevelNames); + if (test.name != null) + { + testsByName[test.name] = test; + } + } + + // Flag unknown-parent and cycle errors up front. A test in a + // broken chain still gets its body validated (so the user can + // see all relevant errors at once), but with NO parent state + // seeded — otherwise we'd risk infinite walks or stale data. + var inBrokenChain = DetectFromChainErrors(program.tests, testsByName); + + // Topological order by from-chain: parents always validate + // before children. Tests not in a chain order as-encountered. + // We compute each test's end-state (visible set, last runto + // target, test-locals, test-functions) during ValidateTest so + // descendants can pick it up. + var endStates = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ordered = TopologicalOrderByFromChain(program.tests, testsByName, inBrokenChain); + + foreach (var test in ordered) + { + TestEndState parentState = null; + if (test.fromParent != null + && !inBrokenChain.Contains(test.name ?? "") + && endStates.TryGetValue(test.fromParent, out var foundParentState)) + { + parentState = foundParentState; + } + + var endState = ValidateTest(test, scopeAt, globalNames, + allTopLevelNames, parentState); + if (test.name != null) + { + endStates[test.name] = endState; + } } } + // Snapshot of a test's final scope state at the end of its body — + // what a child should see as its starting visibility/locals when it + // inherits via `from`. Test-locals and test-functions piggyback + // because runtime register sharing already makes them physically + // present in the child's run; we just need the visitor to know + // they're visible to keep static checks aligned. + private sealed class TestEndState + { + public HashSet Visible; + public string LastRuntoTarget; + public HashSet TestLocals; + public HashSet TestFunctions; + } + + // Walk the from-chain graph once: report unknown parents and + // cycles. Return the set of test names that are in a broken chain + // so the per-test validator can skip parent-state inheritance for + // them. We still validate their bodies so the user gets a complete + // error picture in one pass. + private static HashSet DetectFromChainErrors( + List tests, + Dictionary testsByName) + { + var broken = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var test in tests) + { + if (test.fromParent == null) continue; + if (test.name == null) continue; + + // Unknown parent — single, decisive error. + if (!testsByName.ContainsKey(test.fromParent)) + { + test.Errors.Add(new ParseError( + test.fromParentToken ?? test.startToken, + ErrorCodes.TestFromParentUnknown, test.fromParent)); + broken.Add(test.name); + continue; + } + + // Cycle check: walk up the chain from this test, marking + // visited names. Re-encountering one means a cycle includes + // this test (or an ancestor of it). + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var cursor = test; + while (cursor != null) + { + if (cursor.name == null) break; + if (!visited.Add(cursor.name)) + { + // Cycle — flag every test we passed through. Pin + // the error on the test that triggered the walk + // (the user-facing one) so the message is local. + test.Errors.Add(new ParseError( + test.fromParentToken ?? test.startToken, + ErrorCodes.TestFromParentCycle, test.fromParent)); + broken.Add(test.name); + break; + } + if (cursor.fromParent == null) break; + if (!testsByName.TryGetValue(cursor.fromParent, out var nextCursor)) + { + break; // unknown parent handled above for the originator + } + cursor = nextCursor; + } + } + return broken; + } + + // Kahn-style topological sort by from-chain. Tests with no parent + // (or with a broken chain) come first. Children come after their + // parents. Cycle members appear in `broken` — they're placed at + // the end with no parent-state inheritance to avoid infinite walks. + private static List TopologicalOrderByFromChain( + List tests, + Dictionary testsByName, + HashSet broken) + { + var result = new List(tests.Count); + var emitted = new HashSet(StringComparer.OrdinalIgnoreCase); + + bool TryEmit(TestNode t) + { + if (t.name == null) return false; + if (emitted.Contains(t.name)) return true; + if (t.fromParent == null + || broken.Contains(t.name) + || !testsByName.TryGetValue(t.fromParent, out var parent)) + { + result.Add(t); + emitted.Add(t.name); + return true; + } + if (!emitted.Contains(parent.name)) return false; + result.Add(t); + emitted.Add(t.name); + return true; + } + + // Iterate until no progress — at most O(tests^2), trivial for + // realistic test counts. + var pending = new List(tests); + var madeProgress = true; + while (pending.Count > 0 && madeProgress) + { + madeProgress = false; + for (var i = pending.Count - 1; i >= 0; i--) + { + if (TryEmit(pending[i])) + { + pending.RemoveAt(i); + madeProgress = true; + } + } + } + // Anything still pending is in a chain with a cycle we already + // flagged — append without parent-state inheritance. + foreach (var t in pending) + { + if (t.name != null && !emitted.Contains(t.name)) + { + result.Add(t); + emitted.Add(t.name); + } + } + return result; + } + private static void ComputeFunctionInternalScopeAts( ProgramNode program, HashSet globalNames, @@ -212,22 +379,37 @@ private static void WalkStatements( } } - private static void ValidateTest( + private static TestEndState ValidateTest( TestNode test, Dictionary> scopeAt, HashSet globalNames, - HashSet allTopLevelNames) + HashSet allTopLevelNames, + TestEndState parentState) { var testProgram = test.testProgram; - var testLocals = new HashSet(StringComparer.OrdinalIgnoreCase); - var testFunctions = new HashSet( - testProgram.functions.Select(f => f.name), - StringComparer.OrdinalIgnoreCase); - // Visible program-scope names. Starts with globals only (pre-runto). - // Updated to scope_at(target) when a runto is encountered. - var visible = new HashSet(globalNames, StringComparer.OrdinalIgnoreCase); - string currentRuntoTarget = null; + // Seed test-locals and test-functions from the parent so the + // child's body can reference names the parent declared. The + // runtime makes them physically available (shared compile/run + // scope + GOSUB launcher), and we mirror that visibility here. + var testLocals = parentState != null + ? new HashSet(parentState.TestLocals, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + var testFunctions = parentState != null + ? new HashSet(parentState.TestFunctions, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var fn in testProgram.functions) + { + testFunctions.Add(fn.name); + } + + // Visible program-scope names. Inherited from parent when + // available so any names the parent unlocked via runto are + // already in view at the child's first statement. + var visible = parentState != null + ? new HashSet(parentState.Visible, StringComparer.OrdinalIgnoreCase) + : new HashSet(globalNames, StringComparer.OrdinalIgnoreCase); + string currentRuntoTarget = parentState?.LastRuntoTarget; void VisitStatement(IStatementNode stmt) { @@ -488,6 +670,14 @@ void Walk(IAstVisitable node) { VisitStatement(stmt); } + + return new TestEndState + { + Visible = visible, + LastRuntoTarget = currentRuntoTarget, + TestLocals = testLocals, + TestFunctions = testFunctions + }; } } } diff --git a/FadeBasic/FadeBasic/Errors.cs b/FadeBasic/FadeBasic/Errors.cs index 573356d..1de3c01 100644 --- a/FadeBasic/FadeBasic/Errors.cs +++ b/FadeBasic/FadeBasic/Errors.cs @@ -237,14 +237,31 @@ public static class ErrorCodes public static readonly ErrorCode TestRuntoShadowsLocal = "[0190] runto brings a program-scope variable into view that conflicts with a test-local of the same name"; public static readonly ErrorCode AssertReasonMissingExpression = "[0191] assert reason clause (after `,`) requires a string expression"; public static readonly ErrorCode AssertReasonMustBeString = "[0192] assert reason expression must be a string"; - public static readonly ErrorCode MockReturnsMissingExpression = "[0193] `returns` in a mock body requires an expression"; + public static readonly ErrorCode MockReturnsMissingExpression = "[0193] `exitmock` requires an expression"; public static readonly ErrorCode MockForbidReasonMustBeString = "[0194] forbid reason expression must be a string"; - public static readonly ErrorCode MockReturnsOnVoidCommand = "[0195] `returns` in a mock body is not allowed when the command has no return value"; - public static readonly ErrorCode MockReturnsTypeMismatch = "[0196] mock `returns` expression type does not match the command's return type"; - public static readonly ErrorCode MockMultipleReturns = "[0197] mock body has multiple `returns` statements; only one is allowed"; + public static readonly ErrorCode MockReturnsOnVoidCommand = "[0195] `exitmock`/`endmock ` is not allowed when the command has no return value"; + public static readonly ErrorCode MockReturnsTypeMismatch = "[0196] mock return-value expression type does not match the command's return type"; + public static readonly ErrorCode MockMultipleReturns = "[0197] mock body has multiple `exitmock` statements; only one is allowed"; public static readonly ErrorCode MockMultipleForbid = "[0198] mock body has multiple `forbid` statements; only one is allowed"; public static readonly ErrorCode MockReturnsAndForbid = "[0199] mock body cannot mix `returns` and `forbid`"; + public static readonly ErrorCode MockValueCommandMissingReturns = "[0252] mock body for a value-returning command must contain `exitmock`, `endmock `, or `forbid`"; + public static readonly ErrorCode MockRefParamNotAssigned = "[0253] ref parameter must be assigned in the mock body — the caller's variable is left undefined otherwise"; + public static readonly ErrorCode RuntoInsideMockBody = "[0257] `runto` is a test-control statement and cannot appear inside a mock body"; + public static readonly ErrorCode MockParamsMissingCloseParen = "[0258] mock parameter list opened with `(` is missing its closing `)`"; + public static readonly ErrorCode MockParamCountNoMatchingOverload = "[0259] mock parameter count does not match any overload of the command"; + public static readonly ErrorCode ParamsCannotMixArrayWithInline = "[0260] cannot mix an array spread with inline values at the same `params` position"; + public static readonly ErrorCode ParamsArrayMustBeRankOne = "[0261] only single-dimensional arrays can be spread into a `params` arg"; + public static readonly ErrorCode ParamsArrayElementTypeMismatch = "[0262] array element type does not match the `params` arg's element type"; + public static readonly ErrorCode LenMissingParens = "[0263] `len` requires parentheses around its argument"; + public static readonly ErrorCode LenMissingExpression = "[0264] `len(...)` requires an array or string expression inside"; + public static readonly ErrorCode LenMissingCloseParen = "[0265] `len(` is missing its closing `)`"; + public static readonly ErrorCode LenInvalidType = "[0266] `len` only accepts array or string expressions"; public static readonly ErrorCode CallCountMissingCommand = "[0251] `call count` must be followed by a command name"; + public static readonly ErrorCode MockBodyRefArgMustBeBoundRefParam = "[0267] inside a mock body, a self-recursive call to the mocked command must pass one of the mock's bound ref parameters at each ref position"; + public static readonly ErrorCode TestFromParentUnknown = "[0269] `test ... from ` references a parent test that does not exist"; + public static readonly ErrorCode TestFromParentCycle = "[0270] `from`-chain forms a cycle — a test cannot transitively inherit from itself"; + public static readonly ErrorCode TestDuplicateName = "[0271] another test with this name already exists; test names must be unique within a program"; + public static readonly ErrorCode MockParamsObjectArrayUnnamable = "[0268] cannot bind a name to a `params object[]` argument in a mock body"; // 200 series represents post-parse issues public static readonly ErrorCode InvalidReference = "[0200] Invalid reference"; diff --git a/FadeBasic/FadeBasic/Lexer.cs b/FadeBasic/FadeBasic/Lexer.cs index 64b5e6a..dab4c93 100644 --- a/FadeBasic/FadeBasic/Lexer.cs +++ b/FadeBasic/FadeBasic/Lexer.cs @@ -76,11 +76,12 @@ public enum LexemType KeywordAssert, KeywordMock, KeywordEndMock, - KeywordReturns, + KeywordExitMock, KeywordForbid, KeywordClear, KeywordMocks, KeywordCallCount, + KeywordLen, KeywordAs, KeywordTypeInteger, @@ -262,13 +263,14 @@ public class Lexer new Lexem(LexemType.KeywordEndMock, new Regex("^endmock\\b")), new Lexem(LexemType.KeywordMocks, new Regex("^mocks\\b")), new Lexem(LexemType.KeywordMock, new Regex("^mock\\b")), - new Lexem(LexemType.KeywordReturns, new Regex("^returns\\b")), + new Lexem(LexemType.KeywordExitMock, new Regex("^exitmock\\b")), new Lexem(LexemType.KeywordForbid, new Regex("^forbid\\b")), new Lexem(LexemType.KeywordClear, new Regex("^clear\\b")), // Multi-word keyword: `call count`. Higher priority (-2) so it // matches before VariableGeneral; users who write `call` alone // (or `call somethingElse`) still get a VariableGeneral token. new Lexem(-2, LexemType.KeywordCallCount, new Regex("^call[ \\t]+count\\b")), + new Lexem(-2, LexemType.KeywordLen, new Regex("^len\\b")), new Lexem(LexemType.KeywordGoto, new Regex("^goto")), new Lexem(LexemType.KeywordGoSub, new Regex("^gosub")), @@ -805,6 +807,10 @@ void HandleCommandNames(string[] lines, List tokens, CommandNameTree tree for (var i = 0; i < tokens.Count; i++) { var token = tokens[i]; + // Don't rewrite a token that's already been tagged as a + // language keyword. Words like `len` collide with legacy + // host commands but the keyword wins. + if (token.type == LexemType.KeywordLen) continue; var curr = tree; var j = i; while (tokens[j].caseInsensitiveRaw != null && curr.sub.TryGetValue(tokens[j].caseInsensitiveRaw, out var next)) diff --git a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs index bb6841a..e3238c3 100644 --- a/FadeBasic/FadeBasic/Lsp/LSPUtil.cs +++ b/FadeBasic/FadeBasic/Lsp/LSPUtil.cs @@ -120,11 +120,12 @@ static PortableSemanticTokenType ClassifyLexemType(Token token) case LexemType.KeywordMock: case LexemType.KeywordEndMock: case LexemType.KeywordMocks: - case LexemType.KeywordReturns: + case LexemType.KeywordExitMock: case LexemType.KeywordForbid: case LexemType.KeywordClear: case LexemType.KeywordCallCount: case LexemType.KeywordMaxCycles: + case LexemType.KeywordLen: return PortableSemanticTokenType.Keyword; case LexemType.KeywordType: diff --git a/FadeBasic/FadeBasic/Parser.cs b/FadeBasic/FadeBasic/Parser.cs index c81faca..fe1d204 100644 --- a/FadeBasic/FadeBasic/Parser.cs +++ b/FadeBasic/FadeBasic/Parser.cs @@ -807,10 +807,49 @@ public void ValidateCommandArgs(CommandInfo command, List args, { var arg = args[argIndex]; var descriptor = command.args[argMap[argIndex]]; - - + arg.EnsureVariablesAreDefined(this, ctx); + // Special case: a single array-typed expression at a + // `params` arg position spreads the array onto the stack + // at compile time. Accept it here without the per-element + // type check; the compiler emits SPREAD_ARRAY. Mixing array + // with inline values at the same params position is an + // error. + if (descriptor.isParams && arg.ParsedType.IsArray) + { + // Count how many args map to this same descriptor index. + var sameDescriptorCount = 0; + for (var j = 0; j < argMap.Count; j++) + { + if (argMap[j] == argMap[argIndex]) sameDescriptorCount++; + } + if (sameDescriptorCount > 1) + { + arg.Errors.Add(new ParseError(arg, + ErrorCodes.ParamsCannotMixArrayWithInline)); + continue; + } + if (arg.ParsedType.rank != 1) + { + arg.Errors.Add(new ParseError(arg, + ErrorCodes.ParamsArrayMustBeRankOne)); + continue; + } + // `params object[]` (TypeCodes.ANY) accepts any element + // type — same tolerance the inline-arg path already + // grants below. Without this, `print x$` where x$ is a + // string array trips a 0262 mismatch. + if (descriptor.typeCode != TypeCodes.ANY + && arg.ParsedType.type != ConvertTypeCodeToVariableType(descriptor.typeCode)) + { + arg.Errors.Add(new ParseError(arg, + ErrorCodes.ParamsArrayElementTypeMismatch)); + continue; + } + continue; + } + if (TypeInfo.TryGetFromTypeCode(descriptor.typeCode, out var guessType)) { this.EnforceTypeAssignment(arg, arg.ParsedType, guessType, false, out _); @@ -829,9 +868,21 @@ public void ValidateCommandArgs(CommandInfo command, List args, err.message = err.message.Substring(0, err.message.Length - replace.Length ) + "any"; } } - + } } + + // Helper used by params-array validation: a TypeCode (the byte form + // commands use) → the AST VariableType. Returns Void for unmapped + // codes, which won't match a real array element type so still errors. + private static VariableType ConvertTypeCodeToVariableType(byte typeCode) + { + if (TypeInfo.TryGetFromTypeCode(typeCode, out var info)) + { + return info.type; + } + return VariableType.Void; + } public void AddCommand(CommandInfo command, List args, List argMap, EnsureTypeContext ctx) { @@ -1669,6 +1720,20 @@ IStatementNode Inner() return ParseDimStatement(token); case LexemType.KeywordReDimArray: return ParseRedimStatement(token); + case LexemType.KeywordLen: + { + // `len()` at statement level — value is + // discarded but the form is still legal (matches + // older `len(...)` host-command tests). Put the + // token back so the standard expression parser + // picks it up via its KeywordLen case. + _stream.Restore(_stream.Save() - 1); + if (TryParseExpression(out var lenAsExpr)) + { + return new ExpressionStatement(lenAsExpr); + } + return new NoOpStatement(); + } case LexemType.VariableReal: case LexemType.VariableString: case LexemType.VariableGeneral: @@ -3431,7 +3496,44 @@ private MockStatement ParseMock(Token mockToken) return stmt; } - // Drain end-of-statement separators between the name and the body. + // Optional parameter-name list — `mock find pattern, list` binds + // the command's args to locals named `pattern` and `list` inside + // the body. Two surface forms are accepted: + // • bare: `mock find pattern, list` + // • parens: `mock find(pattern, list)` + // Names are space- or comma-separated. A newline ends the bare + // form; the close paren ends the parens form. Names can be any + // variable token shape (general identifier, `s$` for string, + // `f#` for float) — the param's TYPE comes from the command's + // metadata, not the suffix; the suffix is just naming style. + var inParens = _stream.Peek.type == LexemType.ParenOpen; + if (inParens) _stream.Advance(); + while (IsMockParamToken(_stream.Peek.type) + || _stream.Peek.type == LexemType.ArgSplitter) + { + if (_stream.Peek.type == LexemType.ArgSplitter) + { + _stream.Advance(); + continue; + } + var paramToken = _stream.Advance(); + stmt.parameters.Add(new VariableRefNode(paramToken)); + stmt.endToken = paramToken; + } + if (inParens) + { + if (_stream.Peek.type == LexemType.ParenClose) + { + stmt.endToken = _stream.Advance(); + } + else + { + stmt.Errors.Add(new ParseError(_stream.Peek, + ErrorCodes.MockParamsMissingCloseParen)); + } + } + + // Drain end-of-statement separators between the name/params and the body. while (_stream.Peek.type == LexemType.EndStatement) _stream.Advance(); // Body: `endtest`, `test`, `abstract`, and EOF are hard boundaries @@ -3457,15 +3559,34 @@ private MockStatement ParseMock(Token mockToken) break; case LexemType.KeywordEndMock: + { stmt.endToken = next; _stream.Advance(); + // Optional fall-through return expression — `endmock + // ` matches `endfunction `. When the body + // reaches its closing `endmock` without an earlier + // `exitmock`, this expression becomes the return. + if (!IsMockBodyTerminator(_stream.Peek.type)) + { + var saved = _stream.Save(); + if (TryParseExpression(out var endExpr)) + { + stmt.endmockExpression = endExpr; + stmt.endToken = endExpr.EndToken; + } + else + { + _stream.Restore(saved); + } + } looking = false; break; + } - case LexemType.KeywordReturns: + case LexemType.KeywordExitMock: { var head = _stream.Advance(); - var rs = new MockReturnsStatement(head, head); + var rs = new MockExitMockStatement(head, head); if (TryParseExpression(out var expr)) { rs.expression = expr; @@ -3506,11 +3627,17 @@ private MockStatement ParseMock(Token mockToken) default: { - // Unknown token in mock body — flag and skip one - // token to recover. Phase B will broaden what's - // accepted here (arbitrary test-block statements). - var bad = _stream.Advance(); - stmt.Errors.Add(new ParseError(bad, ErrorCodes.MockEntryRequiresReturnsOrForbid)); + // Any test-block-legal statement is accepted inside + // the mock body (locals, ifs, asserts, static + // commands, plain assignments — including those that + // target a ref parameter for write-through). Defer + // to the generic statement parser; it'll surface its + // own errors for anything truly malformed. + var parsed = ParseStatement(stmt.body); + if (parsed != null) + { + stmt.body.Add(parsed); + } break; } } @@ -3525,13 +3652,26 @@ private static bool IsMockBodyTerminator(LexemType type) { return type == LexemType.EndStatement || type == LexemType.KeywordEndMock - || type == LexemType.KeywordReturns + || type == LexemType.KeywordExitMock || type == LexemType.KeywordForbid || type == LexemType.EOF || type == LexemType.KeywordEndTest || type == LexemType.KeywordTest; } + // True for tokens that can appear as a mock parameter name. We + // accept the three Fade variable lexem types — general identifiers + // (no suffix), `s$` style string names, and `f#` style real names. + // The param's actual type comes from the command metadata, not the + // suffix on the name; this is purely about which lexer tokens we + // accept as identifier-like. + private static bool IsMockParamToken(LexemType type) + { + return type == LexemType.VariableGeneral + || type == LexemType.VariableString + || type == LexemType.VariableReal; + } + // True when the token is a colon-induced EndStatement (same line) // rather than a newline-induced one. The lexer synthesizes newline // EndStatements with empty/null `raw`; colon ones carry `raw = ":"`. @@ -3998,6 +4138,42 @@ private bool TryParseWikiTerm(out IExpressionNode outputExpression, out ProgramR recovery = null; switch (token.type) { + case LexemType.KeywordLen: + { + // `len()` — returns array element count or string + // character count as an int. Parens are required for + // clarity (matches the BASIC family's usual `LEN(x)` + // form). The inner expression's type (array or string) + // determines element size at compile time. + var lenTok = _stream.Advance(); + if (_stream.Peek.type != LexemType.ParenOpen) + { + var badLen = new LenExpression(lenTok, lenTok, null); + badLen.Errors.Add(new ParseError(lenTok, ErrorCodes.LenMissingParens)); + outputExpression = badLen; + break; + } + _stream.Advance(); // consume `(` + if (!TryParseExpression(out var lenInner)) + { + var badLen = new LenExpression(lenTok, lenTok, null); + badLen.Errors.Add(new ParseError(lenTok, ErrorCodes.LenMissingExpression)); + outputExpression = badLen; + break; + } + if (_stream.Peek.type != LexemType.ParenClose) + { + var badLen = new LenExpression(lenTok, lenInner.EndToken, lenInner); + badLen.Errors.Add(new ParseError(lenTok, ErrorCodes.LenMissingCloseParen)); + outputExpression = badLen; + break; + } + var closeTok = _stream.Advance(); + var lenExpr = new LenExpression(lenTok, closeTok, lenInner); + lenExpr.ParsedType = TypeInfo.Int; + outputExpression = lenExpr; + break; + } case LexemType.KeywordCallCount: { // `call count ` is a single keyword followed by diff --git a/FadeBasic/FadeBasic/Virtual/Compiler.cs b/FadeBasic/FadeBasic/Virtual/Compiler.cs index 3f37926..d5286b9 100644 --- a/FadeBasic/FadeBasic/Virtual/Compiler.cs +++ b/FadeBasic/FadeBasic/Virtual/Compiler.cs @@ -536,9 +536,23 @@ public void Compile(ProgramNode program) // program's functions but before interned data. Each test gets its own // entry point recorded in the manifest. A test instance is launched via // `new VirtualMachine(program, manifest.entryPointAddress)`. + // + // Two-phase emission so `from`-chains work without duplicating body + // bytecode: phase 1 lays down each test's body region (statements + + // RETURN, then any test-scoped functions) and records its start + // address; phase 2 lays down each test's launcher region (a flat + // sequence of JUMP_HISTORY → ancestor-body, ..., JUMP_HISTORY → self- + // body, HALT) and stamps the manifest with the launcher address. + // Running a test = jump to its launcher, which GOSUBs through the + // full chain in order, sharing the VM's scope/registers/mock-table. + var testBodyAddresses = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var test in program.tests) + { + CompileTestBody(test, testBodyAddresses); + } foreach (var test in program.tests) { - CompileTest(test); + CompileTestLauncher(test, testBodyAddresses, program.tests); } // Emit the assert-unwind trampoline after tests, before interned @@ -575,21 +589,20 @@ void Visit(IAstVisitable node) } } - private void CompileTest(TestNode test) + // Phase 1: emit a test's body region (statements + RETURN), then any + // test-scoped functions. Records the body's start address in + // `bodyAddresses` so phase 2 launchers can GOSUB to it. Manifest + // entry is added in phase 2 (so it points at the launcher, not the + // body) — but we tag the body's start instruction for debugger + // function-name resolution here. + private void CompileTestBody(TestNode test, + Dictionary bodyAddresses) { - // Abstract tests still produce bytecode (they may be `from`-parents of - // concrete tests, which Stage 8 will leverage), but they don't appear - // as runnable manifest entries. - var entryPoint = _buffer.Count; - _testManifest.Add(new TestManifestEntry + var bodyStart = _buffer.Count; + if (test.name != null) { - name = test.name, - entryPointAddress = entryPoint, - isAbstract = test.isAbstract, - fromParent = test.fromParent, - sourceLine = test.startToken?.lineNumber ?? 0, - sourceChar = test.startToken?.charNumber ?? 0 - }); + bodyAddresses[test.name] = bodyStart; + } // Compile the test body. The dispatch in Compile(IStatementNode) skips // FunctionStatement nodes — they're emitted separately below — so the @@ -600,9 +613,19 @@ private void CompileTest(TestNode test) Compile(statement); } - // Halt at the end of the test body so execution doesn't fall into - // whatever follows in the bytecode blob. - CompileEnd(); + // RETURN instead of HALT: when invoked via the launcher's + // JUMP_HISTORY, this returns control so the next ancestor-or- + // self body in the chain can run. + // + // DEFER drains here are intentionally OMITTED. A `from`-child + // is semantically a continuation of its parent — parent's + // teardown (defer) statements should fire at the END of the + // chain, after the child's body, not at parent's RETURN. + // Defers register on the shared deferredJumps stack; the + // launcher's CompileEnd() drains them once after every body + // in the chain has run. Standalone tests get identical + // behavior — the launcher's drain runs after a single body. + _buffer.Add(OpCodes.RETURN); // Now compile any test-scoped functions. They live alongside program // functions in the bytecode blob and register themselves in the @@ -615,6 +638,82 @@ private void CompileTest(TestNode test) } } + // Phase 2: emit a test's launcher region — what the manifest's + // entryPointAddress points to. Walks the from-chain (root → self, + // skipping any test whose body we don't have, which covers + // chain-broken cases the visitor already errored on) and emits a + // JUMP_HISTORY → body for each, finishing with a HALT. + // + // Each ancestor's body ends with RETURN, popping the launcher's + // pushed return frame so the next JUMP_HISTORY fires. State + // (registers, mock table, runto position) flows naturally between + // segments because they share the same VM context. + private void CompileTestLauncher(TestNode test, + Dictionary bodyAddresses, + List allTests) + { + var launcherStart = _buffer.Count; + _testManifest.Add(new TestManifestEntry + { + name = test.name, + entryPointAddress = launcherStart, + isAbstract = test.isAbstract, + fromParent = test.fromParent, + sourceLine = test.startToken?.lineNumber ?? 0, + sourceChar = test.startToken?.charNumber ?? 0 + }); + + var chain = ResolveTestFromChain(test, allTests); + foreach (var member in chain) + { + if (!bodyAddresses.TryGetValue(member.name ?? "", out var bodyAddr)) + { + // No body recorded — likely a cycle-broken test the + // visitor already flagged. Skip it to avoid an unresolved + // GOSUB target. + continue; + } + AddPushInt(_buffer, bodyAddr); + _buffer.Add(OpCodes.JUMP_HISTORY_LAUNCH); + } + CompileEnd(); + } + + // Walk a test's from-chain from root to self. Bail with just + // [self] if we detect a cycle so we don't loop forever; the + // visitor's TestFromParentCycle error tells the user what's wrong. + // Unknown parents are similarly cut off — the chain stops where + // the name fails to resolve. + private List ResolveTestFromChain(TestNode test, + List allTests) + { + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var t in allTests) + { + if (t.name != null) byName[t.name] = t; + } + + var chain = new List { test }; + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + if (test.name != null) visited.Add(test.name); + + var cursor = test; + while (cursor.fromParent != null + && byName.TryGetValue(cursor.fromParent, out var parent)) + { + if (parent.name != null && !visited.Add(parent.name)) + { + // Cycle — bail. The chain we have so far isn't useful + // (we'd loop), so prefer "self only" semantics. + return new List { test }; + } + chain.Add(parent); + cursor = parent; + } + chain.Reverse(); // root first + return chain; + } + public void CompileJumpReplacements() { @@ -888,6 +987,12 @@ public void Compile(IStatementNode statement) case MockStatement mockStatement: Compile(mockStatement); break; + case MockExitMockStatement mockReturnsStatement: + Compile(mockReturnsStatement); + break; + case MockForbidStatement mockForbidStatement: + Compile(mockForbidStatement); + break; case ClearMockStatement clearMockStatement: Compile(clearMockStatement); break; @@ -1966,73 +2071,589 @@ private List ResolveMockCommandIds(string commandName) private void Compile(MockStatement mockStatement) { - var commandIds = ResolveMockCommandIds(mockStatement.commandName); - if (commandIds.Count == 0) + var allCommandIds = ResolveMockCommandIds(mockStatement.commandName); + if (allCommandIds.Count == 0) { // Unknown command — the lexer would normally have caught this // (CommandWord token doesn't form). Skip silently here. return; } - // Inspect the body: a mock has at most one `returns` and at most - // one `forbid` (the scope visitor enforces this). Pick the install - // opcode based on what's present: - // - forbid → MOCK_FORBID - // - returns → MOCK_RETURNS - // - empty body → MOCK_VOID (suppress the call) - // Phase B will replace this with full mock-body compilation. - MockReturnsStatement returnsStmt = null; - MockForbidStatement forbidStmt = null; - foreach (var stmt in mockStatement.body) + // Filter to overloads whose non-VmArg arg count matches the + // user-named param count. When the user gives zero names, the + // mock applies to every overload (the body's prelude pops every + // arg via DISCARD_TYPED, so any arg count is handled). + // + // Filtering is necessary because a single mock body's prelude + // is tied to one specific overload's signature — different + // overloads with different arg counts need different prelude + // bytecode, which is what the per-overload loop below emits. + var matchingIds = new List(); + foreach (var id in allCommandIds) + { + if (mockStatement.parameters.Count == 0) + { + matchingIds.Add(id); + continue; + } + var methodArgs = methodTable.methods[id].args ?? System.Array.Empty(); + var realCount = 0; + for (var ai = 0; ai < methodArgs.Length; ai++) + { + if (!methodArgs[ai].isVmArg) realCount++; + } + if (realCount == mockStatement.parameters.Count) + { + matchingIds.Add(id); + } + } + if (matchingIds.Count == 0) { - if (stmt is MockReturnsStatement rs && returnsStmt == null) returnsStmt = rs; - else if (stmt is MockForbidStatement fs && forbidStmt == null) forbidStmt = fs; + // The visitor surfaces this as a validation error; the + // compiler just bails on emitting any bytecode. + return; } - foreach (var commandId in commandIds) + // Per-overload: emit a separate body block tailored to that + // overload's signature, then install it for that overload's + // method id. Bodies share source statements but get independent + // register allocations because each body pushes its own + // CompilePushScope before binding args. + foreach (var commandId in matchingIds) { - if (forbidStmt != null) + CompileMockBodyForOverload(mockStatement, commandId); + } + } + + // Emit one mock-body block + install op for a single overload. + private void CompileMockBodyForOverload(MockStatement mockStatement, int commandId) + { + var argMethod = methodTable.methods[commandId]; + + // Skip-over JUMP so normal execution flows past this body. + var skipBodyPatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _buffer.Add(OpCodes.JUMP); + + var bodyStart = _buffer.Count; + // Register in DebugData so call-stack frames inside this body + // get a sensible function name (the mocked command's name). + if (mockStatement.commandNameToken != null) + { + _dbg?.AddFunction(bodyStart, mockStatement.commandNameToken); + } + CompilePushScope(); + + // Build the list of real (non-VmArg) arg indices for this overload. + var commandArgs = argMethod.args ?? System.Array.Empty(); + var realArgIndices = new List(); + for (var ai = 0; ai < commandArgs.Length; ai++) + { + if (!commandArgs[ai].isVmArg) realArgIndices.Add(ai); + } + + // Per-ref-param bookkeeping used to emit writebacks at every + // body exit. Independent per overload. + var prevRefMap = _activeMockRefBindings; + _activeMockRefBindings = new List(); + + // Args were pushed LIFO; pop in reverse to bind to user-named + // positions left-to-right. If the user gave no param names, + // DISCARD_TYPED keeps the stack clean. + for (var i = realArgIndices.Count - 1; i >= 0; i--) + { + var argInfo = commandArgs[realArgIndices[i]]; + var paramIndex = i; + if (paramIndex < mockStatement.parameters.Count) { - // Stack at MOCK_FORBID dispatch (bottom→top): - // reason (string), trampolineAddr (int), commandId (int) - // The reason is an empty literal when the user didn't - // supply one. The trampoline address is patched after the - // assert-unwind trampoline is emitted (same patch list). - if (forbidStmt.reason != null) + var paramRef = mockStatement.parameters[paramIndex]; + if (argInfo.isParams) { - Compile(forbidStmt.reason); + // `params object[]` (ANY) named in the mock body + // is rejected by the visitor (MockParamsObjectArrayUnnamable) + // because the gathered array would need mixed-type + // element storage. Skip the binding here so we + // don't crash on SIZE_TABLE[ANY]; the visitor's + // ParseError is what the user sees. + if (argInfo.typeCode == TypeCodes.ANY) + { + // Drain count + values off the stack so later + // bindings line up. + _buffer.Add(OpCodes.DISCARD_TYPED); // count + // We don't know how many were pushed at compile + // time, so we can't drain values without a + // loop. Bail — the program won't run correctly, + // but the user has the validation error to fix. + continue; + } + // Params arg. The caller pushed `[values..., count]` + // on the stack. Materialize a Fade single-dimensional + // array from those, bind it to a body-local that the + // user can index and pass to `len`. Shape mirrors + // `dim xs(N)` so existing array-access machinery + // works without further changes. + var paramsArrayVar = scope.CreateArray( + paramRef.variableName, rankLength: 1, + typeCode: argInfo.typeCode, isGlobal: false); + // CreateArray just sizes the rank-arrays; we still + // need to allocate distinct register slots for the + // rank size and scaler — mirrors what the regular + // `dim` codegen does (Compile(DeclarationStatement)). + paramsArrayVar.rankSizeRegisterAddresses[0] = scope.AllocateRegister(); + paramsArrayVar.rankIndexScalerRegisterAddresses[0] = scope.AllocateRegister(); + + // 1) DUPE the count so we can stash it in the + // rank-size register before GATHER consumes it. + _buffer.Add(OpCodes.DUPE); + PushStore(_buffer, paramsArrayVar.rankSizeRegisterAddresses[0], isGlobal: false); + + // 2) Rank-0 scaler is 1 for a 1-D array. + AddPushInt(_buffer, 1); + PushStore(_buffer, paramsArrayVar.rankIndexScalerRegisterAddresses[0], isGlobal: false); + + // 3) GATHER pops count + values and leaves a fresh + // PTR_HEAP on top. Store it into the array's + // main register. + _buffer.Add(OpCodes.GATHER_ARRAY); + _buffer.Add(argInfo.typeCode); + PushStorePtr(_buffer, paramsArrayVar.registerAddress, isGlobal: false); + continue; + } + if (argInfo.isRef) + { + // Ref param. Hidden ptr reg + user-visible value reg. + var hiddenPtrName = "$$mockptr_" + paramRef.variableName; + var hiddenRef = new VariableRefNode(paramRef.startToken, hiddenPtrName); + var hiddenDecl = new DeclarationStatement + { + variableNode = hiddenRef, + scopeType = DeclarationScopeType.Local, + type = new TypeReferenceNode(VariableType.Integer, paramRef.startToken) + }; + Compile(hiddenDecl); + scope.TryGetVariable(hiddenPtrName, out var hiddenPtrVar); + + _buffer.Add(OpCodes.STORE_REF); + AddPushULongNoTypeCode(_buffer, hiddenPtrVar.registerAddress); + + VmUtil.TryGetVariableType(argInfo.typeCode, out var valueType); + var valueDecl = new DeclarationStatement + { + variableNode = paramRef, + scopeType = DeclarationScopeType.Local, + type = new TypeReferenceNode(valueType, paramRef.startToken) + }; + Compile(valueDecl); + scope.TryGetVariable(paramRef.variableName, out var valueVar); + + _buffer.Add(OpCodes.LOAD_REF); + AddPushULongNoTypeCode(_buffer, hiddenPtrVar.registerAddress); + _buffer.Add(OpCodes.CAST); + _buffer.Add(argInfo.typeCode); + CompileAssignmentLeftHandSide(paramRef); + + _activeMockRefBindings.Add(new MockRefBinding + { + paramName = paramRef.variableName, + valueRegAddr = valueVar.registerAddress, + ptrRegAddr = hiddenPtrVar.registerAddress, + argTypeCode = argInfo.typeCode + }); } else { - Compile(new LiteralStringExpression(forbidStmt.startToken, "")); + VmUtil.TryGetVariableType(argInfo.typeCode, out var paramType); + var fakeDecl = new DeclarationStatement + { + variableNode = paramRef, + scopeType = DeclarationScopeType.Local, + type = new TypeReferenceNode(paramType, paramRef.startToken) + }; + Compile(fakeDecl); + _buffer.Add(OpCodes.CAST); + _buffer.Add(argInfo.typeCode); + CompileAssignmentLeftHandSide(paramRef); } - var trampolinePatchIndex = _buffer.Count; - AddPushInt(_buffer, int.MaxValue); - _assertTrampolinePatches.Add(trampolinePatchIndex); + } + else + { + _buffer.Add(OpCodes.DISCARD_TYPED); + } + } - AddPushInt(_buffer, commandId); - _buffer.Add(OpCodes.MOCK_FORBID); + // Active-mock context for nested compile of body statements. + var prevReturnTc = _activeMockReturnTypeCode; + var prevCmdName = _activeMockCommandName; + var prevHostId = _activeMockHostMethodId; + var prevParamBindings = _activeMockParamBindings; + var prevBypassIds = _activeMockBypassIds; + _activeMockReturnTypeCode = argMethod.returnType; + _activeMockCommandName = argMethod.name; + _activeMockHostMethodId = commandId; + // All overloads of the mocked command name route to real + // inside this body — gather their ids once. Compile of + // CommandStatement/CommandExpression checks this set. + _activeMockBypassIds = new HashSet( + ResolveMockCommandIds(mockStatement.commandName)); + + // Build the ordered param-binding table used by + // PassthroughExpression: one entry per real (non-VmArg) arg, + // in declaration order, paired with the mock's body-local + // name (null when the mock didn't name that position). + _activeMockParamBindings = new List(); + for (var ri = 0; ri < realArgIndices.Count; ri++) + { + var argInfo = commandArgs[realArgIndices[ri]]; + var paramName = (ri < mockStatement.parameters.Count) + ? mockStatement.parameters[ri].variableName + : null; + _activeMockParamBindings.Add(new MockParamBinding + { + paramName = paramName, + argTypeCode = argInfo.typeCode, + isRef = argInfo.isRef, + isParams = argInfo.isParams + }); + } + + foreach (var stmt in mockStatement.body) + { + Compile(stmt); + } + + // endmock fall-through return value. + if (mockStatement.endmockExpression != null) + { + Compile(mockStatement.endmockExpression); + if (_activeMockReturnTypeCode != 0 && _activeMockReturnTypeCode != TypeCodes.VOID) + { + _buffer.Add(OpCodes.CAST); + _buffer.Add(_activeMockReturnTypeCode); } - else if (returnsStmt != null) + } + + // Ref-arg writebacks before scope-pop (still need the binding map). + EmitMockRefWritebacks(); + + _activeMockReturnTypeCode = prevReturnTc; + _activeMockCommandName = prevCmdName; + _activeMockRefBindings = prevRefMap; + _activeMockHostMethodId = prevHostId; + _activeMockParamBindings = prevParamBindings; + _activeMockBypassIds = prevBypassIds; + + CompilePopScope(); + _buffer.Add(OpCodes.RETURN); + + // Patch the skip-over JUMP to land past this body. + PatchAddress(skipBodyPatchIndex, _buffer.Count); + + // Install the body for this specific overload's id. + AddPushInt(_buffer, bodyStart); + AddPushInt(_buffer, commandId); + _buffer.Add(OpCodes.MOCK_INSTALL); + } + + // The active mock's return-type code, set while compiling a mock + // body. MockExitMockStatement reads this to cast the user's return + // expression to the right shape before pushing it on the stack and + // returning. Outside a mock-body compile, this is 0 (VOID). + private byte _activeMockReturnTypeCode; + + private void Compile(MockExitMockStatement returnsStatement) + { + // `exitmock expr` inside a mock body: push the value, cast it to + // the command's declared return type, emit ref-arg writebacks, + // pop the body's scope and RETURN. The writebacks read each + // ref param's value-register (last write the user did) and + // store it back to the caller's variable via the saved ptr. + if (returnsStatement.expression != null) + { + Compile(returnsStatement.expression); + if (_activeMockReturnTypeCode != 0 && _activeMockReturnTypeCode != TypeCodes.VOID) { - AddPushInt(_buffer, commandId); - if (returnsStmt.expression != null) + _buffer.Add(OpCodes.CAST); + _buffer.Add(_activeMockReturnTypeCode); + } + } + EmitMockRefWritebacks(); + CompilePopScope(); + _buffer.Add(OpCodes.RETURN); + } + + private void Compile(MockForbidStatement forbidStatement) + { + // `forbid [reason]` inside a mock body: shape-compatible with + // ASSERT_FAIL. The body is already running inside a pushed scope + // (set up by the mock body prelude); the trampoline drains + // defers across every live scope and halts the test. + // + // Stack at ASSERT_FAIL (bottom→top): + // reason, sourceText, trampolineAddr. + if (forbidStatement.reason != null) + { + Compile(forbidStatement.reason); + } + else + { + Compile(new LiteralStringExpression(forbidStatement.startToken, "")); + } + // Synthesize a sourceText that names the command being forbidden. + // Walk up to the enclosing MockStatement for the name; if we + // somehow have none, fall back to a generic message. + var cmdName = _activeMockCommandName ?? ""; + Compile(new LiteralStringExpression(forbidStatement.startToken, + "forbidden command was called: " + cmdName)); + var trampolinePatchIndex = _buffer.Count; + AddPushInt(_buffer, int.MaxValue); + _assertTrampolinePatches.Add(trampolinePatchIndex); + _buffer.Add(OpCodes.ASSERT_FAIL); + } + + // Command name of the mock currently being compiled. Read by + // MockForbidStatement so the failure message names the command. + private string _activeMockCommandName; + + // Per-ref-param bookkeeping for the active mock-body compile. + // Populated by the body prelude with one entry per ref parameter, + // then read at every exit site (exitmock, endmock fall-through) to + // emit the writeback sequence. Empty when the active mock has no + // ref params (or when we're not inside a mock body). + private List _activeMockRefBindings; + + // Emit ref writebacks for every ref param in the current mock. + // Called from each body exit point: pushes nothing net onto the + // stack (each writeback loads the value reg and consumes it via + // WRITE_REF). Order is irrelevant — each binding writes to a + // distinct caller register. + private void EmitMockRefWritebacks() + { + if (_activeMockRefBindings == null) return; + foreach (var binding in _activeMockRefBindings) + { + // Load the value-register's current value. + PushLoad(_buffer, binding.valueRegAddr, isGlobal: false); + // CAST to the ref's underlying type — defensive in case the + // user did any unusual arithmetic that widened the type. + _buffer.Add(OpCodes.CAST); + _buffer.Add(binding.argTypeCode); + // Write through the saved pointer to the caller's register. + _buffer.Add(OpCodes.WRITE_REF); + AddPushULongNoTypeCode(_buffer, binding.ptrRegAddr); + } + } + + struct MockRefBinding + { + public string paramName; + // Body-local register holding the value the user reads/writes. + // Typed as the arg's base type (int / float / etc). + public ulong valueRegAddr; + // Hidden body-local register holding the typed caller pointer + // (PTR_REG or PTR_GLOBAL_REG). Used by WRITE_REF at writeback. + public ulong ptrRegAddr; + // The command arg's underlying TypeCode (e.g. TypeCodes.INTEGER). + public byte argTypeCode; + } + + // Per-arg binding info for the currently-compiling mock body — one + // entry per real (non-VmArg) command arg, in declaration order. + // PassthroughExpression iterates these to re-construct the call. + struct MockParamBinding + { + public string paramName; + public byte argTypeCode; + public bool isRef; + public bool isParams; + } + + // Host-method id of the command currently being mocked. Read by + // PassthroughExpression to emit the CALL_HOST_REAL target. Zero + // outside a mock body. + private int _activeMockHostMethodId; + + // Ordered list of bindings for the active mock body. Index matches + // the order in which args are pushed at a normal call site (which + // is also the order CALL_HOST_REAL expects). + private List _activeMockParamBindings; + + // `passthrough` inside a mock body — re-pushes the body's currently + // bound argument values, then dispatches to the real underlying + // command via CALL_HOST_REAL (which bypasses the mock table). + // Leaves the real command's return value (if any) on the stack; + // when used as a statement, the wrapping ExpressionStatement + // emits a DISCARD_TYPED. + // + // Args are re-built from body-locals in declaration order: + // - value: LOAD + CAST + // - ref: flush user-side write through hidden ptr (LOAD val, + // CAST, WRITE_REF ), then LOAD so the + // real host can read AND write through it. + // - params: LOAD_PTR + SPREAD_ARRAY . + // + // After the call we refresh each ref param's value-reg from the + // caller (which the real command may have updated) so subsequent + // body reads see the real output. + // Set of host-method ids that, when invoked inside the current + // mock body, should dispatch to the real host (CALL_HOST_REAL) + // instead of looking up the mock table. Populated per body with + // every overload id of the mocked command name. Null outside a + // mock body. Read at the top of Compile(CommandStatement) and + // Compile(CommandExpression) for the self-recursive rewrite. + private HashSet _activeMockBypassIds; + + // Compile a CommandStatement or CommandExpression whose target + // is the mocked command. Emits the same shape as a normal call + // (push each arg, then PUSH cmd-id, then CALL_HOST_REAL), but + // ref args route through the body's bound ref-param table so + // writes land in the caller's scope. After the call, refresh + // each refreshed ref's value-reg from the caller so later body + // reads see the real-output. Caller is responsible for emitting + // the final DISCARD when used as a statement (the regular + // CommandStatement compile already handles void-return discard). + private void CompileMockedCommandSelfCall(CommandInfo command, + List args, int commandId, bool isStatement) + { + var refsRefreshed = new HashSet(StringComparer.OrdinalIgnoreCase); + + var argCounter = 0; + for (var i = 0; i < command.args.Length; i++) + { + var argDesc = command.args[i]; + if (argDesc.isVmArg) continue; + + if (argDesc.isParams) + { + // Spread shape OR inline. Mirror the regular + // CommandStatement params logic: if the only + // remaining user arg is an array-typed expression, + // compile it and SPREAD_ARRAY. Otherwise compile each + // inline arg in reverse and push the count. + var remaining = args.Count - argCounter; + if (remaining == 1 + && args[argCounter].ParsedType.IsArray + && args[argCounter].ParsedType.rank == 1) { - Compile(returnsStmt.expression); + Compile(args[argCounter]); + _buffer.Add(OpCodes.SPREAD_ARRAY); + _buffer.Add(argDesc.typeCode); } else { - AddPushInt(_buffer, 0); + for (var j = args.Count - 1; j >= argCounter; j--) + { + Compile(args[j]); + } + AddPushInt(_buffer, args.Count - argCounter); } - _buffer.Add(OpCodes.MOCK_RETURNS); + break; } - else + + if (argCounter >= args.Count) { - // Empty body → suppress the real call entirely. - AddPushInt(_buffer, commandId); - _buffer.Add(OpCodes.MOCK_VOID); + if (argDesc.isOptional) + { + AddPush(_buffer, new byte[] { }, TypeCodes.VOID); + continue; + } + throw new Exception( + "Compiler: self-recursive mock call missing required arg"); + } + + var userExpr = args[argCounter]; + + if (argDesc.isRef) + { + // Visitor already required this to be a bound ref-param + // name. Route through the binding so the host writes + // into the caller's scope (where the original ref lives). + string refName = (userExpr is VariableRefNode vn) ? vn.variableName : null; + if (refName == null) + { + throw new Exception( + "Compiler: self-recursive mock ref arg must be a variable ref by validation"); + } + EmitMockedCallRefByBoundName(argDesc.typeCode, refName, refsRefreshed); + argCounter++; + continue; + } + + // Value arg — compile the user's expression normally, cast. + Compile(userExpr); + if (argDesc.typeCode != TypeCodes.ANY) + { + CompileCast(argDesc.typeCode); + } + argCounter++; + } + + // Push the host method id and dispatch to the real command. + _buffer.Add(OpCodes.PUSH); + _buffer.Add(TypeCodes.INT); + var idBytes = BitConverter.GetBytes(commandId); + for (var i = 0; i < idBytes.Length; i++) _buffer.Add(idBytes[i]); + _buffer.Add(OpCodes.CALL_HOST_REAL); + + // Refresh ref bindings that this call wrote through, so later + // body reads observe the real output. Untouched bindings stay + // as the user left them (preserves any pre-call user write). + if (_activeMockRefBindings != null) + { + foreach (var rb in _activeMockRefBindings) + { + if (!refsRefreshed.Contains(rb.paramName)) continue; + _buffer.Add(OpCodes.LOAD_REF); + AddPushULongNoTypeCode(_buffer, rb.ptrRegAddr); + _buffer.Add(OpCodes.CAST); + _buffer.Add(rb.argTypeCode); + var refNode = new VariableRefNode(null, rb.paramName); + CompileAssignmentLeftHandSide(refNode); + } + } + + // For void real-commands invoked at statement position there + // is nothing on the stack to discard. For value-returning + // ones at statement position, the regular CommandStatement + // caller doesn't emit a discard either — the value is left + // on the stack. Match that behavior here (the caller stack + // hygiene is the same as a normal CALL_HOST). + } + + // Flush the body-visible value-reg through the hidden ptr (so + // the real host reads the user's latest write), then push the + // ptr itself as PTR_REG / PTR_GLOBAL_REG. Records the binding + // name in `refsRefreshed` so we know which value-regs to reload + // from the caller after the call. + private void EmitMockedCallRefByBoundName(byte argTypeCode, + string boundName, HashSet refsRefreshed) + { + MockRefBinding refBinding = default; + var found = false; + if (_activeMockRefBindings != null) + { + foreach (var rb in _activeMockRefBindings) + { + if (string.Equals(rb.paramName, boundName, + StringComparison.OrdinalIgnoreCase)) + { + refBinding = rb; + found = true; + break; + } } } + if (!found) + { + throw new Exception( + "Compiler: self-recursive mock call missing ref binding for " + boundName); + } + PushLoad(_buffer, refBinding.valueRegAddr, isGlobal: false); + _buffer.Add(OpCodes.CAST); + _buffer.Add(argTypeCode); + _buffer.Add(OpCodes.WRITE_REF); + AddPushULongNoTypeCode(_buffer, refBinding.ptrRegAddr); + PushLoad(_buffer, refBinding.ptrRegAddr, isGlobal: false); + refsRefreshed.Add(refBinding.paramName); } private void Compile(ClearMockStatement clearMockStatement) @@ -2227,7 +2848,23 @@ private void Compile(AddressExpression expression) } public void Compile(CommandStatement commandStatement) - { + { + // Inside a mock body, a call to the mocked command itself + // (any overload) dispatches to the real host via + // CALL_HOST_REAL — the mock body is transparent to its own + // command. Ref args route through the body's bound ref-param + // table (validation enforced this) so writes land in the + // caller's scope through the scope-swap in CALL_HOST_REAL. + if (_activeMockBypassIds != null + && _commandToPtr.TryGetValue( + commandStatement.command.UniqueName, out var bypassIdStmt) + && _activeMockBypassIds.Contains(bypassIdStmt)) + { + CompileMockedCommandSelfCall(commandStatement.command, + commandStatement.args, bypassIdStmt, isStatement: true); + return; + } + // TODO: save local state? // put each expression on the stack. var argCounter = 0; @@ -2237,14 +2874,39 @@ public void Compile(CommandStatement commandStatement) if (commandStatement.command.args[i].isParams) { - - // and then, compile the rest of the args + // Spread shape: exactly one remaining arg, and it's an + // array-typed expression matching the params element + // type. Compile the array (which puts its heap ptr on + // the stack), then SPREAD_ARRAY pushes each element + + // count — the same shape as the inline loop below. + var remaining = commandStatement.args.Count - argCounter; + if (remaining == 1 + && commandStatement.args[argCounter].ParsedType.IsArray + && commandStatement.args[argCounter].ParsedType.rank == 1) + { + Compile(commandStatement.args[argCounter]); + _buffer.Add(OpCodes.SPREAD_ARRAY); + // Use the array's actual element type, not the + // descriptor's — for `params object[]` (TypeCodes.ANY) + // the descriptor doesn't carry a usable byte size, + // but the source array always has a concrete element + // type the VM can size and tag per-element. + var descTc = commandStatement.command.args[i].typeCode; + var spreadTc = descTc == TypeCodes.ANY + ? VmUtil.GetTypeCode(commandStatement.args[argCounter].ParsedType.type) + : descTc; + _buffer.Add(spreadTc); + break; + } + + // Inline-list shape (existing): compile each arg in + // reverse, then push the count. for (var j = commandStatement.args.Count - 1; j >= argCounter; j --) { var argExpr2 = commandStatement.args[j]; Compile(argExpr2); } - + // first, we need to tell the program how many arguments there are left in the set // , which of course, is args - i. AddPushInt(_buffer, commandStatement.args.Count - argCounter); @@ -2939,13 +3601,18 @@ public void Compile(AssignmentStatement assignmentStatement) * If it is an array, then it lives in memory. */ + // Note: assignment to a ref param inside a mock body is NOT + // special-cased here — the body's value register is a regular + // local. The writeback to the caller's variable happens at + // every body exit (exitmock + endmock fall-through) via the + // ref-binding list. if (assignmentStatement.variable is VariableRefNode leftRef && scope.TryGetVariable(leftRef.variableName, out var leftVar) && leftVar.typeCode == TypeCodes.STRUCT) { // _buffer.Add(OpCodes.BREAKPOINT); } - + // compile the rhs of the assignment... Compile(assignmentStatement.expression); CompileAssignmentLeftHandSide(assignmentStatement.variable); @@ -3058,6 +3725,33 @@ public void Compile(IExpressionNode expr) argMap = commandExpr.argMap }); break; + case LenExpression lenExpr: + { + // `len()` — push the inner expression (an array + // or string heap pointer), then LENGTH with the + // element-size byte to divide the allocation size and + // push the count. + if (lenExpr.inner == null) { AddPushInt(_buffer, 0); break; } + Compile(lenExpr.inner); + var innerType = lenExpr.inner.ParsedType; + byte elemSize; + if (innerType.type == VariableType.String) + { + // Fade chars are uint codepoints — 4 bytes each. + elemSize = TypeCodes.GetByteSize(TypeCodes.INT); + } + else + { + // Array: take the inner element type's byte size. + // ParsedType.type for an array variable is its + // element type (Integer for `dim x(...)`, etc). + var elemTc = VmUtil.GetTypeCode(innerType.type); + elemSize = TypeCodes.GetByteSize(elemTc); + } + _buffer.Add(OpCodes.LENGTH); + _buffer.Add(elemSize); + break; + } case CallCountExpression callCountExpr: { // Resolve the command name to all overload ids; the count diff --git a/FadeBasic/FadeBasic/Virtual/OpCodes.cs b/FadeBasic/FadeBasic/Virtual/OpCodes.cs index 099a899..70d57b0 100644 --- a/FadeBasic/FadeBasic/Virtual/OpCodes.cs +++ b/FadeBasic/FadeBasic/Virtual/OpCodes.cs @@ -232,10 +232,9 @@ public static class OpCodes /// public const byte DISCARD_TYPED = 55; - /// - /// Reads a ptr value from the stack, and that ptr is used as a heap address. The result on the stack is the length (in bytes) of the allocation on the heap - /// - public const byte LENGTH = 16; + // Slot 16 was an earlier (never-wired) LENGTH opcode. Reused for + // nothing today; the actual LENGTH lives further down at byte 79 + // with a defined contract (inline elem size, pushes count). /// /// Duplicates the current value on the stack, like a type-code qualified int or word @@ -465,5 +464,99 @@ public static class OpCodes /// when `cmd` was never called. /// public const byte CALL_COUNT = 73; + + /// + /// Installs a mock pointing at a bytecode body. Stack at dispatch + /// (top → bottom): commandId (int), bodyAddr (int). The VM records + /// the body's address; CALL_HOST for that command id will JUMP to + /// the body (pushing methodStack like a normal function call) so + /// the body runs with command args bound as locals in a new scope. + /// Replaces MOCK_VOID / MOCK_RETURNS / MOCK_FORBID for new mocks. + /// + public const byte MOCK_INSTALL = 74; + + /// + /// Writes a typed value through a PTR_REG / PTR_GLOBAL_REG pointer + /// stored in a body-local register. Reads an inline register address + /// (the body local's slot). Pops a typed value from the stack and + /// writes it through the pointer found in that local. + /// Stack at dispatch (top → bottom): typed value to write. + /// Used by ref-arg writeback at mock-body exit. + /// + public const byte WRITE_REF = 75; + + /// + /// Reads through a PTR_REG / PTR_GLOBAL_REG stored in a body-local + /// register and pushes the value at the pointed-to register onto + /// the data stack as a typed value. Reads an inline register + /// address (the body local's slot holding the ptr). + /// Used at mock-body prelude to initialize a ref param's value + /// register with the caller's current value. + /// + public const byte LOAD_REF = 76; + + /// + /// Stores a typed ref-pointer from the stack into a body-local + /// register, preserving the stack-side type code (PTR_REG or + /// PTR_GLOBAL_REG). Reads an inline register address (the slot). + /// Distinct from STORE_PTR — which reads the type code from VM + /// state — because in the mock-body prelude the VM-state typeCode + /// has been clobbered by intervening opcodes between the caller's + /// push and this store. + /// Stack at dispatch (top → bottom): typed pointer (8 bytes + type). + /// + public const byte STORE_REF = 77; + + /// + /// Spreads a Fade single-dimensional array onto the data stack in + /// the shape a `params` arg expects. Pops a heap pointer to the + /// array's contents (PTR_HEAP, 8 bytes + type code). Reads an + /// inline element type code (1 byte). Pushes each element as a + /// typed value in REVERSE order (so the host reads them back in + /// declaration order), then pushes the element count as an int. + /// + public const byte SPREAD_ARRAY = 78; + + /// + /// Pops a heap pointer (PTR_HEAP, 8 bytes + type code) and reads + /// the underlying allocation's byte length. Divides by an inline + /// element-size byte and pushes the resulting count as an int. + /// Used to implement the `len()` keyword for arrays and strings: + /// the compiler emits the element size based on the source type + /// (4 for string chars, 4/8/etc. for typed arrays). + /// + public const byte LENGTH = 79; + + /// + /// Inverse of SPREAD_ARRAY: pops a count int from the stack, then + /// pops `count` typed values, allocates a heap block of + /// count*elemSize bytes, writes the values into it, and pushes + /// a PTR_HEAP to the new allocation. Element type comes from an + /// inline byte. Used by mock-body preludes to receive a params + /// arg as a Fade array the body can iterate. + /// + public const byte GATHER_ARRAY = 80; + + /// + /// Like CALL_HOST, but bypasses the mock table entirely — always + /// dispatches to the real registered host command, even when a + /// mock is installed for that command id. Used by the + /// `passthrough` keyword inside a mock body so a mock can invoke + /// the underlying real command. Identical stack shape and inline + /// encoding to CALL_HOST. CallCount is NOT incremented (the + /// surrounding CALL_HOST already counted the outer invocation). + /// + public const byte CALL_HOST_REAL = 81; + + /// + /// Same as JUMP_HISTORY (pops destination off the stack, pushes + /// a return frame onto methodStack, jumps). Used by test + /// launchers to GOSUB into ancestor-or-self test bodies. The + /// pushed frame is tagged isLauncherFrame=true so user-facing + /// stack-trace capture skips it — launcher frames are control- + /// flow plumbing, not visible call sites. RETURN treats them + /// identically to a normal JUMP_HISTORY frame. + /// + public const byte JUMP_HISTORY_LAUNCH = 82; } } \ No newline at end of file diff --git a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs index 92adb3f..63ad370 100644 --- a/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs +++ b/FadeBasic/FadeBasic/Virtual/VirtualMachine.cs @@ -57,6 +57,12 @@ public struct JumpHistoryData { public int fromIns; public int toIns; + // True when this frame was pushed by a test launcher's GOSUB + // (JUMP_HISTORY_LAUNCH). Launcher frames are control-flow plumbing + // — they exist so a test's `from`-chain can run as a sequence of + // GOSUBs — and shouldn't appear in user-facing stack traces. The + // RETURN opcode still pops them; only stack-trace capture filters. + public bool isLauncherFrame; } public struct VirtualRuntimeError @@ -200,7 +206,8 @@ public class TestFailure public class MockBehavior { - // 0 = void (skip), 1 = returns (push value), 2 = forbid (assert-fail). + // 0 = void (skip), 1 = returns (push value), 2 = forbid (assert-fail), + // 3 = body (run bytecode block — Phase B onward). public byte kind; // For kind = Returns: the typed return value to push. public byte returnTypeCode; @@ -211,6 +218,11 @@ public class MockBehavior // defers the same way an assert failure does. public string forbidReason; public int forbidTrampolineAddr; + // For kind = Body: bytecode address of the mock body. CALL_HOST + // pushes methodStack and jumps here. The body itself pushes a + // scope, binds args from the stack as locals, runs user code, + // pops scope, and RETURNs to the caller. + public int bodyAddr; } public VirtualMachine(IEnumerable program) : this(program.ToArray()) @@ -535,6 +547,18 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp logger?.Log($"[VM] JUMP HISTORY FROM=[{instructionIndex}] TO=[{insPtr}]"); instructionIndex = insPtr; break; + case OpCodes.JUMP_HISTORY_LAUNCH: + // Identical to JUMP_HISTORY but tags the frame as + // launcher-pushed so CaptureCallStack filters it. + VmUtil.ReadAsInt(ref stack, out insPtr); + methodStack.Push(new JumpHistoryData + { + toIns = insPtr, + fromIns = instructionIndex, + isLauncherFrame = true + }); + instructionIndex = insPtr; + break; case OpCodes.RETURN: if (methodStack.ptr > 0) { @@ -763,19 +787,19 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp { heap.TryDecrementRefCount(scope.dataRegisters[addr]); } - + scope.dataRegisters[addr] = data; scope.typeRegisters[addr] = typeCode; - scope.insIndexes[addr] = instructionIndex - 1; // minus one because the instruction has already been advanced. + scope.insIndexes[addr] = instructionIndex - 1; // minus one because the instruction has already been advanced. scope.flags[addr] = VirtualScope.FLAG_PTR; - + heap.IncrementRefCount(data); - - // TODO: this is not a very good balance of efficiency... + + // TODO: this is not a very good balance of efficiency... // the sweeping is costly, and maybe it makes sense to // do it only every now and then, not on EVERY assign - heap.Sweep(); - + heap.Sweep(); + break; case OpCodes.STORE_PTR_GLOBAL: @@ -788,15 +812,15 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp { heap.TryDecrementRefCount(globalScope.dataRegisters[addr]); } - + globalScope.dataRegisters[addr] = data; globalScope.typeRegisters[addr] = typeCode; - globalScope.insIndexes[addr] = instructionIndex - 1; // minus one because the instruction has already been advanced. + globalScope.insIndexes[addr] = instructionIndex - 1; // minus one because the instruction has already been advanced. globalScope.flags[addr] = VirtualScope.FLAG_PTR | VirtualScope.FLAG_GLOBAL; - + heap.IncrementRefCount(data); - heap.Sweep(); - + heap.Sweep(); + break; case OpCodes.STORE_GLOBAL: VmUtil.ReadRegAddress(program, ref instructionIndex, out addr); @@ -923,8 +947,26 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp if (mockTable != null && mockTable.TryGetValue(hostMethodPtr, out var mock)) { - // Mocked: pop the args off the stack as the real - // executor would, then synthesize the behavior. + if (mock.kind == 3) + { + // Phase B: mock body is a bytecode block. + // The args are still on the stack — the + // body itself pops and binds them as + // locals in a fresh scope. We push the + // method-call return frame so the body's + // RETURN lands us back here. + methodStack.Push(new JumpHistoryData + { + fromIns = instructionIndex, + toIns = mock.bodyAddr + }); + instructionIndex = mock.bodyAddr; + break; + } + + // Legacy path (Phase A): pop the args off the + // stack as the real executor would, then + // synthesize the behavior. if (method.args != null) { for (var ai = method.args.Length - 1; ai >= 0; ai--) @@ -976,6 +1018,226 @@ public int Execute3(int instructionBatchCount=1000, Func shouldBreakp break; + case OpCodes.CALL_HOST_REAL: + { + // `passthrough` inside a mock body: dispatch + // to the real command, never to the mock. We + // don't bump hostCallCounts here because the + // outer CALL_HOST that routed into the mock + // already counted this invocation. + // + // Scope dance: the body's PUSH_SCOPE made the + // mock body's locals the current scope. The + // real host writes ref args via + // `vm.dataRegisters[addr]` (current scope), so + // for PTR_REG addresses that point to the + // caller's registers to land correctly, we + // need the caller's scope to BE the current + // scope during the call. Temporarily pop the + // body scope, run the host, then put it back. + // Body-local arrays remain valid because + // VirtualScope.dataRegisters is a managed + // reference and survives the by-value copy. + VmUtil.ReadAsInt(ref stack, out var realHostMethodPtr); + hostMethods.FindMethod(realHostMethodPtr, out var realMethod); + + var savedBodyScope = scopeStack.buffer[scopeStack.ptr - 1]; + scopeStack.ptr--; + scope = scopeStack.buffer[scopeStack.ptr - 1]; + + HostMethodUtil.Execute(realMethod, this); + + scopeStack.buffer[scopeStack.ptr] = savedBodyScope; + scopeStack.ptr++; + scope = savedBodyScope; + break; + } + + case OpCodes.GATHER_ARRAY: + { + // Inverse of SPREAD_ARRAY. Inline element type + // byte; stack has `[..., elemN, ..., elem1, count]` + // (count on top — same shape a `params` arg + // produces). Pops count, then pops `count` + // typed values, materializes a heap block, + // pushes the PTR_HEAP. + var gatherElemTc = Advance(); + var gatherElemSize = TypeCodes.GetByteSize(gatherElemTc); + VmUtil.ReadAsInt(ref stack, out var gatherCount); + var gatherBytes = new byte[gatherCount * gatherElemSize]; + for (var gi = 0; gi < gatherCount; gi++) + { + // Each element has [data_bytes][type_byte]; + // pop type, then data. We trust the type + // matches what the inline byte says (caller + // sets it from the params arg metadata). + stack.Pop(); // discard type code + for (var gb = gatherElemSize - 1; gb >= 0; gb--) + { + gatherBytes[gi * gatherElemSize + gb] = stack.Pop(); + } + } + var gatherFormat = new HeapTypeFormat + { + typeCode = gatherElemTc, + typeFlags = HeapTypeFormat.CreateArrayFlag(1) + }; + heap.Allocate(ref gatherFormat, gatherBytes.Length, out var gatherPtr); + heap.Write(gatherPtr, gatherBytes.Length, gatherBytes); + var gatherPtrBytes = VmPtr.GetBytes(ref gatherPtr); + VmUtil.PushSpan(ref stack, gatherPtrBytes, TypeCodes.PTR_HEAP); + break; + } + case OpCodes.LENGTH: + { + // Inline 1-byte element size. Pops a heap ptr + // (or STRING-typed heap ptr — interned strings + // are tagged STRING after their CAST), reads + // the allocation size, divides by the element + // size, pushes the count as an int. + var lenElemSize = Advance(); + stack.Pop(); // discard the type code (PTR_HEAP, STRING, etc.) + var lenPtrBytes = new byte[8]; + for (var lb = 7; lb >= 0; lb--) lenPtrBytes[lb] = stack.Pop(); + var lenPtr = VmPtr.FromBytes(lenPtrBytes); + heap.TryGetAllocationSize(lenPtr, out var lenBytes); + var lenCount = lenElemSize > 0 ? lenBytes / lenElemSize : 0; + VmUtil.PushSpan(ref stack, + BitConverter.GetBytes(lenCount), + TypeCodes.INT); + break; + } + case OpCodes.SPREAD_ARRAY: + { + // Pops a Fade-array heap ptr, then pushes each + // element as a typed value (in reverse, so the + // first element ends up second-from-top), then + // pushes the element count as an int. The + // overall stack shape after this matches what a + // `params` arg expects from the host-method + // dispatcher: [..., elemN, ..., elem1, count]. + var spreadElemTc = Advance(); + var spreadElemSize = TypeCodes.GetByteSize(spreadElemTc); + VmUtil.ReadAsVmPtr(ref stack, out var spreadPtr); + heap.TryGetAllocationSize(spreadPtr, out var spreadBytes); + var spreadCount = spreadElemSize > 0 ? spreadBytes / spreadElemSize : 0; + if (spreadCount > 0) + { + heap.Read(spreadPtr, spreadBytes, out var spreadData); + // Push elements LIFO so the receiver reads + // them back in declaration order — same as + // an inline `Foo(1,2,3)` call would produce. + for (var ei = spreadCount - 1; ei >= 0; ei--) + { + var elemBytes = new byte[spreadElemSize]; + System.Array.Copy(spreadData, ei * spreadElemSize, elemBytes, 0, spreadElemSize); + VmUtil.PushSpan(ref stack, new ReadOnlySpan(elemBytes), spreadElemTc); + } + } + VmUtil.PushSpan(ref stack, + BitConverter.GetBytes(spreadCount), + TypeCodes.INT); + break; + } + case OpCodes.STORE_REF: + { + // Inline 4-byte register address (a body-local). + // Stack at dispatch (top → bottom): + // ptr type code (1 byte), 8 bytes register addr. + // Unlike STORE_PTR, the type comes from the + // stack — necessary because VM-state typeCode + // has been clobbered by intervening opcodes. + VmUtil.ReadRegAddress(program, ref instructionIndex, out var refStoreAddr); + var ptrTc = stack.Pop(); + var ptrBytes = new byte[8]; + for (var sb = 7; sb >= 0; sb--) ptrBytes[sb] = stack.Pop(); + var ptrData = BitConverter.ToUInt64(ptrBytes, 0); + scope.dataRegisters[refStoreAddr] = ptrData; + scope.typeRegisters[refStoreAddr] = ptrTc; + scope.flags[refStoreAddr] = VirtualScope.FLAG_PTR; + break; + } + case OpCodes.LOAD_REF: + { + // Inline 4-byte register address (a body-local + // holding a PTR_REG / PTR_GLOBAL_REG). Read + // through that pointer into the caller's scope + // (or global) and push the typed value found + // there. The body's PUSH_SCOPE pushed a new + // scope after CALL_HOST routed here, so the + // caller's scope sits one slot below current. + VmUtil.ReadRegAddress(program, ref instructionIndex, out var refReadAddr); + var refRegAddr2 = scope.dataRegisters[refReadAddr]; + var refPtrType2 = scope.typeRegisters[refReadAddr]; + + ulong valData; + byte valType; + if (refPtrType2 == TypeCodes.PTR_GLOBAL_REG) + { + valData = globalScope.dataRegisters[refRegAddr2]; + valType = globalScope.typeRegisters[refRegAddr2]; + } + else + { + ref var callerScope2 = ref scopeStack.buffer[scopeStack.ptr - 2]; + valData = callerScope2.dataRegisters[refRegAddr2]; + valType = callerScope2.typeRegisters[refRegAddr2]; + } + var valSize = TypeCodes.GetByteSize(valType); + var valBytes = BitConverter.GetBytes(valData); + stack.PushSpanAndType(new ReadOnlySpan(valBytes), valType, valSize); + break; + } + case OpCodes.WRITE_REF: + { + // Inline 4-byte register address (a body-local + // holding the caller's ref pointer). Pops a + // typed value from the stack and writes it + // through the pointer into the caller's scope. + VmUtil.ReadRegAddress(program, ref instructionIndex, out var refLocalAddr); + + // Body-local holds: dataRegister = 8 bytes of + // the caller's register address, typeRegister = + // PTR_REG or PTR_GLOBAL_REG. + var refRegAddr = scope.dataRegisters[refLocalAddr]; + var refPtrTypeCode = scope.typeRegisters[refLocalAddr]; + + // Peek the value's type code from the stack + // before reading the data, so we can stamp it + // back into the caller's register. + var valTypeCode = stack.buffer[stack.ptr - 1]; + VmUtil.ReadSpanAsUInt(ref stack, out var refData); + + if (refPtrTypeCode == TypeCodes.PTR_GLOBAL_REG) + { + globalScope.dataRegisters[refRegAddr] = refData; + globalScope.typeRegisters[refRegAddr] = valTypeCode; + } + else + { + // PTR_REG: write into the caller's scope. + // The body's PUSH_SCOPE pushed a new scope on + // top after CALL_HOST routed here, so the + // caller's scope sits one slot below. + ref var callerScope = ref scopeStack.buffer[scopeStack.ptr - 2]; + callerScope.dataRegisters[refRegAddr] = refData; + callerScope.typeRegisters[refRegAddr] = valTypeCode; + } + break; + } + case OpCodes.MOCK_INSTALL: + { + // Stack at dispatch (bottom→top): bodyAddr (int), commandId (int). + VmUtil.ReadAsInt(ref stack, out var installCmdId); + VmUtil.ReadAsInt(ref stack, out var installBodyAddr); + mockTable ??= new Dictionary(); + mockTable[installCmdId] = new MockBehavior + { + kind = 3, + bodyAddr = installBodyAddr + }; + break; + } case OpCodes.MOCK_VOID: { VmUtil.ReadAsInt(ref stack, out var voidId); @@ -1246,10 +1508,21 @@ void TriggerRuntimeError(VirtualRuntimeError error) public JumpHistoryData[] CaptureCallStack() { var depth = methodStack.Count; - var copy = new JumpHistoryData[depth]; + // Two-pass so we know the visible count up front and can size + // the array exactly. Launcher frames are filtered — they're + // internal control flow, not user-visible calls. + var visible = 0; + for (var i = 0; i < depth; i++) + { + if (!methodStack.buffer[i].isLauncherFrame) visible++; + } + var copy = new JumpHistoryData[visible]; + var write = 0; for (var i = 0; i < depth; i++) { - copy[i] = methodStack.buffer[depth - 1 - i]; + var src = methodStack.buffer[depth - 1 - i]; + if (src.isLauncherFrame) continue; + copy[write++] = src; } return copy; } diff --git a/FadeBasic/Tests/ArraySpreadParamsTests.cs b/FadeBasic/Tests/ArraySpreadParamsTests.cs new file mode 100644 index 0000000..251e750 --- /dev/null +++ b/FadeBasic/Tests/ArraySpreadParamsTests.cs @@ -0,0 +1,165 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class ArraySpreadParamsTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunMain(string src) + { + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + vm.Execute3(); + return vm; + } + + private List ParseErrors(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + return prog.GetAllErrors(); + } + + [Test] + public void Spread_IntArray_IntoSumParams_AddsCorrectly() + { + // `sum(params int[])` in TestCommands returns the sum. Spreading + // a 3-element int array should produce the same result as calling + // sum(10, 20, 30). + var src = @" +dim xs(3) +xs(0) = 10 +xs(1) = 20 +xs(2) = 30 +n = sum(xs) +"; + var vm = RunMain(src); + // The `n` global register should hold 60. + // dataRegisters[register for n] = 60. We don't know the exact + // register here; assert via the program's debug view. Simpler: + // use a known TestCommands hook. But the cleanest: re-bind and + // check via the static-print buffer. Let me just inspect register 0 + // since n is the first declaration after the array. + // Actually we can ask via the runtime: look up by scope. + var found = false; + for (var i = 0; i < vm.globalScope.dataRegisters.Length; i++) + { + if (vm.globalScope.dataRegisters[i] == 60) + { + found = true; + break; + } + } + Assert.That(found, Is.True, "expected sum(xs) = 60 stored somewhere in globals"); + } + + [Test] + public void Spread_EmptyArray_PushesZeroCount() + { + // `sum` on a 0-element array sums to 0. + var src = @" +dim xs(0) +n = sum(xs) +"; + var vm = RunMain(src); + // n should be 0; that's the default so we just check no crash. + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + } + + [Test] + public void Spread_InlineAndArray_BothWork() + { + // The existing inline-list call shape must still work alongside + // the new spread shape. + var src = @" +dim xs(2) +xs(0) = 5 +xs(1) = 7 +a = sum(xs) ` spread +b = sum(1, 2, 3) ` inline +"; + var vm = RunMain(src); + // a should be 12, b should be 6 — both live in globals. + var foundA = false; var foundB = false; + for (var i = 0; i < vm.globalScope.dataRegisters.Length; i++) + { + if (vm.globalScope.dataRegisters[i] == 12) foundA = true; + if (vm.globalScope.dataRegisters[i] == 6) foundB = true; + } + Assert.That(foundA, Is.True, "expected sum(xs) = 12"); + Assert.That(foundB, Is.True, "expected sum(1,2,3) = 6"); + } + + [Test] + public void Spread_RankTwoArray_Errors() + { + // 2D arrays can't be spread. + var src = @" +dim xs(3, 2) +n = sum(xs) +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ParamsArrayMustBeRankOne)), + Is.True, + "expected ParamsArrayMustBeRankOne; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Spread_StringArray_IntoObjectParams_Works() + { + // `static print` is `params object[]` (TypeCode.ANY at the params + // slot). Spreading a string array into it should NOT error — + // an object[] params slot accepts any element type, matching the + // same tolerance the inline-arg path grants. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +dim x$(2) +x$(0) = ""a"" +x$(1) = ""b"" +static print x$ +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ParamsArrayElementTypeMismatch)), + Is.False, + "expected no ParamsArrayElementTypeMismatch on params object[]; got: " + + string.Join(", ", errs.Select(e => e.Display))); + + RunMain(src); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "a", "b" }), + "string-array spread into params object[] should print each element"); + } + + [Test] + public void Spread_MixingArrayAndInline_Errors() + { + // Can't mix `Foo(arr, 99)` — array spread is exclusive at the + // params position. + var src = @" +dim xs(2) +xs(0) = 5 +xs(1) = 7 +n = sum(xs, 99) +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.ParamsCannotMixArrayWithInline)), + Is.True, + "expected ParamsCannotMixArrayWithInline; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/ExpressionTests.cs b/FadeBasic/Tests/ExpressionTests.cs index 701a90f..db91b53 100644 --- a/FadeBasic/Tests/ExpressionTests.cs +++ b/FadeBasic/Tests/ExpressionTests.cs @@ -36,7 +36,7 @@ public Parser BuildParser(string src, out List tokens) [TestCase("\"a\"", "(\"a\")")] [TestCase("\"a\" + \"b\" ", "(+ (\"a\"),(\"b\"))")] [TestCase("refDbl x", "(xcall refDbl (ref x))")] - [TestCase("a.b + len(a.c)", "(+ ((ref a).(ref b)),(xcall len ((ref a).(ref c))))")] + [TestCase("a.b + len(a.c)", "(+ ((ref a).(ref b)),(len ((ref a).(ref c))))")] [TestCase("*x", "(derefExpr (ref x))")] [TestCase("*x(3)", "(derefExpr (ref x[(3)]))")] [TestCase("x", "(ref x)")] diff --git a/FadeBasic/Tests/LenKeywordTests.cs b/FadeBasic/Tests/LenKeywordTests.cs new file mode 100644 index 0000000..7dd2af4 --- /dev/null +++ b/FadeBasic/Tests/LenKeywordTests.cs @@ -0,0 +1,120 @@ +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace Tests; + +[TestFixture] +public class LenKeywordTests +{ + private (Compiler compiler, byte[] program) Compile(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + prog.AssertNoParseErrors(); + var compiler = new Compiler(TestCommands.CommandsForTesting, new CompilerOptions()); + compiler.Compile(prog); + return (compiler, compiler.Program.ToArray()); + } + + private VirtualMachine RunMain(string src) + { + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + vm.Execute3(); + return vm; + } + + private List ParseErrors(string src) + { + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + return prog.GetAllErrors(); + } + + private bool TryFindGlobalValue(VirtualMachine vm, ulong value) + { + for (var i = 0; i < vm.globalScope.dataRegisters.Length; i++) + { + if (vm.globalScope.dataRegisters[i] == value) return true; + } + return false; + } + + [Test] + public void Len_IntArray_ReturnsElementCount() + { + var src = @" +dim xs(3) +n = len(xs) +"; + var vm = RunMain(src); + Assert.That(TryFindGlobalValue(vm, 3), Is.True, "expected len(xs) = 3"); + } + + [Test] + public void Len_String_ReturnsCharCount() + { + var src = @" +n = len(""hello"") +"; + var vm = RunMain(src); + Assert.That(TryFindGlobalValue(vm, 5), Is.True, "expected len(\"hello\") = 5"); + } + + [Test] + public void Len_EmptyString_ReturnsZero() + { + var src = @" +n = len("""") +"; + var vm = RunMain(src); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); + // The default for n (an int that gets assigned 0) is already 0, so + // we can't distinguish from "uninitialized" by just searching for + // the value. But no runtime error proves the path didn't trip on + // an empty allocation. + } + + [Test] + public void Len_InAssignmentToLong_Works() + { + var src = @" +dim xs(7) +m as long = len(xs) +"; + var vm = RunMain(src); + Assert.That(TryFindGlobalValue(vm, 7), Is.True, "expected len(xs) = 7 stored as long"); + } + + [Test] + public void Len_OnNonArrayNonString_Errors() + { + // `len` on an int variable should error at validation time. + var src = @" +x = 5 +n = len(x) +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.LenInvalidType)), + Is.True, + "expected LenInvalidType; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Len_MissingParens_Errors() + { + // `len xs` (no parens) should fail to parse. + var src = @" +dim xs(3) +n = len xs +"; + var errs = ParseErrors(src); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.LenMissingParens)), + Is.True, + "expected LenMissingParens; got: " + string.Join(", ", errs.Select(e => e.Display))); + } +} diff --git a/FadeBasic/Tests/MockExecutionTests.cs b/FadeBasic/Tests/MockExecutionTests.cs index 74c035a..4d819e4 100644 --- a/FadeBasic/Tests/MockExecutionTests.cs +++ b/FadeBasic/Tests/MockExecutionTests.cs @@ -53,7 +53,7 @@ public void MockReturns_OverridesReturnValue() test mocked_screen_width mock screen width - returns 42 + exitmock 42 endmock assert screen width() = 42 endtest @@ -113,7 +113,7 @@ local w as integer test mocked_via_runto mock screen width - returns 99 + exitmock 99 endmock runto checkpoint assert w = 99 @@ -134,7 +134,7 @@ public void ClearMock_RestoresRealBehavior() test clear_mock mock screen width - returns 42 + exitmock 42 endmock assert screen width() = 42 clear mock screen width @@ -156,7 +156,7 @@ public void ClearMocks_RemovesAllRegistrations() test clear_all mock screen width - returns 42 + exitmock 42 endmock mock wait ms endmock @@ -241,8 +241,16 @@ runto checkpoint Assert.That(result.passed, Is.False); Assert.That(result.failureFrames, Is.Not.Empty, "forbid failure should resolve to source frames when DebugData is present"); - // Innermost frame is inside `trigger()` (where wait ms was called). - Assert.That(result.failureFrames[0].functionName, Is.EqualTo("trigger")); + // Phase B: the body itself is a dispatched bytecode block, so the + // innermost frame is the mock body (named after the command). The + // frame immediately below shows where the forbidden call originated + // — `trigger()` in this case. + Assert.That(result.failureFrames.Count, Is.GreaterThanOrEqualTo(2), + "expected at least mock-body frame + caller frame"); + Assert.That(result.failureFrames[0].functionName, Is.EqualTo("wait ms"), + "innermost frame is the mock body, named after the mocked command"); + Assert.That(result.failureFrames[1].functionName, Is.EqualTo("trigger"), + "frame below the mock body shows where the forbidden call originated"); } // ── call count ─────────────────────────────────────────────── @@ -331,6 +339,285 @@ test second "second test must see count=0; failure: " + second.failureMessage); } + // ── Phase B: mock body as mini-function ──────────────────────────────── + + [Test] + public void MockBody_RunsStatementsAtCallTime() + { + // The body executes every time the mocked command is called, not at + // install time. We use the host-side staticPrintBuffer to observe. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test body_runs + mock screen width + static print ""called"" + exitmock 7 + endmock + local w as integer = screen width() + local w2 as integer = screen width() + assert w = 7 + assert w2 = 7 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("body_runs"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "called", "called" }), + "body should run once per call, not at install time"); + } + + [Test] + public void MockBody_LocalAndIf_Work() + { + // Arbitrary test-block statements (local, if/then) inside a body. + var src = @" +end + +test body_with_local + mock screen width + local result as integer + result = 100 + if result > 50 then result = 99 + exitmock result + endmock + assert screen width() = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("body_with_local"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_ParamsBoundToArgs() + { + // `mock ` binds the command's arg to a local named + // inside the body. The body can read it to compute a return + // value based on the input. + var src = @" +end + +test param_binding + mock prim test di n + exitmock n * 3 + endmock + local x as long = prim test di(5) + assert x = 15 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("param_binding"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + // ── Ref-arg writes inside a mock body ────────────────────────────────── + + [Test] + public void MockBody_RefArgWrite_BackToCaller() + { + // `inc` takes `(ref int variable, int amount = 1)`. A mock body that + // names the ref param and writes to it via plain assignment should + // mutate the caller's variable. + var src = @" +end + +test ref_write + mock inc target, amount + target = 99 + endmock + local x as integer = 5 + inc x, 1 + assert x = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("ref_write"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_RefArg_CoercesType() + { + // `target = 200` writes an int literal through a ref to an integer; + // the same coercion rules as `local n as long = 5` apply. + var src = @" +end + +test ref_coerce + mock inc target, amount + target = 200 + endmock + local x as integer + x = 0 + inc x, 1 + assert x = 200 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("ref_coerce"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_EndmockWithExpression_ReturnsValue() + { + // `endmock ` provides the fall-through return value, mirroring + // `endfunction ` for functions. No `exitmock` needed for the + // simple case. + var src = @" +end + +test endmock_expr + mock screen width + endmock 7 + assert screen width() = 7 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("endmock_expr"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_ExitmockEarlyExit_OverridesEndmock() + { + // `exitmock` is an early return. If hit, the fall-through + // `endmock ` is bypassed. + var src = @" +end + +test exitmock_short_circuit + mock screen width + exitmock 100 + endmock 200 + assert screen width() = 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("exitmock_short_circuit"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_RefParamReadsInitialCallerValue() + { + // The body's value-register for a ref param is seeded from the + // caller's variable at body entry, so `static print target` shows + // whatever the caller passed in — not the pointer bytes. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test ref_read_initial + mock inc target, amount + static print str$(target) + target = 99 + endmock + local x as integer = 5 + inc x, 1 + assert x = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("ref_read_initial"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "5" }), + "body should read the caller's pre-call value through the ref param"); + } + + [Test] + public void MockBody_RefParamReadsAfterWrite() + { + // After the body assigns `target`, subsequent reads of `target` + // inside the body see the new value (it's a normal local). + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test ref_read_after_write + mock inc target, amount + target = 99 + static print str$(target) + endmock + local x as integer = 5 + inc x, 1 + assert x = 99 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("ref_read_after_write"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "99" }), + "body should read its own latest write to the ref param"); + } + + // ── Params arg gathered into a Fade array inside a mock body ────────── + + [Test] + public void MockBody_ParamsArg_LenReturnsCount() + { + // `sum(params int[] numbers)` — a mock body that names the params + // arg should receive it as a Fade array. `len(nums)` returns the + // count the caller passed. + var src = @" +end + +test params_len + mock sum(nums) + endmock len(nums) + assert sum(10, 20, 30) = 3 + assert sum() = 0 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("params_len"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_ParamsArg_IndexedAccess() + { + // The body can read individual elements by index. Returning the + // first element proves indexing works. + var src = @" +end + +test params_index + mock sum(nums) + endmock nums(0) + assert sum(42, 100, 7) = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("params_index"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_ParamsArg_SumViaIteration() + { + // Sum the elements via for/len inside the body. Verifies the + // gathered array round-trips length + indexing + control flow. + var src = @" +end + +test params_sum_via_iter + mock sum(nums) + total = 0 + for i = 0 to len(nums) - 1 + total = total + nums(i) + next i + endmock total + assert sum(1, 2, 3, 4) = 10 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("params_sum_via_iter"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + [Test] public void MockIsolation_BetweenTestRuns() { @@ -341,7 +628,7 @@ public void MockIsolation_BetweenTestRuns() test installs_mock mock screen width - returns 42 + exitmock 42 endmock assert screen width() = 42 endtest @@ -357,4 +644,271 @@ assert screen width() = 5 Assert.That(second.passed, Is.True, "second test must not see the first test's mock; failure: " + second.failureMessage); } + + // ── Self-recursive call: mocked command name inside body → real ──────── + + [Test] + public void MockBody_SelfCall_VoidCommand_RunsRealCommand() + { + // Inside the mock for `inc`, writing `inc target, amount` calls + // the real underlying C# Inc rather than recursing into the mock. + var src = @" +end + +test selfcall_void + mock inc target, amount + inc target, amount + endmock + local x as integer = 10 + inc x, 5 + assert x = 15 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_void"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_ReturningCommand_CapturesValue() + { + // `screen width()` returns 5 in TestCommands. Inside the mock, the + // same expression invokes the real command. + var src = @" +end + +test selfcall_return + mock screen width + real_width = screen width() + exitmock real_width + 100 + endmock + assert screen width() = 105 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_return"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_RefArgFlushesUserChangeThenRealRuns() + { + // User writes `target = 100` then self-calls. The compiler flushes + // the value-reg through the hidden ptr first, so the real Inc reads + // 100 and adds 1 → caller's x = 101. + var src = @" +end + +test selfcall_ref_flush + mock inc target, amount + target = 100 + inc target, amount + endmock + local x as integer = 0 + inc x, 1 + assert x = 101 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_ref_flush"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_RefRefreshesLocalAfterCall() + { + // After the self-call runs the real Inc, the body's `target` value + // reg is refreshed from the caller — subsequent reads observe the + // real output. A trailing user write to `target` still wins at exit. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test selfcall_ref_refresh + mock inc target, amount + inc target, amount + static print str$(target) + target = 999 + endmock + local x as integer = 10 + inc x, 5 + assert x = 999 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_ref_refresh"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, Is.EqualTo(new[] { "15" }), + "after the self-call the body should observe the real-Inc result (10+5)"); + } + + [Test] + public void MockBody_SelfCall_ModifiedValueArg() + { + // The user supplies any expression at value positions. Here the + // mock calls the real Inc with a doubled amount. + var src = @" +end + +test selfcall_modified_value + mock inc target, amount + inc target, amount * 2 + endmock + local x as integer = 0 + inc x, 5 + assert x = 10 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_modified_value"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_LiteralOverride() + { + // The mock ignores the user's `amount` entirely, calling the real + // Inc with a hard-coded literal. + var src = @" +end + +test selfcall_literal + mock inc target, amount + inc target, 100 + endmock + local x as integer = 0 + inc x, 5 + assert x = 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_literal"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_ParamsArgSpread() + { + // The body owns the gathered array as `nums`. Passing it as the + // sole arg at the params position spreads it through to the real + // sum, which sums the mutated values. + var src = @" +end + +test selfcall_params_spread + mock sum(nums) + for i = 0 to len(nums) - 1 + nums(i) = nums(i) * 10 + next i + exitmock sum(nums) + endmock + assert sum(1, 2, 3) = 60 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_params_spread"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_RefArgMustBeBoundParam() + { + // A self-recursive call must pass one of the mock's bound ref + // params at each ref position. A body-local int would yield a + // PTR_REG into the body's scope, which the scope swap in + // CALL_HOST_REAL turns into a write to the wrong cell. + var src = @" +end + +test bad_selfcall_ref + mock inc target, amount + local fake as integer + inc fake, amount + endmock +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, "expected compile failure when ref arg isn't a bound ref param"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.MockBodyRefArgMustBeBoundRefParam)), + Is.True, + "expected MockBodyRefArgMustBeBoundRefParam; got: " + errors.ToDisplay()); + } + + [Test] + public void MockBody_ParamsObjectArray_NamingFails_Cleanly() + { + // `static print` is `params object[]`. Naming the params slot + // (e.g. `mock static print(args)`) requires mixed-type element + // storage that the body array model doesn't support — surface a + // clean error rather than crashing the compiler on SIZE_TABLE[ANY]. + var src = @" +end + +test bad_params_object_named + mock static print(args) + endmock +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure on named params object[] slot"); + var match = errors.ParserErrors.FirstOrDefault( + e => e.errorCode.Equals(ErrorCodes.MockParamsObjectArrayUnnamable)); + Assert.That(match, Is.Not.Null, + "expected MockParamsObjectArrayUnnamable; got: " + errors.ToDisplay()); + // The site-specific detail names the offending param, the command + // being mocked, and the rewrite the user should reach for. + Assert.That(match.message, Does.Contain("args"), + "error should name the param the user tried to bind"); + Assert.That(match.message, Does.Contain("static print"), + "error should name the command being mocked"); + Assert.That(match.message, Does.Contain("params object[]"), + "error should call out the param shape causing the limitation"); + Assert.That(match.message, Does.Contain("mock static print"), + "error should show the rewrite (mock with no param name)"); + } + + [Test] + public void MockBody_ParamsObjectArray_UnnamedFormCompiles() + { + // The workaround: don't name the params slot. The mock still + // installs and the real call is suppressed/handled. + var src = @" +end + +test params_object_unnamed + mock static print + endmock + static print ""a"", ""b"" +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("params_object_unnamed"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void MockBody_SelfCall_SatisfiesRefAssignedCheck() + { + // A bare self-call writes through every ref it's handed, so the + // mock body doesn't need a separate `target = ...` assignment. + var src = @" +end + +test selfcall_satisfies_ref_check + mock inc target, amount + inc target, amount + endmock + local x as integer = 3 + inc x, 4 + assert x = 7 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("selfcall_satisfies_ref_check"); + Assert.That(result.passed, Is.True, result.failureMessage); + } } diff --git a/FadeBasic/Tests/MockParserTests.cs b/FadeBasic/Tests/MockParserTests.cs index 9fd0ce1..c200ac8 100644 --- a/FadeBasic/Tests/MockParserTests.cs +++ b/FadeBasic/Tests/MockParserTests.cs @@ -68,7 +68,7 @@ public void Mock_Returns_ParsesAsReturnsStatement() var src = @" test foo mock screen width - returns 10 + exitmock 10 endmock endtest "; @@ -77,8 +77,8 @@ returns 10 "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); var mock = FindFirstMock(prog); Assert.That(mock.body.Count, Is.EqualTo(1)); - Assert.That(mock.body[0], Is.TypeOf()); - var rs = (MockReturnsStatement)mock.body[0]; + Assert.That(mock.body[0], Is.TypeOf()); + var rs = (MockExitMockStatement)mock.body[0]; Assert.That(rs.expression, Is.Not.Null); } @@ -124,19 +124,19 @@ mock wait ms [Test] public void Mock_InlineForm_NoLongerSupported_Errors() { - // `mock cmd returns X` on one line is no longer valid — the parser - // expects a newline and `endmock`. The `returns 10` token sequence - // now sits in an empty mock body, awaiting `endmock`; eventually - // the surrounding `endtest` is hit and MockMissingEndMock fires. + // Every mock requires its own `endmock`. Even when a body has a + // single `exitmock ` on the same line as the mock header, + // the parser will still keep looking for `endmock` and eventually + // run into the surrounding `endtest`, surfacing MockMissingEndMock. var src = @" test foo - mock screen width returns 10 + mock screen width exitmock 10 endtest "; Parse(src, out var errs); Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockMissingEndMock)), Is.True, - "inline mock should now require endmock; got: " + + "inline mock should still require endmock; got: " + string.Join(", ", errs.Select(e => e.Display))); } @@ -163,7 +163,7 @@ public void Mock_MissingEndMock_Errors() var src = @" test foo mock screen width - returns 10 + exitmock 10 endtest "; Parse(src, out var errs); @@ -180,7 +180,7 @@ public void Mock_UnknownCommand_Errors() var src = @" test foo mock not_a_real_command - returns 10 + exitmock 10 endmock endtest "; @@ -198,8 +198,8 @@ public void Mock_MultipleReturns_Errors() var src = @" test foo mock screen width - returns 10 - returns 20 + exitmock 10 + exitmock 20 endmock endtest "; @@ -234,7 +234,7 @@ public void Mock_ReturnsAndForbid_Errors() var src = @" test foo mock screen width - returns 10 + exitmock 10 forbid endmock endtest @@ -270,7 +270,7 @@ public void Mock_BlockForm_MissingEndMock_DoesNotConsumeEndTest() var src = @" test foo mock screen width - returns 10 + exitmock 10 endtest "; var prog = Parse(src, out var errs); @@ -335,7 +335,7 @@ public void Mock_OutsideTest_Errors() { var src = @" mock screen width - returns 10 + exitmock 10 endmock "; Parse(src, out var errs); @@ -366,7 +366,7 @@ public void Mock_ReturnsOnVoidCommand_Errors() var src = @" test foo mock wait ms - returns 0 + exitmock 0 endmock endtest "; @@ -383,7 +383,7 @@ public void Mock_ReturnsTypeMismatch_Errors() var src = @" test foo mock screen width - returns ""nope"" + exitmock ""nope"" endmock endtest "; @@ -403,7 +403,7 @@ public void Mock_ReturnsNumericCoercion_Ok() var src = @" test foo mock now - returns 5 + exitmock 5 endmock endtest "; @@ -414,6 +414,24 @@ returns 5 string.Join(", ", errs.Select(e => e.Display))); } + [Test] + public void Mock_EndmockExprTypeMismatch_Errors() + { + // `screen width` returns int. `endmock ""3""` (string literal) is + // not assignable to int — should error like `exitmock` does. + var src = @" +test foo + mock screen width + endmock ""3"" +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockReturnsTypeMismatch)), + Is.True, + "expected MockReturnsTypeMismatch on endmock string→int; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + [Test] public void Mock_ReturnsMatchingType_Ok() { @@ -421,7 +439,7 @@ public void Mock_ReturnsMatchingType_Ok() var src = @" test foo mock screen width - returns 42 + exitmock 42 endmock endtest "; @@ -434,12 +452,28 @@ returns 42 } [Test] - public void Mock_EmptyBodyOnAnyCommand_Ok() + public void Mock_EmptyBodyOnVoidCommand_Ok() { - // Empty mock body = suppress the call. Always valid regardless of - // whether the command returns a value (the caller of a value- - // returning command gets a stack-leak if it reads the return — but - // that's a separate runtime concern; the parser accepts it). + // Empty mock body = suppress the call. Legal for void commands — + // the caller doesn't read a return, so there's nothing to leak. + var src = @" +test foo + mock wait ms + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "empty mock body on void command should parse cleanly; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_EmptyBodyOnValueCommand_Errors() + { + // A value-returning command's mock body must contain `returns` or + // `forbid`. An empty body would leave the caller's expected return + // value missing on the stack — that's now a compile-time error. var src = @" test foo mock screen width @@ -447,11 +481,194 @@ mock screen width endtest "; Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockValueCommandMissingReturns)), + Is.True, + "expected MockValueCommandMissingReturns; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RefParamNotAssigned_Errors() + { + // A ref parameter must be assigned in the mock body — otherwise the + // caller's variable is left undefined. `forbid` short-circuits the + // check, but otherwise every ref param needs at least one top-level + // assignment. + var src = @" +test foo + mock inc target, amount + ` target (ref) never assigned + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockRefParamNotAssigned)), + Is.True, + "expected MockRefParamNotAssigned; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RefParamAssigned_Ok() + { + // `inc` takes `(ref int variable, int amount = 1)`. Assigning to + // `target` (the ref param) inside the body is the happy path and + // should produce no validation errors. + var src = @" +test foo + mock inc target, amount + target = 99 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockRefParamNotAssigned)), + Is.False, + "no ref errors expected; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RefParamUnassigned_WithForbid_Ok() + { + // `forbid` halts the test before the caller observes any output, + // so unassigned ref params are fine when forbid is present. + var src = @" +test foo + mock inc target, amount + forbid ""nope"" + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockRefParamNotAssigned)), + Is.False, + "forbid should suppress the ref-assignment requirement; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RuntoInBody_Errors() + { + // `runto` is a test-control primitive and must not appear inside a + // mock body. The body is mini-function bytecode run on dispatch, + // not a test-navigation context. + var src = @" +checkpoint: +end + +test foo + mock screen width + runto checkpoint + endmock 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.RuntoInsideMockBody)), + Is.True, + "expected RuntoInsideMockBody; got: " + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_RuntoNestedInBody_Errors() + { + // Even wrapped in an `if`, runto inside a mock body is illegal — + // we walk the body tree recursively. + var src = @" +checkpoint: +end + +test foo + mock screen width + if 1 then runto checkpoint + endmock 0 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.RuntoInsideMockBody)), + Is.True, + "expected RuntoInsideMockBody (nested in if); got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ParamsInParens_ParseOk() + { + // `mock inc(target, amount)` should parse identically to the bare + // `mock inc target, amount` form. + var src = @" +test foo + mock inc(target, amount) + target = 99 + endmock +endtest +"; + var prog = Parse(src, out var errs); Assert.That(errs, Is.Empty, - "empty mock body should parse cleanly; got: " + + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.parameters.Count, Is.EqualTo(2)); + Assert.That(mock.parameters[0].variableName, Is.EqualTo("target")); + Assert.That(mock.parameters[1].variableName, Is.EqualTo("amount")); + } + + [Test] + public void Mock_ParamsInParens_MissingClose_Errors() + { + var src = @" +test foo + mock inc(target, amount + target = 99 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockParamsMissingCloseParen)), + Is.True, + "expected MockParamsMissingCloseParen; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } + + [Test] + public void Mock_ParamCountNoMatchingOverload_Errors() + { + // `inc` has one overload: `(ref int variable, int amount = 1)` — 2 + // args (the optional one still counts). A mock with 3 named params + // matches no overload and should error. + var src = @" +test foo + mock inc(a, b, c) + a = 1 + endmock +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.MockParamCountNoMatchingOverload)), + Is.True, + "expected MockParamCountNoMatchingOverload; got: " + string.Join(", ", errs.Select(e => e.Display))); } + [Test] + public void Mock_StringParamName_ParseOk() + { + // String-suffixed param names (`s$`) must be accepted as identifiers. + // The actual type comes from the command metadata. + var src = @" +test foo + mock tuna_echo a, x$ + x$ = ""hello"" + endmock +endtest +"; + var prog = Parse(src, out var errs); + Assert.That(errs, Is.Empty, + "no parse errors expected; got: " + string.Join(", ", errs.Select(e => e.Display))); + var mock = FindFirstMock(prog); + Assert.That(mock.parameters.Count, Is.EqualTo(2)); + Assert.That(mock.parameters[1].variableName.ToLowerInvariant(), Does.Contain("x")); + } + [Test] public void Assert_OutsideTest_IsAllowed() { diff --git a/FadeBasic/Tests/MusicFbasicReproTests.cs b/FadeBasic/Tests/MusicFbasicReproTests.cs index ae61a51..55f5716 100644 --- a/FadeBasic/Tests/MusicFbasicReproTests.cs +++ b/FadeBasic/Tests/MusicFbasicReproTests.cs @@ -48,4 +48,66 @@ public void MusicFbasic_RunsCleanly() Assert.That(result.passed, Is.True, "expected pass; failure: " + result.failureMessage); } + + // Mirrors the user-reported scenario: mock the 1-arg overload of INPUT + // and verify it writes back through the ref param. INPUT has two + // overloads (`input(ref string)` and `input(string, ref string)`), so + // the compiler must filter to the 1-arg version based on param count + // — otherwise the body's prelude binds against the wrong signature + // and the VM stack underflows at dispatch. + // The user's "I expected this to error" scenario: mock the 1-arg + // ref overload of `input` but never assign `val$` in the body. The + // ref-not-assigned check should fire — but only if the visitor picks + // the OVERLOAD MATCHING the user's param count, not just overloads[0] + // (which for input is the 2-arg `(prompt, ref output)` form, where + // overload[0].arg0 is a value `prompt`, not a ref). + [Test] + public void InputOverloadMock_RefUnassigned_Errors() + { + var src = @" +input x$ +_L1: +end + +test sample + mock input(val$) + ` val$ never assigned + endmock + runto _L1 + assert x$ = ""toast"" +endtest +"; + var commands = new CommandCollection(new ConsoleCommands(), new StandardCommands()); + Fade.TryCreateFromString(src, commands, out _, out var errors); + var hasRefError = errors != null + && errors.ParserErrors.Any(e => e.errorCode.Equals(ErrorCodes.MockRefParamNotAssigned)); + Assert.That(hasRefError, Is.True, + "expected MockRefParamNotAssigned on `val$`; got: " + + (errors == null ? "(null errors)" : errors.ToDisplay())); + } + + [Test] + public void InputOverloadMock_WritesBackToCaller() + { + var src = @" +input x$ +_L1: +end + +test sample + mock input(val$) + val$ = ""toast"" + endmock + runto _L1 + assert x$ = ""toast"" +endtest +"; + var commands = new CommandCollection(new ConsoleCommands(), new StandardCommands()); + var ok = Fade.TryCreateFromString(src, commands, out var ctx, out var errors); + Assert.That(ok, Is.True, errors?.ToDisplay() ?? "(null errors)"); + + var result = ctx.RunTest("sample"); + Assert.That(result.passed, Is.True, + "expected pass; failure: " + result.failureMessage); + } } diff --git a/FadeBasic/Tests/ParserTests.cs b/FadeBasic/Tests/ParserTests.cs index 372b033..19e27a7 100644 --- a/FadeBasic/Tests/ParserTests.cs +++ b/FadeBasic/Tests/ParserTests.cs @@ -2074,7 +2074,7 @@ public void AssignmentWithCommandAndField() var code = prog.ToString(); Console.WriteLine(code); Assert.That(code, Is.EqualTo(@"( -(= (ref x),(+ ((ref a).(ref b)),(xcall len ((ref a).(ref c))))) +(= (ref x),(+ ((ref a).(ref b)),(len ((ref a).(ref c))))) )".ReplaceLineEndings(""))); } diff --git a/FadeBasic/Tests/TestFromChainTests.cs b/FadeBasic/Tests/TestFromChainTests.cs new file mode 100644 index 0000000..99382e6 --- /dev/null +++ b/FadeBasic/Tests/TestFromChainTests.cs @@ -0,0 +1,557 @@ +using FadeBasic; +using FadeBasic.Sdk; + +namespace Tests; + +[TestFixture] +public class TestFromChainTests +{ + // ── End-to-end runtime tests via the SDK runner ──────────────────────── + + private FadeRuntimeContext CreateContext(string src) + { + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out var ctx, out var errors); + Assert.That(ok, Is.True, + "expected clean compile; got: " + (errors == null ? "(null)" : errors.ToDisplay())); + return ctx; + } + + [Test] + public void FromChain_ChildSeesParentsRuntoState() + { + // The motivating case: child references a main-body variable that + // parent brought into view via runto. Without inheritance, this + // errors at the visitor and crashes at runtime; with the chain + // launcher, parent's runto runs first and `x` is in registers + // before child's assert reads it. + var src = @" +x = 3 +_L1: +end + +test sample + runto _L1 + assert x = 3 +endtest + +test sample2 from sample + assert x = 3 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("sample2"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ChildSeesParentMainBodyAssignment() + { + // Parent's runto brings a main-body variable into view; child + // reads it. Tests static visibility (visitor) + runtime persistence + // (shared registers) end-to-end. + var src = @" +foo = 42 +_L: +end + +test parent + runto _L +endtest + +test child from parent + assert foo = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ChildSeesParentTestLocal() + { + // Parent declares a test-local and assigns to it. Child references + // it directly. The base scope checker now walks chained tests in + // topological order, copying parent's scope state (locals + funcs) + // into the child's fresh scope before validating — the same way + // a test's sub-program already inherits from the outer program. + var src = @" +end + +test parent + local foo as integer + foo = 42 +endtest + +test child from parent + assert foo = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ChildInheritsMocksInstalledByParent() + { + // Parent installs a mock that overrides `screen width` to return + // 42. Child references `screen width()` directly. The mock survives + // into the child run because it lives in the VM's mockTable, which + // is wholly shared across chain segments. + var src = @" +end + +test parent + mock screen width + exitmock 42 + endmock +endtest + +test child from parent + assert screen width() = 42 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ChildSeesParentRuntoSideEffects() + { + // Parent's `runto` causes the main program to execute up to the + // label, including this `inc` call which writes to register `n`. + // Child should see n = 1 because parent's runto ran before child's + // body started. + var src = @" +n = 0 +inc n +_L1: +end + +test parent + runto _L1 +endtest + +test child from parent + assert n = 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_ParentAssertFailure_CascadesToChild() + { + // Parent fails an assert. The trampoline halts the VM mid-chain. + // Child's body never runs; the child run is reported as failed. + var src = @" +end + +test parent + assert 0, ""parent always fails"" +endtest + +test child from parent + assert 1 = 1 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("child"); + Assert.That(result.passed, Is.False, + "child should fail because parent's assert failed"); + Assert.That(result.failureMessage, Does.Contain("parent always fails"), + "failure should propagate parent's reason; got: " + result.failureMessage); + } + + [Test] + public void FromChain_ThreeLevelChain_RunsAllInOrder() + { + // A → B → C. Each ancestor mutates a register; the final assert + // proves all three segments ran in order, sharing state. + var src = @" +n = 0 +inc n +_L1: +end + +test a + runto _L1 +endtest + +test b from a + n = n + 10 +endtest + +test c from b + n = n + 100 + assert n = 111 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("c"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_StandaloneParent_StillRunsAlone() + { + // Running parent directly still works — its launcher is just + // [GOSUB self_body, HALT] with no ancestor links. Child's launcher + // points to a different address with the chain. + var src = @" +n = 5 +_L: +end + +test parent + runto _L + assert n = 5 +endtest + +test child from parent + assert n = 5 +endtest +"; + var ctx = CreateContext(src); + var parentResult = ctx.RunTest("parent"); + Assert.That(parentResult.passed, Is.True, parentResult.failureMessage); + var childResult = ctx.RunTest("child"); + Assert.That(childResult.passed, Is.True, childResult.failureMessage); + } + + [Test] + public void FromChain_AbstractParent_NotInRunnableList_ButInherited() + { + // Abstract tests aren't runnable directly but their body still runs + // as part of a child's chain. The manifest flags it isAbstract; + // the runner skips it for top-level execution but the launcher + // GOSUBs into its body all the same. + var src = @" +x = 100 +_L: +end + +abstract test setup + runto _L +endtest + +test concrete from setup + assert x = 100 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("concrete"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + + [Test] + public void FromChain_TwoSiblings_IsolatedAtStatic_ButShareRuntimeRegisters() + { + // Both `childA` and `childB` inherit from `parent`. Each gets its own + // fresh Scope at validation time (no leakage between siblings — the + // scope copy is one-way, parent → child). Runtime-wise they share + // the same global register file, so a sibling's locals collide if + // they have the same name — but that's already how cross-test state + // works in the existing language and isn't specific to chains. + // + // The test demonstrates static isolation: childA declares a local + // `siblingA_only`; childB declares `siblingB_only`. Neither sibling + // sees the other's local. (If isolation were broken, the visitor + // would let `siblingA_only` slip into childB and either spuriously + // accept it or alias to a parent-declared name.) + var src = @" +end + +test parent + local shared as integer = 5 +endtest + +test childA from parent + local siblingA_only as integer = 1 + assert shared = 5 + assert siblingA_only = 1 +endtest + +test childB from parent + local siblingB_only as integer = 2 + assert shared = 5 + assert siblingB_only = 2 +endtest +"; + var ctx = CreateContext(src); + var ra = ctx.RunTest("childA"); + Assert.That(ra.passed, Is.True, ra.failureMessage); + var rb = ctx.RunTest("childB"); + Assert.That(rb.passed, Is.True, rb.failureMessage); + } + + [Test] + public void FromChain_SiblingCannotSeeOtherSiblingsLocal() + { + // Static isolation check: childA declares `priv` as a local; childB + // shouldn't see it. If sibling state were leaking (e.g. via shared + // scope mutation during validation), childB's reference to `priv` + // would spuriously succeed. + var src = @" +end + +test parent +endtest + +test childA from parent + local priv as integer = 1 +endtest + +test childB from parent + n = priv +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "childB should NOT see childA's local — they're siblings, not parent/child"); + Assert.That(errors.ParserErrors.Any(e => e.Display.Contains("priv")), + Is.True, + "expected an unknown-symbol error mentioning `priv`; got: " + errors.ToDisplay()); + } + + [Test] + public void FromChain_ParentDefer_RunsAfterChildBody() + { + // The motivating bug. Parent registers a DEFER (teardown) inside + // its body. With per-body defer drains, teardown fires at parent's + // RETURN — i.e., BEFORE the child runs — which is semantically + // wrong (child is supposed to be a continuation of parent). + // + // Expected order: child body runs first, THEN parent's defer. + // The shared deferredJumps stack accumulates parent's defer + // during the chain; the launcher's tail drains it after every + // body has run. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +counter = 0 +_L1: +do + counter = counter + 1 + _L2: +loop + +abstract test parent + defer + static print ""teardown"" + enddefer +endtest + +test sample from parent + runto _L1 + + while counter < 3 + static print ""looping"" + runto _L2 + endwhile + + static print str$(counter) +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("sample"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, + Is.EqualTo(new[] { "looping", "looping", "looping", "3", "teardown" }), + "parent's defer should fire AFTER the child's body, not before"); + } + + [Test] + public void FromChain_TwoLevelDefers_LIFOAcrossChain() + { + // Parent A and parent B both register defers. C runs in the + // middle. At chain end, defers drain LIFO — B's first, then A's. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +abstract test a + defer + static print ""a_teardown"" + enddefer +endtest + +abstract test b from a + defer + static print ""b_teardown"" + enddefer +endtest + +test c from b + static print ""body"" +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("c"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, + Is.EqualTo(new[] { "body", "b_teardown", "a_teardown" }), + "defers should drain LIFO at chain end: child body, then b's defer, then a's defer"); + } + + [Test] + public void FromChain_StandaloneDefer_StillDrainsAtTestEnd() + { + // No `from`-parent — just a normal test with a defer. The defer + // should still fire when the test body completes (after the + // body's other statements). Confirms the launcher-tail drain + // works for the simple case. + TestCommands.staticPrintBuffer.Clear(); + var src = @" +end + +test solo + defer + static print ""teardown"" + enddefer + static print ""body"" +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("solo"); + Assert.That(result.passed, Is.True, result.failureMessage); + Assert.That(TestCommands.staticPrintBuffer, + Is.EqualTo(new[] { "body", "teardown" }), + "standalone defer should fire after body — same as before"); + } + + // ── Compile-time validation: cycles and unknown parents ──────────────── + + [Test] + public void FromChain_UnknownParent_Errors() + { + var src = @" +end + +test child from nonexistent + assert 1 = 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure when fromParent doesn't name a test"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.TestFromParentUnknown)), + Is.True, + "expected TestFromParentUnknown; got: " + errors.ToDisplay()); + } + + [Test] + public void FromChain_DirectCycle_Errors() + { + // `selfref` from itself — simplest self-cycle. (Avoiding `loop` + // as a test name because it collides with the do/loop keyword.) + var src = @" +end + +test selfref from selfref + assert 1 = 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure on self-from cycle"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.TestFromParentCycle)), + Is.True, + "expected TestFromParentCycle; got: " + errors.ToDisplay()); + } + + [Test] + public void TestNames_DuplicateAcrossTopLevel_Errors() + { + // Two tests sharing a name confuse the runner's manifest lookup + // and obscure intent. Surface a clean compile error at the second + // (and any further) occurrence; the first one keeps the name. + var src = @" +end + +test N + assert 1 +endtest + +test N + assert 0 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure when two tests share a name"); + var dupes = errors.ParserErrors + .Where(e => e.errorCode.Equals(ErrorCodes.TestDuplicateName)) + .ToList(); + Assert.That(dupes, Has.Count.EqualTo(1), + "exactly one duplicate flagged (first occurrence keeps the name); got: " + + errors.ToDisplay()); + Assert.That(dupes[0].message, Does.Contain("N"), + "error detail should name the offending test; got: " + dupes[0].Display); + } + + [Test] + public void TestNames_DuplicateCaseInsensitive_Errors() + { + // Lookups (FindTestByName, runner manifest) are case-insensitive, + // so `test Foo` + `test foo` collide just as much as two `Foo`s. + var src = @" +end + +test Foo + assert 1 +endtest + +test foo + assert 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure on case-insensitive duplicate names"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.TestDuplicateName)), + Is.True, + "expected TestDuplicateName; got: " + errors.ToDisplay()); + } + + [Test] + public void FromChain_IndirectCycle_Errors() + { + // A from B, B from C, C from A — three-node cycle. + var src = @" +end + +test a from c + assert 1 = 1 +endtest + +test b from a + assert 1 = 1 +endtest + +test c from b + assert 1 = 1 +endtest +"; + var ok = Fade.TryCreateFromString(src, TestCommands.CommandsForTesting, + out _, out var errors); + Assert.That(ok, Is.False, + "expected compile failure on indirect cycle"); + Assert.That(errors.ParserErrors.Any( + e => e.errorCode.Equals(ErrorCodes.TestFromParentCycle)), + Is.True, + "expected TestFromParentCycle; got: " + errors.ToDisplay()); + } +} diff --git a/FadeBasic/Tests/TestScopeStrictnessTests.cs b/FadeBasic/Tests/TestScopeStrictnessTests.cs index dadea86..d929356 100644 --- a/FadeBasic/Tests/TestScopeStrictnessTests.cs +++ b/FadeBasic/Tests/TestScopeStrictnessTests.cs @@ -526,4 +526,40 @@ runto labelB "test `bDoesntSeeA` should error referencing aLocal at labelB; errors: " + string.Join(", ", errs.Select(e => e.Display))); } + + [Test] + public void Strictness_TestFromParent_InheritsParentRuntoScope() + { + // `test sample2 from sample` should inherit sample's runto-scope + // position so any main-body names sample had brought into view via + // runto are also visible inside sample2. Without this, the child + // test can't reference anything the parent unlocked, which defeats + // the point of `from`. + // + // STATUS: this currently fails — `fromParent` is parsed and stored + // (TestNode.fromParent, surfaced in the test manifest) but the + // strictness visitor doesn't propagate the parent's runto-visible + // set to the child. The child starts fresh with globals only and + // flags `x` as unreachable. + var src = @" +x = 3 +_L1: +end + +test sample + runto _L1 + assert x = 3 +endtest + +test sample2 from sample + print ""hahahah"" + assert x = 3 +endtest +"; + Parse(src, out var errs); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.TestVariableUnreachable)), + Is.False, + "expected `x` to be visible in `sample2` via `from sample`'s runto inheritance; got: " + + string.Join(", ", errs.Select(e => e.Display))); + } } diff --git a/FadeBasic/book/FadeBook/Language.md b/FadeBasic/book/FadeBook/Language.md index 34cba97..7fd799c 100644 --- a/FadeBasic/book/FadeBook/Language.md +++ b/FadeBasic/book/FadeBook/Language.md @@ -1384,7 +1384,7 @@ ENDTEST ` a ``` -Instead, test blocks are run as distinct top level programs using the `dotnet test` command. #TODO, insert guide to this. `--logger "console;verbosity=detailed"` +Instead, test blocks are run as distinct top level programs. When `dotnet test` is run, all of the test blocks will run in sequence, in the order they are defined in the program. ```basic TEST tuna @@ -1399,7 +1399,7 @@ ENDTEST ` b ``` -Test blocks have a unique access to the scope of the main program. They can _access_ variables defined in the global [scope](#scopes) of the program, but by default, they do not have access to local scoped variables. However, the test program does not run the main program, so global values are _declared_, but they will not have their actual assigned values. +Test blocks have a unique access to the scope of the main program. They can _access_ variables defined in the global [scope](#scopes) of the program, but by default, they do not have access to local scoped variables. However, the test program does not run the main program, so global values are _declared_, but they will not have their actual assigned values. In other words, the test starts executing before the main program. ```basic GLOBAL x = 42 TEST sample @@ -1433,7 +1433,7 @@ ENDTEST ` end ``` -The `RUNTO` statement can take an optional `MAX CYCLES` cluase followed by an integer expression. The value represents the max number of instructions that the _Fade Basic_ virtual machine will allow before causing the test to _fail_. For example, if the main program would never reach the desired label, the `MAX CYCLES` clause can be used to prevent the test from running forever. +The `RUNTO` statement can take an optional `MAX CYCLES` clause followed by an integer expression. The value represents the max number of instructions that the _Fade Basic_ virtual machine will allow before causing the test to _fail_. For example, if the main program would never reach the desired label, the `MAX CYCLES` clause can be used to prevent the test from running forever. ```basic WHILE 1 `loop forever @@ -1452,10 +1452,62 @@ ENDTEST ` ``` +A label can be `RUNTO` multiple times, if it is in a loop. +```basic +counter = 0 +DO + counter = counter + 1 + _L1: +LOOP + +TEST sample + RUNTO _L1 + PRINT counter + + PRINT "Mid Test" + + RUNTO _L1 + PRINT counter +ENDTEST + +`test output +` 1 +` Mid Test +` 2 +``` + +The `RUNTO` syntax can be combined with other _Fade Basic_ language primitives to make conditional `RUNTO` sections. +```basic +counter = 0 +_L1: +DO + counter = counter + 1 + _L2: +LOOP + +TEST sample + RUNTO _L1 + + WHILE counter < 3 + PRINT "looping" + RUNTO _L2: + ENDWHILE + + PRINT counter + +ENDTEST + +`test output +` looping +` looping +` looping +` 3 +``` + ---- #### Test Scope -By default, test blocks can only reference global scope from the main program. However, anytime a `RUNTO` statement resumes execution into the test program, the current available scope in the test block is equivilent to the scope from where the main program was paused. +By default, test blocks can only reference global scope from the main program. However, anytime a `RUNTO` statement resumes execution into the test program, the current available scope in the test block is equivalent to the scope from where the main program was paused. ```basic x = 42 _L1: @@ -1554,10 +1606,23 @@ ENDTEST ` 1 ``` +Each test can declare variables in isolation of _other_ tests. +```basic +TEST t1 + LOCAL x = 4 + PRINT x +ENDTEST + +TEST t2 + LOCAL x = 80 + PRINT x +ENDTEST +``` + ---- #### Asserts -A test block can use the `ASSERT` statement to cause a test to pass or fail. An `ASSERT` statement must be followed by an expression that resolves to a boolean. When the expresion resolves to a truthy value, the assert statement is valid. Otherwise, the assert statement is considered _invalid_. The test block will stop executing and be marked as a failure at the first invalid `ASSERT` statement. +A test block can use the `ASSERT` statement to cause a test to pass or fail. An `ASSERT` statement must be followed by an expression that resolves to a boolean. When the expression resolves to a truthy value, the assert statement is valid. Otherwise, the assert statement is considered _invalid_. The test block will stop executing and be marked as a failure at the first invalid `ASSERT` statement. ```basic TEST sample x = 1 @@ -1590,8 +1655,8 @@ ENDTEST `ASSERT` statements can optionally include a _reason_ phrase after the condition. The _reason_ phrase will be included the output if the assert ever fails. These are useful for adding documentation to failed assertions. ```basic TEST sample - x = 12 - ASSERT x > 100, "x should be greater than zero" + x = -12 + ASSERT x > 0, "x should be greater than zero" ENDTEST ``` @@ -1688,16 +1753,360 @@ ENDFUNCTION x ---- #### Mocks +During a test block, it is possible to change what happens when a [command](#commands) executes. This is called _mocking_. + +To create a mock, use the `MOCK` block. The name of the command must follow immediately after the opening `MOCK` keyword. The block must end with an `ENDMOCK` keyword. +```basic +WAIT MS(1000) `WAIT MS() waits for the given number of milliseconds +_L1: +TEST sample + `mock the WAIT MS(1000) so that the test does not need to wait at all. + MOCK WAIT MS + PRINT "simulating instant wait." + ENDMOCK + RUNTO _L1 +ENDTEST + +`test output +` simulating instant wait. +``` + +If the mocked command returns a value, then the `ENDMOCK` statement must be followed by a mocked return value. +```basic +x = TIMER() `TIMER() is a command that returns the number of milliseconds since the program started. +_L1: +TEST sample + MOCK TIMER + ENDMOCK 42 + + RUNTO _L1 + ASSERT x = 42 +ENDTEST +``` + +The mock block may exit early with the `EXITMOCK` keyword. If the command is supposed to return a value, the `EXITMOCK` keyword must be followed by the mocked value. +```basic +x = TIMER() `TIMER() is a command that returns the number of milliseconds since the program started. +_L1: +TEST sample + MOCK TIMER + EXITMOCK 11 + ENDMOCK 42 + + RUNTO _L1 + ASSERT x = 11 +ENDTEST +``` + +Mock blocks can access global scope from the test block. +```basic +x = TIMER() `TIMER() is a command that returns the number of milliseconds since the program started. +_L1: +TEST sample + GLOBAL t = 42 + MOCK TIMER + ENDMOCK t + + RUNTO _L1 + ASSERT x = t +ENDTEST +``` + +The mock block can access the parameters passed to the command. +```basic +WAIT MS(1000) +_L1: +TEST sample + MOCK WAIT MS(time) + PRINT "simulating wait for " + str$(time) + ENDMOCK + RUNTO _L1 +ENDTEST + +`test output +` simulating wait for 1000 +``` + +When the command has parameters that must be assigned, the mock block must assign them. +```basic +`normally, the INPUT command accepts a line of input from the terminal, and puts the value in x$ +INPUT "enter name", x$ +_L1: +TEST sample + MOCK INPUT(_, val$) + `override the terminal input so the test does not need the user to type anything + val$ = "mr tuna" + ENDMOCK + RUNTO _L1 + + ASSERT x$ = "mr tuna" +ENDTEST +``` + +Instead of returning a value and setting out parameters, a mocked command can use the `FORBID` keyword to signal that the command must not be called at all. +```basic +WAIT MS(1000) +_L1: + +TEST sample + MOCK WAIT MS + FORBID `if the WAIT MS command is called at all, the test will fail. + ENDMOCK + + RUNTO _L1 +ENDTEST + +`the test will fail. +``` + +It is not valid to put a `FORBID` statement as a sub statement in a mock block. It must be part of the top level scope. + +Once a command is mocked, the mock will be called for every call to the command. +```basic +FOR n = 1 to 3 + WAIT MS(n) +NEXT + +_L1: + +TEST sample + MOCK WAIT MS(n) + PRINT "waiting: " + str$(n) + ENDMOCK + RUNTO _L1 +ENDTEST + +`test output +` waiting: 1 +` waiting: 2 +` waiting: 3 +``` + +Any mocked command can be overridden if a new mock block targets the command. +```basic +FOR n = 1 to 3 + WAIT MS(n) + _L1: +NEXT + +TEST sample + MOCK WAIT MS(n) + PRINT "a: " + str$(n) + ENDMOCK + RUNTO _L1 + + MOCK WAIT MS(n) + PRINT "b: " + str$(n) + ENDMOCK + RUNTO _L1 + + MOCK WAIT MS(n) + PRINT "c: " + str$(n) + ENDMOCK + RUNTO _L1 +ENDTEST + +`test output +` a: 1 +` b: 2 +` c: 3 +``` + +The `CLEAR MOCK` syntax will remove any mocks for the given command. The name of the command must follow. +```basic +FOR n = 1 to 3 + WAIT MS(n) + _L1: +NEXT + +_L2: + +TEST sample + MOCK WAIT MS(n) + PRINT "a: " + str$(n) + ENDMOCK + RUNTO _L1 + + CLEAR MOCK WAIT MS + RUNTO _L2 +ENDTEST + +`test output +` a: 1 +``` ---- #### Child Tests +A test block can use the `FROM` keyword to pick up where a previous test block ended. +```basic +TEST parent + x = 1 +ENDTEST + +TEST child FROM parent + assert x = 1 +ENDTEST + +`in this case, there are 2 successful tests, parent, and child. +``` + +A test block can be prefixed with the `ABSTRACT` keyword to prevent it from running or counting as a test. However, an abstract test can still be used as a parent. +```basic +ABSTRACT TEST parent + x = 1 +ENDTEST + +TEST child FROM parent + assert x = 1 +ENDTEST + +`now there is only one runnable test, child +``` + +When a test starts from the end of a previous test, it will inherit the current scope semantics. +```basic +x = 42 +_L1: + +ABSTRACT TEST parent + RUNTO _L1 +ENDTEST + +TEST child FROM parent + assert x = 42 `x is in scope, because the parent ran to _L1 +ENDTEST +``` + +A test will inherit function declarations and mocks from a parent test. +```basic +WAIT MS(1000) +x = 42 +_L1: + +ABSTRACT TEST parent + + ` configure mock for children. + MOCK WAIT MS + ENDMOCK + + FUNCTION add(a, b) + ENDFUNCTION a + b +ENDTEST + +TEST child FROM parent + RUNTO _L1 + + ` use function from parent + n = add(21, 21) + assert x = n +ENDTEST +``` + +When multiple test blocks continue from the same parent, they each get an isolated continuation. Modifications from one child test will not modify the initial parent scope for the next child test. +```basic +ABSTRACT TEST parent + x = 1 +ENDTEST + +TEST child1 FROM parent + x = x + 1 + assert x = 2 +ENDTEST + +TEST child2 FROM parent + x = x + 2 + assert x = 3 +ENDTEST + +TEST child3 FROM parent + x = x + 3 + assert x = 4 +ENDTEST +``` + +Deferred statements in a parent test run as if they were deferred from a child test. This can be used to set up re-usable teardown. +```basic +counter = 0 +_L1: +DO + counter = counter + 1 + _L2: +LOOP + +ABSTRACT TEST parent + DEFER + PRINT "teardown" + ENDDEFER +ENDTEST + +TEST sample FROM parent + RUNTO _L1 + + WHILE counter < 3 + PRINT "looping" + RUNTO _L2: + ENDWHILE + + PRINT counter + +ENDTEST + +`test output +` teardown +` looping +` looping +` looping +` 3 + +``` + + ---- -#### Testing Edge Cases -- defer statements? -- type definitions? -- +#### Call Counts +The `CALL COUNT` keyword allows a test block to get the number of times a command has been invoked, since the start of the test. +```basic +FOR n = 1 to 3 + WAIT MS(n) +NEXT + +_L1: + +TEST sample + RUNTO _L1 + x = CALL COUNT WAIT MS + ASSERT x = 3 +ENDTEST +``` + +The `CALL COUNT` is not reset between parent and child test runs. +```basic + +ABSTRACT TEST parent + WAIT MS(1) +ENDTEST + +TEST sample FROM parent + WAIT MS(1) + PRINT CALL COUNT WAIT MS +ENDTEST +`test output +` 2 +``` ---- #### Calling Tests -- quirks with `dotnet test` + +Tests can be invoked from the command line in 2 ways, either through `dotnet test` to integrate with the dotnet ecosystem, or directly through reserved program flags using `dotnet run`. + +| Action | `dotnet test` | `dotnet run` | +| :----- | :------------ | :----------- | +| Run all tests | `dotnet test` | `dotnet run --fade-test-all` | +| List all tests | `dotnet test --list-tests` | `dotnet run --fade-list-tests` | +| Run a test | | `dotnet run --fade-test ` | + +> [!TIP] +> By default, `dotnet test` hides the standard out logging during testing. To see it, you must include the `--logger` switch. +> ```bash +> dotnet test --logger "console;verbosity=detailed" +> ``` From 68484e00beab612ac85d02586cee3dd53b2db91a Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Sun, 17 May 2026 21:04:09 -0400 Subject: [PATCH 07/30] some fixes --- .../Project/ProjectBuilder.cs | 49 +++++++------- FadeBasic/FadeBasic.sln | 2 +- FadeBasic/FadeBasic/Sdk/Fade.cs | 16 ++--- FadeBasic/FadeBasic/Virtual/Compiler.cs | 65 +++++++++++++++---- FadeBasic/Tests/DapIntegrationTests.cs | 27 -------- FadeBasic/Tests/LenKeywordTests.cs | 14 ++-- FadeBasic/Tests/TestExecutionTests.cs | 16 +++-- FadeBasic/Tests/TestFromChainTests.cs | 58 +++++++++++++++++ FadeBasic/Tests/TestFunctionTests.cs | 6 +- FadeBasic/build.sln | 2 +- 10 files changed, 168 insertions(+), 87 deletions(-) diff --git a/FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs b/FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs index ba77ef3..cf63286 100644 --- a/FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs +++ b/FadeBasic/ApplicationSupport/Project/ProjectBuilder.cs @@ -239,39 +239,40 @@ public static (ProjectCommandInfo, AssemblyLoadContext) LoadCommands(string libP var sources = new List(); var loadContext = new AssemblyLoadContext("metadata", isCollectible: true); - // TODO: technically there could be multiple lib paths, right? one for each library? - // var libPath = Path.GetDirectoryName(libraries[0].absoluteOutputDllPath); - // var libPath = AppContext.BaseDirectory; - + // Probe the consumer's TargetDir (libPath) AND the directory of each + // loaded library DLL. A macro command in lib A can call into a sibling + // assembly B that A project-references; B sits next to A in A's own bin + // (or in the NuGet lib/ folder), but on a clean build it has not yet + // been copied into the consumer's TargetDir when this resolver runs. + var probeDirs = new List { libPath }; + foreach (var lib in libraries) + { + var dir = Path.GetDirectoryName(lib.absoluteOutputDllPath); + if (!string.IsNullOrEmpty(dir) && !probeDirs.Contains(dir)) + probeDirs.Add(dir); + } + loadContext.Resolving += (assemblyContext, assemblyName) => { if (assemblyName.FullName == typeof(IMethodSource).Assembly.GetName().FullName) { - // log("!!! Trying to load common assembly."); return typeof(IMethodSource).Assembly; } - - - // log($"!!! Requested: [{assemblyName.FullName}]"); - //log($"!!! Compared: [{typeof(IMethodSource).Assembly.GetName().FullName}]"); - - string candidatePath = Path.Combine( - libPath, - assemblyName.Name + ".dll"); - // log($"!!! candidate-path=[{candidatePath}]"); - - if (!File.Exists(candidatePath)) - return null; + foreach (var dir in probeDirs) + { + var candidatePath = Path.Combine(dir, assemblyName.Name + ".dll"); + if (!File.Exists(candidatePath)) + continue; - var foundName = AssemblyName.GetAssemblyName(candidatePath); - // log($"!!! candidate-name=[{foundName.Name}] vs requested=[{assemblyName.Name}]"); - - if (foundName.Name != assemblyName.Name) - return null; + var foundName = AssemblyName.GetAssemblyName(candidatePath); + if (foundName.Name != assemblyName.Name) + continue; + + return assemblyContext.LoadIntoMemory(candidatePath); + } - // log($"!!! Proxied: [{candidatePath}]"); - return assemblyContext.LoadIntoMemory(candidatePath); + return null; }; using var _ = loadContext.EnterContextualReflection(); diff --git a/FadeBasic/FadeBasic.sln b/FadeBasic/FadeBasic.sln index ac355b5..22e73c8 100644 --- a/FadeBasic/FadeBasic.sln +++ b/FadeBasic/FadeBasic.sln @@ -1,6 +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}" diff --git a/FadeBasic/FadeBasic/Sdk/Fade.cs b/FadeBasic/FadeBasic/Sdk/Fade.cs index 834a579..2351409 100644 --- a/FadeBasic/FadeBasic/Sdk/Fade.cs +++ b/FadeBasic/FadeBasic/Sdk/Fade.cs @@ -37,9 +37,9 @@ public static List GetFadeFilesFromProject(string csProjPath) public static bool TryCreateFromProject( - string csProjPath, - CommandCollection availableCommands, - out FadeRuntimeContext context, + string csProjPath, + CommandCollection availableCommands, + out FadeRuntimeContext context, out FadeErrors errors) { context = null; @@ -91,10 +91,10 @@ public static bool TryCreateFromProject( return TryCreateFromString(sourceMap.fullSource, commandCollection, out context, out errors, sourceMap); } - + public static bool TryCreateFromString( - string src, - CommandCollection commands, + string src, + CommandCollection commands, out FadeRuntimeContext context, out FadeErrors errors, SourceMap map=null) @@ -759,8 +759,8 @@ private bool TryFindVariable(string name, out DebugVariable variable, out Virtua return false; } - public static bool TryFromSource(string src, - CommandCollection commands, + public static bool TryFromSource(string src, + CommandCollection commands, out FadeRuntimeContext context, out FadeErrors errors, SourceMap map = null) diff --git a/FadeBasic/FadeBasic/Virtual/Compiler.cs b/FadeBasic/FadeBasic/Virtual/Compiler.cs index d5286b9..677535e 100644 --- a/FadeBasic/FadeBasic/Virtual/Compiler.cs +++ b/FadeBasic/FadeBasic/Virtual/Compiler.cs @@ -109,6 +109,11 @@ public void ProcessJson(IJsonOperation op) public struct LabelReplacement { public int InstructionIndex; + // Region-prefixed label key (see Compiler.MakeLabelKey). Built + // at emit time from the current label region + the user-written + // label name so two tests / two functions with same-named labels + // resolve independently. Runto replacements use the main-body + // region prefix regardless of where the `runto X` was written. public string Label; } @@ -424,7 +429,23 @@ public class Compiler private Stack> _skipInstructionIndexes = new Stack>(); private List _labelReplacements = new List(); + // Keyed by region-prefixed label name. Region is empty for main + // body, "test:" inside a test body, "fn:" inside a + // function body. Each region has its own label namespace so two + // tests / two functions can share label names without collision. private Dictionary _labelToInstructionIndex = new Dictionary(); + // The region currently being compiled. Compile(LabelDeclarationNode) + // builds the key from this region; Compile(GotoStatement) / + // Compile(GoSubStatement) stamp the region-prefixed key into the + // emitted replacement so resolution stays scoped. + private string _currentLabelRegion = ""; + + // Compose a label dictionary key from a region + user-written + // label name. The `::` separator can't appear in either piece + // (region names are compiler-generated, label names are restricted + // to identifier characters) so the encoding is unambiguous. + private static string MakeLabelKey(string region, string label) + => (region ?? "") + "::" + label; // For each `runto label` call site, record where in the bytecode the // PUSH int placeholder lives so we can patch the resolved post-yield @@ -604,6 +625,13 @@ private void CompileTestBody(TestNode test, bodyAddresses[test.name] = bodyStart; } + // Set the label region for this test so two tests with same- + // named labels don't collide at jump-replacement time. Restore + // on the way out — nested compile of test-scoped functions + // below will set their own regions over this. + var prevRegion = _currentLabelRegion; + _currentLabelRegion = "test:" + (test.name ?? ""); + // Compile the test body. The dispatch in Compile(IStatementNode) skips // FunctionStatement nodes — they're emitted separately below — so the // body's own function declarations don't pollute the test's entry-point @@ -627,6 +655,10 @@ private void CompileTestBody(TestNode test, // behavior — the launcher's drain runs after a single body. _buffer.Add(OpCodes.RETURN); + // Restore the label region. Test-scoped functions emitted below + // re-set their own region inside Compile(FunctionStatement). + _currentLabelRegion = prevRegion; + // Now compile any test-scoped functions. They live alongside program // functions in the bytecode blob and register themselves in the // shared _functionTable, which means the test body can call them by @@ -720,7 +752,6 @@ public void CompileJumpReplacements() // replace all label instructions... foreach (var replacement in _labelReplacements) { - // TODO: look up in labelTable if (!_labelToInstructionIndex.TryGetValue(replacement.Label, out var location)) { throw new Exception("Compiler: unknown label location " + replacement.Label); @@ -738,10 +769,13 @@ public void CompileJumpReplacements() // the byte AFTER the label's RUNTO_YIELD opcode (= label_addr + 2). // RUNTO_YIELD checks `runtoStack.Peek().target == instructionIndex`, // and instructionIndex at that point is post-RUNTO_YIELD, so we need - // to bake `label_addr + 2` into the PUSH int placeholder. + // to bake `label_addr + 2` into the PUSH int placeholder. Runto + // always targets MAIN-BODY labels regardless of where `runto X` + // was written, so the lookup uses the main-body region prefix. foreach (var replacement in _runtoReplacements) { - if (!_labelToInstructionIndex.TryGetValue(replacement.Label, out var location)) + var mainKey = MakeLabelKey("", replacement.Label); + if (!_labelToInstructionIndex.TryGetValue(mainKey, out var location)) { throw new Exception("Compiler: unknown runto target label " + replacement.Label); } @@ -1296,7 +1330,13 @@ private void Compile(FunctionStatement functionStatement) // push a new scope CompilePushScope(); - + + // Labels inside this function get their own region — two + // functions can share label names without resolving to each + // other's body. Restored at function end. + var prevRegion = _currentLabelRegion; + _currentLabelRegion = "fn:" + functionStatement.name; + // now, we need to pull values off the stack and put them into variable declarations... // foreach (var arg in functionStatement.parameters) for (var i = functionStatement.parameters.Count - 1; i >= 0; i --) // read in reverse order due to stack @@ -1345,12 +1385,13 @@ private void Compile(FunctionStatement functionStatement) // at the end of the function, we need to jump home // pop a scope CompilePopScope(); - + // and then jump home _buffer.Add(OpCodes.RETURN); - + + _currentLabelRegion = prevRegion; } - + private void Compile(ExitLoopStatement exitLoopStatement) { // immediately jump to the exit... @@ -1863,7 +1904,7 @@ private void Compile(IfStatement ifStatement) private void Compile(LabelDeclarationNode labelStatement) { // take note of instruction number... - _labelToInstructionIndex[labelStatement.label] = _buffer.Count; + _labelToInstructionIndex[MakeLabelKey(_currentLabelRegion, labelStatement.label)] = _buffer.Count; _buffer.Add(OpCodes.NOOP); // Emit RUNTO_YIELD only for labels that some test targets via `runto`. // In `dotnet run` builds where no tests exist, this set is empty and @@ -2691,20 +2732,20 @@ private void Compile(GoSubStatement goSubStatement) _labelReplacements.Add(new LabelReplacement { InstructionIndex = _buffer.Count, - Label = goSubStatement.label + Label = MakeLabelKey(_currentLabelRegion, goSubStatement.label) }); AddPushInt(_buffer, int.MaxValue); _buffer.Add(OpCodes.JUMP_HISTORY); - + } - + private void Compile(GotoStatement gotoStatement) { // identify the instruction ID of the label _labelReplacements.Add(new LabelReplacement { InstructionIndex = _buffer.Count, - Label = gotoStatement.label + Label = MakeLabelKey(_currentLabelRegion, gotoStatement.label) }); AddPushInt(_buffer, int.MaxValue); _buffer.Add(OpCodes.JUMP); diff --git a/FadeBasic/Tests/DapIntegrationTests.cs b/FadeBasic/Tests/DapIntegrationTests.cs index 96adf61..4135ff0 100644 --- a/FadeBasic/Tests/DapIntegrationTests.cs +++ b/FadeBasic/Tests/DapIntegrationTests.cs @@ -162,33 +162,6 @@ await SendReverseResponse(ritReq["seq"]!.GetValue(), "runInTerminal", // and by the StoppedEventIncludesThreadId source-level test. } - [Test] - public async Task StoppedEventIncludesThreadId() - { - // This test verifies the DAP adapter sends threadId in stopped events. - // We check this by reading the adapter source, but also verify via the - // protocol that the field serializes correctly. - // - // The stopped event JSON from the last real session log was: - // {"type":"event","event":"stopped","body":{"reason":"breakpoint", - // "description":"Hit a breakpoint","threadId":1,"allThreadsStopped":true, - // "hitBreakpointIds":[0]}} - // - // Verify the DAP source has ThreadId = 1 in both HitBreakpointCallback locations - var dapSource = File.ReadAllText(Path.GetFullPath(Path.Combine( - TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "DAP", "FadeDebugAdapter.cs"))); - - var hitCallbackMatches = System.Text.RegularExpressions.Regex.Matches( - dapSource, @"HitBreakpointCallback\s*="); - Assert.That(hitCallbackMatches.Count, Is.GreaterThanOrEqualTo(2), - "Expected at least 2 HitBreakpointCallback assignments (launch + attach)"); - - var threadIdMatches = System.Text.RegularExpressions.Regex.Matches( - dapSource, @"ThreadId\s*=\s*1"); - Assert.That(threadIdMatches.Count, Is.GreaterThanOrEqualTo(2), - "Both HitBreakpointCallback locations must set ThreadId = 1"); - } - [Test] public async Task RunInTerminalEnvPortIsUsable() { diff --git a/FadeBasic/Tests/LenKeywordTests.cs b/FadeBasic/Tests/LenKeywordTests.cs index 7dd2af4..054b5ad 100644 --- a/FadeBasic/Tests/LenKeywordTests.cs +++ b/FadeBasic/Tests/LenKeywordTests.cs @@ -1,5 +1,6 @@ using FadeBasic; using FadeBasic.Ast; +using FadeBasic.Sdk; using FadeBasic.Virtual; namespace Tests; @@ -71,12 +72,15 @@ public void Len_EmptyString_ReturnsZero() var src = @" n = len("""") "; - var vm = RunMain(src); + var (compiler, program) = Compile(src); + var vm = new VirtualMachine(program) { hostMethods = compiler.methodTable }; + vm.Execute3(); + Assert.That(vm.error.type, Is.EqualTo(VirtualRuntimeErrorType.NONE)); - // The default for n (an int that gets assigned 0) is already 0, so - // we can't distinguish from "uninitialized" by just searching for - // the value. But no runtime error proves the path didn't trip on - // an empty allocation. + Assert.That(compiler.globalScope.TryGetVariable("n", out var nVar), Is.True, + "compiler should have allocated a register for `n`"); + Assert.That(vm.globalScope.dataRegisters[nVar.registerAddress], Is.EqualTo(0UL), + "expected len(\"\") to write 0 into n's register"); } [Test] diff --git a/FadeBasic/Tests/TestExecutionTests.cs b/FadeBasic/Tests/TestExecutionTests.cs index 4dbd748..19b15e5 100644 --- a/FadeBasic/Tests/TestExecutionTests.cs +++ b/FadeBasic/Tests/TestExecutionTests.cs @@ -188,6 +188,9 @@ public void Execute_NormalProgram_StillRunsUnchanged() [Test] public void Execute_GlobalVariables() { + // `GLOBAL x = 32` lives inside the test body, so `x` is scoped to + // the test — main-body code cannot see it. Referencing `x` from + // the main program is a hard parse error. var src = @" test foo GLOBAL x = 32 @@ -195,9 +198,14 @@ test foo print x `this should result in an error. "; - var (compiler, program) = Compile(src); - var vm = new VirtualMachine(program); - vm.hostMethods = compiler.methodTable; - vm.Execute3(); + var lex = new Lexer().TokenizeWithErrors(src, TestCommands.CommandsForTesting); + lex.AssertNoLexErrors(); + var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); + var prog = parser.ParseProgram(); + var errs = prog.GetAllErrors(); + Assert.That(errs.Any(e => e.errorCode.Equals(ErrorCodes.SymbolNotDeclaredYet)), + Is.True, + "expected SymbolNotDeclaredYet on the main-body `print x` reference; got: " + + string.Join("; ", errs.Select(e => e.Display))); } } diff --git a/FadeBasic/Tests/TestFromChainTests.cs b/FadeBasic/Tests/TestFromChainTests.cs index 99382e6..eca42bf 100644 --- a/FadeBasic/Tests/TestFromChainTests.cs +++ b/FadeBasic/Tests/TestFromChainTests.cs @@ -425,6 +425,64 @@ static print ""body"" "standalone defer should fire after body — same as before"); } + [Test] + public void LabelScoping_SameLabelInTwoTests_DoesNotCollide() + { + // Bug repro: each test's `retry_done` label was sharing a global + // dictionary entry, so test alpha's `goto retry_done` resolved to + // beta's label and execution fell into beta's `assert 0`. + // Both tests should resolve their labels independently — alpha + // passes, beta fails as intended. + var src = @" +end + +test alpha +retry: + goto retry_done +retry_done: +endtest + +test beta +retry: + goto retry_done +retry_done: + assert 0, ""boooo"" +endtest +"; + var ctx = CreateContext(src); + var alpha = ctx.RunTest("alpha"); + Assert.That(alpha.passed, Is.True, + "alpha has no failing assert — should pass; got: " + alpha.failureMessage); + var beta = ctx.RunTest("beta"); + Assert.That(beta.passed, Is.False, + "beta's assert 0 must fail when its OWN label was reached"); + Assert.That(beta.failureMessage, Does.Contain("boooo"), + "beta should fail with its own message; got: " + beta.failureMessage); + } + + [Test] + public void LabelScoping_RuntoFromTest_StillFindsMainBodyLabel() + { + // Sanity check: even though labels are region-scoped, runto from + // a test resolves against the main-body region. Otherwise the + // visitor would flag it and the compiler would fail to bake the + // runto target's address. + var src = @" +n = 0 +_pause: +n = n + 5 +end + +test foo + runto _pause + assert n = 0 +endtest +"; + var ctx = CreateContext(src); + var result = ctx.RunTest("foo"); + Assert.That(result.passed, Is.True, result.failureMessage); + } + // ── Compile-time validation: cycles and unknown parents ──────────────── [Test] diff --git a/FadeBasic/Tests/TestFunctionTests.cs b/FadeBasic/Tests/TestFunctionTests.cs index 4c815cf..bd41f73 100644 --- a/FadeBasic/Tests/TestFunctionTests.cs +++ b/FadeBasic/Tests/TestFunctionTests.cs @@ -271,10 +271,6 @@ goto retry_done var parser = new Parser(lex.stream, TestCommands.CommandsForTesting); var prog = parser.ParseProgram(); var errs = prog.GetAllErrors(); - // Reasonably expecting same-name in different tests to work. If labelTable - // is global, this might error — and we'd need namespacing. - // For now, document the expectation. - Assert.That(errs.Where(e => !e.errorCode.Equals(ErrorCodes.TraverseLabelBetweenScopes)).Count(), - Is.GreaterThanOrEqualTo(0)); + prog.AssertNoParseErrors(); } } diff --git a/FadeBasic/build.sln b/FadeBasic/build.sln index d9129bc..44f68d2 100644 --- a/FadeBasic/build.sln +++ b/FadeBasic/build.sln @@ -1,6 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FadeBasic", "FadeBasic\FadeBasic.csproj", "{57007F64-F4ED-4979-BC09-1F58502953A2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommandSourceGenerator", "CommandSourceGenerator\CommandSourceGenerator.csproj", "{E7702D0D-11F7-43E6-9574-C4DF0C1410A7}" From a9774346056b729285e8d686792c2d9e6b157507 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Sun, 17 May 2026 21:04:20 -0400 Subject: [PATCH 08/30] web runtime fun --- WebRuntime/FadeBridge.cs | 59 ++++++++++++ WebRuntime/Program.cs | 14 +++ WebRuntime/Properties/launchSettings.json | 38 ++++++++ WebRuntime/WebCommands.cs | 62 ++++++++++++ WebRuntime/WebRuntime.csproj | 23 +++++ WebRuntime/global.json | 6 ++ WebRuntime/wwwroot/css/app.css | 32 +++++++ WebRuntime/wwwroot/index.html | 54 +++++++++++ WebRuntime/wwwroot/web-commands.js | 17 ++++ WebRuntime/wwwroot/worker.html | 111 ++++++++++++++++++++++ WebRuntime/wwwroot/worker.js | 63 ++++++++++++ 11 files changed, 479 insertions(+) create mode 100644 WebRuntime/FadeBridge.cs create mode 100644 WebRuntime/Program.cs create mode 100644 WebRuntime/Properties/launchSettings.json create mode 100644 WebRuntime/WebCommands.cs create mode 100644 WebRuntime/WebRuntime.csproj create mode 100644 WebRuntime/global.json create mode 100644 WebRuntime/wwwroot/css/app.css create mode 100644 WebRuntime/wwwroot/index.html create mode 100644 WebRuntime/wwwroot/web-commands.js create mode 100644 WebRuntime/wwwroot/worker.html create mode 100644 WebRuntime/wwwroot/worker.js diff --git a/WebRuntime/FadeBridge.cs b/WebRuntime/FadeBridge.cs new file mode 100644 index 0000000..8be7b31 --- /dev/null +++ b/WebRuntime/FadeBridge.cs @@ -0,0 +1,59 @@ +using System; +using System.Runtime.InteropServices.JavaScript; +using System.Runtime.Versioning; +using System.Text; +using Microsoft.JSInterop; +using FadeBasic; +using FadeBasic.Lib.Standard; +using FadeBasic.Sdk; + +namespace WebRuntime; + +[SupportedOSPlatform("browser")] +public static partial class FadeBridge +{ + // [JSInvokable] is the Blazor-host bridge (main thread, DotNet.invokeMethodAsync). + // [JSExport] is the runtime-level bridge (worker, getAssemblyExports). Same body, + // two front doors so both index.html (Blazor) and worker.html (raw runtime) work. + [JSInvokable] + [JSExport] + public static string CompileAndRun(string source) + { + var sb = new StringBuilder(); + + // WebCommands provides print (→ console.log + page buffer) and JS-interop + // commands. StandardCommands provides rgb, wait ms, string ops (upper$, + // lower$, str$, ...), rnd, timer, etc. ConsoleCommands is intentionally + // omitted — its print would shadow ours and its inputs need a real TTY. + var commands = new CommandCollection(new WebCommands(), new StandardCommands()); + if (!Fade.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + sb.AppendLine("Compile failed:"); + sb.Append(errors.ToDisplay()); + return sb.ToString(); + } + + try + { + ctx.Run(); + } + catch (Exception ex) + { + sb.AppendLine($"Runtime error: {ex.GetType().Name}: {ex.Message}"); + } + + var printed = WebCommands.DrainPrintBuffer(); + if (!string.IsNullOrEmpty(printed)) + { + sb.AppendLine("--- print output ---"); + sb.Append(printed); + } + + sb.AppendLine("--- variables ---"); + if (ctx.TryGetInteger("x", out var x)) sb.AppendLine($"x = {x}"); + if (ctx.TryGetInteger("y", out var y)) sb.AppendLine($"y = {y}"); + if (ctx.TryGetString("s", out var s)) sb.AppendLine($"s = \"{s}\""); + + return sb.ToString(); + } +} diff --git a/WebRuntime/Program.cs b/WebRuntime/Program.cs new file mode 100644 index 0000000..b32dfba --- /dev/null +++ b/WebRuntime/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using System.Runtime.InteropServices.JavaScript; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +var host = builder.Build(); + +// Load the JS module that backs WebInterop's [JSImport] methods. +// Must complete before any command that uses location()/user_agent()/alert is called. +// Path is relative to where the .NET runtime loaded from (/_framework/), +// so "../web-commands.js" points at wwwroot/web-commands.js. +await JSHost.ImportAsync("web-commands", "../web-commands.js"); + +await host.RunAsync(); diff --git a/WebRuntime/Properties/launchSettings.json b/WebRuntime/Properties/launchSettings.json new file mode 100644 index 0000000..503695c --- /dev/null +++ b/WebRuntime/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "iisSettings": { + "iisExpress": { + "applicationUrl": "http://localhost:52783", + "sslPort": 44336 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5299", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7260;http://localhost:5299", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/WebRuntime/WebCommands.cs b/WebRuntime/WebCommands.cs new file mode 100644 index 0000000..9b78c8d --- /dev/null +++ b/WebRuntime/WebCommands.cs @@ -0,0 +1,62 @@ +using System; +using System.Runtime.InteropServices.JavaScript; +using System.Runtime.Versioning; +using System.Text; +using FadeBasic.SourceGenerators; + +namespace WebRuntime; + +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); + // Live stream — main thread's web-commands.js exports a no-op; + // worker's setModuleImports overrides it to postMessage back to the page. + 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); + + [FadeBasicCommand("alert")] + public static void Alert(string msg) => WebInterop.Alert(msg); +} + +[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/WebRuntime/WebRuntime.csproj b/WebRuntime/WebRuntime.csproj new file mode 100644 index 0000000..3d991f9 --- /dev/null +++ b/WebRuntime/WebRuntime.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/WebRuntime/global.json b/WebRuntime/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/WebRuntime/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/WebRuntime/wwwroot/css/app.css b/WebRuntime/wwwroot/css/app.css new file mode 100644 index 0000000..c1a00f8 --- /dev/null +++ b/WebRuntime/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/WebRuntime/wwwroot/index.html b/WebRuntime/wwwroot/index.html new file mode 100644 index 0000000..43f712f --- /dev/null +++ b/WebRuntime/wwwroot/index.html @@ -0,0 +1,54 @@ + + + + + + Fade WebRuntime PoC + + + + + + +

Fade WebRuntime

+

Loading .NET runtime...

+ + +
+ + +

Output

+
(not yet run)
+ + + + + + diff --git a/WebRuntime/wwwroot/web-commands.js b/WebRuntime/wwwroot/web-commands.js new file mode 100644 index 0000000..7b344c2 --- /dev/null +++ b/WebRuntime/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/WebRuntime/wwwroot/worker.html b/WebRuntime/wwwroot/worker.html new file mode 100644 index 0000000..b04ed31 --- /dev/null +++ b/WebRuntime/wwwroot/worker.html @@ -0,0 +1,111 @@ + + + + + + Fade WebRuntime (worker mode) + + + + + + +

Fade WebRuntime 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/WebRuntime/wwwroot/worker.js b/WebRuntime/wwwroot/worker.js new file mode 100644 index 0000000..9c77a8f --- /dev/null +++ b/WebRuntime/wwwroot/worker.js @@ -0,0 +1,63 @@ +// Dedicated module worker: hosts the .NET runtime + Fade compiler/VM. +// Bootstraps the runtime once, then handles run requests from the page +// via postMessage. + +import { dotnet } from '/_framework/dotnet.js'; + +let exports = null; +const queue = []; + +function log(message) { + self.postMessage({ type: 'log', message }); +} + +async function init() { + log('creating .NET runtime...'); + // Do NOT call runMain() — Program.cs ends with host.RunAsync() which never + // returns, hanging the worker forever. Skip Main; bootstrap manually. + const runtime = await dotnet.create(); + log('runtime created, registering JS imports...'); + + // Worker-side implementation of the "web-commands" module. The C# side + // declares [JSImport(..., "web-commands")] for each of these; main-thread + // mode satisfies them by loading web-commands.js, worker mode satisfies + // them here so we never hit "module not registered" errors. + runtime.setModuleImports('web-commands', { + onPrint: (line) => self.postMessage({ type: 'print', line }), + getLocation: () => '(unavailable in worker context)', + getUserAgent: () => self.navigator?.userAgent ?? '(unavailable)', + alert: (msg) => self.postMessage({ type: 'alert', msg }), + }); + + log('registering assembly exports...'); + const config = runtime.getConfig(); + exports = await runtime.getAssemblyExports(config.mainAssemblyName); + log('exports loaded'); + + while (queue.length) handle(queue.shift()); + self.postMessage({ type: 'ready' }); +} + +function handle(msg) { + if (msg.type === 'run') { + let result; + try { + result = exports.WebRuntime.FadeBridge.CompileAndRun(msg.source); + } catch (e) { + result = 'Worker error: ' + (e?.message ?? e); + } + self.postMessage({ type: 'result', id: msg.id, result }); + } +} + +self.onmessage = (e) => { + if (exports) { + handle(e.data); + } else { + queue.push(e.data); + } +}; + +init().catch((e) => { + self.postMessage({ type: 'boot-error', message: String(e?.stack ?? e) }); +}); From 990beb1a936a76a1b86074f4d362362179af8b55 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Mon, 18 May 2026 14:36:28 -0400 Subject: [PATCH 09/30] web stuff play --- .../ApplicationSupport.csproj | 1 + .../Project/ProjectDocsCommandDocsProvider.cs | 57 + FadeBasic/CHANGELOG.md | 36 +- FadeBasic/FadeBasic/Launch/Launcher.cs | 30 +- FadeBasic/LSP.Core/AUDIT.md | 58 + FadeBasic/LSP.Core/Dtos.cs | 133 + FadeBasic/LSP.Core/FadeBasic.LSP.Core.csproj | 17 + FadeBasic/LSP.Core/FadeDocument.cs | 51 + FadeBasic/LSP.Core/FadeWorkspace.cs | 73 + .../LSP.Core/Handlers/CompletionHandler.cs | 124 + .../LSP.Core/Handlers/DefinitionHandler.cs | 60 + .../LSP.Core/Handlers/DiagnosticsHandler.cs | 55 + .../Handlers/DocumentSymbolHandler.cs | 120 + .../LSP.Core/Handlers/FoldingRangeHandler.cs | 76 + .../LSP.Core/Handlers/FormattingHandler.cs | 111 + FadeBasic/LSP.Core/Handlers/HoverHandler.cs | 365 ++ .../LSP.Core/Handlers/ReferencesHandler.cs | 134 + FadeBasic/LSP.Core/Handlers/RenameHandler.cs | 119 + .../Handlers/SemanticTokensHandler.cs | 107 + .../LSP.Core/Handlers/SignatureHelpHandler.cs | 269 ++ FadeBasic/LSP/Handlers/CompletionHandler2.cs | 235 +- FadeBasic/LSP/Handlers/CoreAdapter.cs | 39 + .../LSP/Handlers/DocumentSymbolHandler.cs | 136 +- .../LSP/Handlers/FindReferencesHandler.cs | 156 +- FadeBasic/LSP/Handlers/FoldingRangeHandler.cs | 53 +- FadeBasic/LSP/Handlers/FormattingHandler.cs | 93 +- .../LSP/Handlers/FormattingRangeHandler.cs | 26 +- .../Handlers/FormattingWhenTypingHandler.cs | 33 +- .../LSP/Handlers/GotoDefinitionHandler.cs | 143 +- FadeBasic/LSP/Handlers/HoverHandler.cs | 311 +- FadeBasic/LSP/Handlers/RenameHandler.cs | 228 +- .../LSP/Handlers/SemanticTokenHandler.cs | 124 +- .../LSP/Handlers/SignatureHelpHandler.cs | 310 +- FadeBasic/LSP/LSP.csproj | 1 + Playground/.gitignore | 5 + Playground/index.html | 1014 ++++++ Playground/notes.md | 16 + Playground/package-lock.json | 1679 +++++++++ Playground/package.json | 51 + Playground/page-shot.png | Bin 0 -> 52173 bytes Playground/scripts/build-runtime.mjs | 34 + Playground/scripts/check-page.mjs | 379 ++ Playground/scripts/test-dap.mjs | 150 + Playground/scripts/test-lsp.mjs | 357 ++ Playground/scripts/test-tests-panel.mjs | 192 ++ Playground/src/main.ts | 3060 +++++++++++++++++ Playground/tsconfig.json | 20 + Playground/tsconfig.tsbuildinfo | 1 + Playground/vite.config.ts | 84 + VsCode/basicscript/src/extension.ts | 22 +- WebRuntime/DAP_AUDIT.md | 99 + WebRuntime/FadeBridge.cs | 699 +++- WebRuntime/StandardCommandDocs.cs | 42 + WebRuntime/WebCommands.cs | 10 + WebRuntime/WebDebugSession.cs | 74 + WebRuntime/WebRuntime.csproj | 6 + WebRuntime/wwwroot/worker.js | 298 +- 57 files changed, 10858 insertions(+), 1318 deletions(-) create mode 100644 FadeBasic/ApplicationSupport/Project/ProjectDocsCommandDocsProvider.cs create mode 100644 FadeBasic/LSP.Core/AUDIT.md create mode 100644 FadeBasic/LSP.Core/Dtos.cs create mode 100644 FadeBasic/LSP.Core/FadeBasic.LSP.Core.csproj create mode 100644 FadeBasic/LSP.Core/FadeDocument.cs create mode 100644 FadeBasic/LSP.Core/FadeWorkspace.cs create mode 100644 FadeBasic/LSP.Core/Handlers/CompletionHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/DefinitionHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/DiagnosticsHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/DocumentSymbolHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/FoldingRangeHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/FormattingHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/HoverHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/ReferencesHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/RenameHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/SemanticTokensHandler.cs create mode 100644 FadeBasic/LSP.Core/Handlers/SignatureHelpHandler.cs create mode 100644 FadeBasic/LSP/Handlers/CoreAdapter.cs create mode 100644 Playground/.gitignore create mode 100644 Playground/index.html create mode 100644 Playground/notes.md create mode 100644 Playground/package-lock.json create mode 100644 Playground/package.json create mode 100644 Playground/page-shot.png create mode 100644 Playground/scripts/build-runtime.mjs create mode 100644 Playground/scripts/check-page.mjs create mode 100644 Playground/scripts/test-dap.mjs create mode 100644 Playground/scripts/test-lsp.mjs create mode 100644 Playground/scripts/test-tests-panel.mjs create mode 100644 Playground/src/main.ts create mode 100644 Playground/tsconfig.json create mode 100644 Playground/tsconfig.tsbuildinfo create mode 100644 Playground/vite.config.ts create mode 100644 WebRuntime/DAP_AUDIT.md create mode 100644 WebRuntime/StandardCommandDocs.cs create mode 100644 WebRuntime/WebDebugSession.cs diff --git a/FadeBasic/ApplicationSupport/ApplicationSupport.csproj b/FadeBasic/ApplicationSupport/ApplicationSupport.csproj index fc70a2c..bf0e239 100644 --- a/FadeBasic/ApplicationSupport/ApplicationSupport.csproj +++ b/FadeBasic/ApplicationSupport/ApplicationSupport.csproj @@ -15,6 +15,7 @@ + diff --git a/FadeBasic/ApplicationSupport/Project/ProjectDocsCommandDocsProvider.cs b/FadeBasic/ApplicationSupport/Project/ProjectDocsCommandDocsProvider.cs new file mode 100644 index 0000000..e4cdaaf --- /dev/null +++ b/FadeBasic/ApplicationSupport/Project/ProjectDocsCommandDocsProvider.cs @@ -0,0 +1,57 @@ +// Adapter: ProjectDocs ⟶ FadeBasic.LSP.Core.ICommandDocsProvider. +// +// Lets any host that already has a ProjectDocs (native LSP, WebRuntime, +// docs site) plug into LSP.Core's hover/completion handlers without +// duplicating the XML-doc parsing pipeline. The lookup is by +// `CommandInfo.sig`, matching the key ProjectDocs builds. + +using FadeBasic.LSP.Core; +using FadeBasic.Virtual; + +namespace FadeBasic.ApplicationSupport.Project; + +public sealed class ProjectDocsCommandDocsProvider : ICommandDocsProvider +{ + private readonly ProjectDocs _docs; + private readonly Func? _urlForCommand; + + public ProjectDocsCommandDocsProvider(ProjectDocs docs, Func? urlForCommand = null) + { + _docs = docs; + _urlForCommand = urlForCommand; + } + + public ICommandDocs? Lookup(CommandInfo command) + { + if (_docs?.map == null) return null; + if (!_docs.map.TryGetValue(command.sig ?? string.Empty, out var found)) return null; + return new CommandDocsAdapter(found, _urlForCommand); + } + + private sealed class CommandDocsAdapter : ICommandDocs + { + private readonly CommandDocs _src; + private readonly Func? _urlForCommand; + public CommandDocsAdapter(CommandDocs src, Func? urlForCommand) + { + _src = src; + _urlForCommand = urlForCommand; + } + public string? Summary => _src.methodDocs?.summary; + public string? Returns => _src.methodDocs?.returns; + public string? Remarks => _src.methodDocs?.remarks; + public IReadOnlyList Parameters => + _src.methodDocs?.parameters?.Select(p => (ICommandParameterDoc)new ParamAdapter(p)).ToList() + ?? new List(); + public IReadOnlyList Examples => _src.methodDocs?.examples ?? new List(); + public string? Url => _urlForCommand?.Invoke(_src.commandName ?? string.Empty); + } + + private sealed class ParamAdapter : ICommandParameterDoc + { + private readonly XmlDocMethodParameter _src; + public ParamAdapter(XmlDocMethodParameter src) { _src = src; } + public string? Name => _src.name; + public string? Body => _src.body; + } +} diff --git a/FadeBasic/CHANGELOG.md b/FadeBasic/CHANGELOG.md index 4ad9484..468582b 100644 --- a/FadeBasic/CHANGELOG.md +++ b/FadeBasic/CHANGELOG.md @@ -5,41 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.0.65] ### Added -- `FadeBasic.Testing` adapter — first-party Microsoft.Testing.Platform `ITestFramework` - that surfaces every Fade `test ... endtest` block to `dotnet test` and IDE Test - Explorer (VS / Rider via VSTestBridge). Replaces the NUnit-fixture path. -- `IFadeTestHost` extension point — downstream consumers (MonoGame, Avalonia, - custom hosts) implement this and tag the class with - `[FadeBasic.Testing.FadeTestHost]` to take over per-session / per-test setup - (e.g., rebuild a `Game1`, reset GPU state). -- The opt-in is **a single PackageReference**: adding `FadeBasic.Testing` - pulls in MTP + the VSTestBridge + the MTP MSBuild integration, defaults - `FadeEnableTesting=true` via the package's own props, sets every required - MTP knob (`IsTestProject`, runner flags, auto-Main suppression), and - auto-writes a `global.json` next to the csproj so `dotnet test` opts into - the new runner. To opt back out, set `false`. -- Diagnostic codes: - - `FADE0001` — warns when the deprecated `FadeGenerateNUnitFixture` flag is set. - - `FADE0002` — fires when `FadeEnableTesting=true` but `FadeBasic.Testing` is - not in the consumer's `PackageReference` list (NuGet can't resolve a - package-provided conditional `PackageReference` during restore, so this - has to be authored explicitly). - - `FADE0003` — fires when an existing `global.json` does not opt into MTP. +- `TEST` keyword and related mechanisms +- `LEN` keyword for checking string and single dimension array length ### Changed -- All testing-related MSBuild config moved out of `FadeBasic.Build` into - `FadeBasic.Testing/build/FadeBasic.Testing.props/targets`. The Build - package stays focused on Fade source compilation; the Testing package - owns its own MSBuild surface. - -### Removed -- `FadeGenerateNUnitFixture` MSBuild property — emits FADE0001 instead. - The auto-imported `Microsoft.NET.Test.Sdk` / `NUnit` / `NUnit3TestAdapter` - package references are gone; the new path is dependency-free apart from MTP. -- `LaunchableGenerator.NUnitFixtureTemplate` and the corresponding - `IsTestProject` / `GenerateProgramFile` workarounds in `FadeBasic.Build.targets`. +- Commands that accept a typed `isParams` arg can be fulfilled with a fade array ### Fixed - boolean type inference works through binary operators diff --git a/FadeBasic/FadeBasic/Launch/Launcher.cs b/FadeBasic/FadeBasic/Launch/Launcher.cs index 7ea01cf..951f816 100644 --- a/FadeBasic/FadeBasic/Launch/Launcher.cs +++ b/FadeBasic/FadeBasic/Launch/Launcher.cs @@ -27,21 +27,35 @@ public class LaunchOptions public static readonly LaunchOptions DefaultOptions; static LaunchOptions() { - var debugEnv = Environment.GetEnvironmentVariable(ENV_ENABLE_DEBUG)?.ToLowerInvariant(); - var debugDontWait = Environment.GetEnvironmentVariable(ENV_ENABLE_DEBUG_DONT_WAIT)?.ToLowerInvariant(); + // Best-effort: in WASM there are no env vars / TCP sockets, and + // any throw here gets wrapped in a TypeInitializationException + // for every later access to ANY LaunchOptions field. Swallow + // failures so the type stays usable. DefaultOptions = new LaunchOptions { - debug = debugEnv == "true" || debugEnv == "1", + debug = false, debugPort = 0, - debugWaitForConnection = !(debugDontWait == "true" || debugDontWait == "1"), - debugLogPath = Environment.GetEnvironmentVariable(ENV_DEBUG_LOG_PATH) + debugWaitForConnection = true, + debugLogPath = null, }; + try + { + var debugEnv = Environment.GetEnvironmentVariable(ENV_ENABLE_DEBUG)?.ToLowerInvariant(); + var debugDontWait = Environment.GetEnvironmentVariable(ENV_ENABLE_DEBUG_DONT_WAIT)?.ToLowerInvariant(); + DefaultOptions.debug = debugEnv == "true" || debugEnv == "1"; + DefaultOptions.debugWaitForConnection = !(debugDontWait == "true" || debugDontWait == "1"); + DefaultOptions.debugLogPath = Environment.GetEnvironmentVariable(ENV_DEBUG_LOG_PATH); - if (!int.TryParse(Environment.GetEnvironmentVariable(ENV_DEBUG_PORT), out DefaultOptions.debugPort)) + if (!int.TryParse(Environment.GetEnvironmentVariable(ENV_DEBUG_PORT), out DefaultOptions.debugPort)) + { + DefaultOptions.debugPort = LaunchUtil.FreeTcpPort(); + } + } + catch { - DefaultOptions.debugPort = LaunchUtil.FreeTcpPort(); + // Browser / sandboxed environment — DefaultOptions retains + // the safe defaults set above. } - } } diff --git a/FadeBasic/LSP.Core/AUDIT.md b/FadeBasic/LSP.Core/AUDIT.md new file mode 100644 index 0000000..09c0280 --- /dev/null +++ b/FadeBasic/LSP.Core/AUDIT.md @@ -0,0 +1,58 @@ +# LSP.Core vs Native LSP — Behavioral Audit + +After the refactor, the native LSP project (`FadeBasic/LSP/`) is a thin +adapter over `FadeBasic.LSP.Core.Handlers.*`. Each native handler: + +1. Looks up the requesting URI's `CodeUnit` via `CompilerService`. +2. Calls `CoreAdapter.ToDocument(unit, uri, projectDocs?)` to build a + single `FadeDocument` view of the parsed AST. +3. Maps the request's position into the compiled unit's coordinate space + via `unit.sourceMap.TryGetMappedLocation` (identity for single-file + projects; non-trivial for multi-file ones). +4. Invokes Core's `Compute(...)`. +5. Translates Core's DTOs into OmniSharp protocol types, mapping ranges + back through `unit.sourceMap.GetOriginalLocation` so multi-file + projects resolve to the originating files. + +Doc-aware handlers (Hover today, Completion if extended) install a +`ProjectDocsCommandDocsProvider` on the `FadeDocument` so Core's +`ICommandDocsProvider` hook gets the *same* parsed `ProjectDocs` the +native LSP already exposes. + +## Per-handler diff vs the pre-refactor native code + +| Handler | Pre-refactor native | Core | Diff | +|---|---|---|---| +| **Diagnostics** | Project-aware, multi-file | Per-document | (Unchanged — diagnostics aren't routed through Core yet; lives in `LSP/Handlers/DiagnosticsHandler.cs`.) | +| **SemanticTokens** | Project-aware, source-mapped | Per-document | (Unchanged — lives in `LSP/Handlers/SemanticTokenHandler.cs`.) | +| **Hover** | Walked AST; rich Markdown for commands via `ProjectDocs`; raw `function.Trivia` for function calls; nothing for variables/parameters/labels; no diagnostics on hover | Diagnostics first; rich Markdown for commands via `ICommandDocsProvider` (same `ProjectDocs` underneath); fenced `fade` code-block header + trivia for functions / variables / parameters / labels | ✅ More coverage. Function trivia now has a header (signature) before the doc text. Hovering an error region now shows the error. | +| **Completion** | Returned a `` placeholder item when the macro program was absent | Returns an empty list in the same situation | ✅ No more debug-string leakage. Otherwise identical context-building + `LSPUtil.GetCompletions` call. | +| **SignatureHelp** | AST walk + token-fallback for `name(`; per-param documentation from `ProjectDocs` | Same AST walk + token-fallback; per-param docs come via `LspSignatureParameter.Documentation` (currently null because Core doesn't fill it from docs — handler post-fills if needed) | ⚠ Per-param documentation: Core doesn't fetch from docs yet. Native adapter passes `ProjectDocs` to Core but Core ignores it for sig help. **Follow-up: surface command param docs in Core's sig help.** | +| **GotoDefinition** | AST `FindFirst` on allowed types; walked program *and* macroProgram; mapped result range via sourceMap | Same AST `FindFirst`; walks `doc.Program` only | ⚠ Macro-expanded tokens won't resolve to definitions through Core. Multi-file source-map mapping handled at the native adapter layer. | +| **FindReferences** | Single-pass: matched node → DeclaredFromSymbol.source → walk program for DeclaredFromSymbol matches. Walked macroProgram too. | Multi-pass: matched node + DeclaredFromSymbol.source + nodes whose DeclaredFromSymbol.source's token equals the clicked token. **Walks only `doc.Program`.** | ✅ Clicking the declaration site (e.g. LHS of `x = 1`) now returns use sites — old native code returned only the LHS. ⚠ Macro-expanded refs not walked. | +| **DocumentSymbol** | Dumped every "interesting" lexer token as a separate symbol; re-read file from disk per request | AST-driven outline (functions with nested labels, types, top-level declarations, labels); reads in-memory parse tree | ✅ Massive UX improvement. Range now covers full bodies; SelectionRange covers the name token. No more per-keyword/per-string noise. | +| **FoldingRange** | Hardcoded stub (`[2,4]`) | AST-driven (functions, if/for/while/do/repeat blocks, type/test blocks, multi-line `rem` comments) | ✅ Massive UX improvement. | +| **Formatting** | Re-lexed source from disk per request; cased per `conf.language.fade.formatCasing` | Operates on the LexerResults the workspace already has; same casing setting plumbed through `LspFormattingOptions` | ✅ No FS roundtrip; output now strictly follows the LSP's view of the document. Casing behavior preserved. | +| **FormattingRange** | Filtered FormattingHandler result by intersection | (Unchanged composition; just delegates to the refactored FormattingHandler) | (None) | +| **FormattingWhenTyping** | Filtered FormattingHandler result by line-distance | (Unchanged composition; just delegates to the refactored FormattingHandler) | (None) | +| **Rename** | Walked program + macroProgram; emitted edits keyed on the request URI | Walks `doc.Program` only; ranges mapped back through `unit.sourceMap` to originating files | ⚠ Macro-expanded refs not walked. Multi-file projects: edits now correctly target originating source files instead of a single URI. | + +## Open follow-ups + +1. **Macro program walks.** GotoDef / References / Rename in Core don't yet + inspect `LexerResults.macroProgram`. Native pre-refactor did. Adding a + `doc.MacroProgram` field to `FadeDocument` and visiting both is a + straightforward extension. +2. **Per-parameter docs in SignatureHelp.** Core's `LspSignatureParameter` + has a `Documentation` field but `SignatureHelpHandler.BuildCommandSignature` + doesn't yet pull from `ICommandDocsProvider`. The pre-refactor native + filled this from `ProjectDocs.methodDocs.parameters[i].body`. Wire the + same lookup in Core to close this gap. +3. **Completion item documentation.** Built-in command completions have no + per-item documentation in either Core or the native pre-refactor. + Hover compensates by showing rich docs on hover-over-completion. + Surface command summaries on the `LspCompletionItem.Documentation` + field if the suggest popup's doc panel should show more. +4. **Per-token Range output.** The Core Hover handler returns a range + based on the matched token, not the matched AST node. Single-file + parity; multi-file output is slightly tighter. diff --git a/FadeBasic/LSP.Core/Dtos.cs b/FadeBasic/LSP.Core/Dtos.cs new file mode 100644 index 0000000..609dbba --- /dev/null +++ b/FadeBasic/LSP.Core/Dtos.cs @@ -0,0 +1,133 @@ +// Transport-agnostic DTOs used by all LSP handlers in Core. Different LSP +// frontends (OmniSharp-based native server, browser FadeBridge) translate +// between these and their wire-protocol types. + +using System.Collections.Generic; + +namespace FadeBasic.LSP.Core +{ + public class LspPosition + { + public int Line; // 0-based + public int Character; // 0-based + } + + public class LspRange + { + public LspPosition Start; + public LspPosition End; + } + + public enum LspDiagnosticSeverity + { + Error = 1, + Warning = 2, + Information = 3, + Hint = 4, + } + + public class LspDiagnostic + { + public LspRange Range; + public LspDiagnosticSeverity Severity; + public string Code; + public string Source; + public string Message; + } + + public class LspHoverResult + { + public string Contents; // Markdown + public LspRange Range; + } + + public enum LspCompletionKind + { + Text = 0, + Variable = 1, + Function = 2, + Interface = 3, + Keyword = 4, + Field = 5, + Class = 6, + Constant = 7, + Reference = 8, + Folder = 9, + Method = 10, + Snippet = 11, + } + + public enum LspInsertTextFormat + { + PlainText = 1, + Snippet = 2, + } + + public class LspCompletionItem + { + public string Label; + public string InsertText; + public LspCompletionKind Kind; + public string Detail; + public string Documentation; + public string SortText; + public string FilterText; + public LspInsertTextFormat InsertTextFormat = LspInsertTextFormat.PlainText; + public bool TriggerParameterHints; + } + + public class LspSemanticTokens + { + // LSP-encoded delta-format: groups of 5 ints. + public List Data; + } + + public class LspTextEdit + { + public LspRange Range; + public string NewText; + } + + public class LspWorkspaceEdit + { + // Per-URI list of edits. + public Dictionary> Changes; + } + + // Matches the LSP SymbolKind enum subset we use. + public enum LspSymbolKind + { + File = 1, Module = 2, Namespace = 3, Package = 4, Class = 5, + Method = 6, Property = 7, Field = 8, Constructor = 9, Enum = 10, + Interface = 11, Function = 12, Variable = 13, Constant = 14, + String = 15, Number = 16, Boolean = 17, Array = 18, Object = 19, + Key = 20, Null = 21, EnumMember = 22, Struct = 23, Event = 24, + Operator = 25, TypeParameter = 26, + } + + public class LspDocumentSymbol + { + public string Name; + public string Detail; + public LspSymbolKind Kind; + public LspRange Range; // full extent (body included) + public LspRange SelectionRange; // just the name token + public List Children; + } + + public enum LspFoldingRangeKind + { + Region = 0, + Comment = 1, + Imports = 2, + } + + public class LspFoldingRange + { + public int StartLine; + public int EndLine; + public int? StartCharacter; + public int? EndCharacter; + public LspFoldingRangeKind Kind; + } +} diff --git a/FadeBasic/LSP.Core/FadeBasic.LSP.Core.csproj b/FadeBasic/LSP.Core/FadeBasic.LSP.Core.csproj new file mode 100644 index 0000000..d20de4e --- /dev/null +++ b/FadeBasic/LSP.Core/FadeBasic.LSP.Core.csproj @@ -0,0 +1,17 @@ + + + + FadeBasic.LSP.Core + FadeBasic.LSP.Core + + netstandard2.1 + 9.0 + disable + + + + + + + diff --git a/FadeBasic/LSP.Core/FadeDocument.cs b/FadeBasic/LSP.Core/FadeDocument.cs new file mode 100644 index 0000000..3de9da3 --- /dev/null +++ b/FadeBasic/LSP.Core/FadeDocument.cs @@ -0,0 +1,51 @@ +// Per-document state held by the LSP. Each open file in the editor has one +// FadeDocument; the workspace owns the dictionary of them. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace FadeBasic.LSP.Core +{ + public class FadeDocument + { + public string Uri; + public string Text; + public LexerResults LexResults; + public ProgramNode Program; + public CommandCollection Commands; + // Optional doc-lookup hook. Hosts that have command documentation + // (native LSP via ProjectDocs, WebRuntime via embedded JSON) install + // a provider so hover/completion can surface rich markdown. + public ICommandDocsProvider Docs; + + public bool IsValid => LexResults != null; + } + + // A minimal contract for command documentation. Returns null if the + // command is unknown. + public interface ICommandDocsProvider + { + ICommandDocs Lookup(CommandInfo command); + } + + // The slice of a command's documentation we actually render. Hosts map + // their own doc types into this. + public interface ICommandDocs + { + string Summary { get; } + string Returns { get; } + string Remarks { get; } + IReadOnlyList Parameters { get; } + IReadOnlyList Examples { get; } + // Optional canonical web URL for this command (e.g. docs site). + string Url { get; } + } + + public interface ICommandParameterDoc + { + string Name { get; } + string Body { get; } + } +} diff --git a/FadeBasic/LSP.Core/FadeWorkspace.cs b/FadeBasic/LSP.Core/FadeWorkspace.cs new file mode 100644 index 0000000..390846d --- /dev/null +++ b/FadeBasic/LSP.Core/FadeWorkspace.cs @@ -0,0 +1,73 @@ +// Collection of FadeDocuments. Owns the lexer/parser path. Frontends call +// SetDocument when a file is opened or changes, then ask handlers for results. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast.Visitors; + +namespace FadeBasic.LSP.Core +{ + public class FadeWorkspace + { + private readonly Dictionary _docs = new(); + private readonly Lexer _lexer = new(); + + public CommandCollection Commands { get; set; } + // Optional docs provider — when set, every SetDocument call attaches + // it to the resulting FadeDocument so handlers can render rich + // command markdown. + public ICommandDocsProvider Docs { get; set; } + + public FadeWorkspace(CommandCollection commands = null) + { + Commands = commands ?? new CommandCollection(); + } + + public FadeDocument SetDocument(string uri, string text) + { + var lex = _lexer.TokenizeWithErrors(text, Commands); + var parser = new Parser(lex.stream, Commands); + var program = parser.ParseProgram(); + + // Resolves names, populates DeclaredFromSymbol on AST refs, and + // fills program.scope.positionedVariables — all of which the + // completion, references, and goto-def handlers depend on. + // Without this the only errors we report are syntax-level. + try + { + program.AddScopeRelatedErrors(ParseOptions.Default); + } + catch { /* visitor is best-effort; never fail SetDocument */ } + + // Attach trivia (doc-comment) strings to functions/declarations/ + // labels so the hover handler can render them as markdown. + try + { + program.AddTrivia(lex); + } + catch { /* trivia is best-effort */ } + + var doc = new FadeDocument + { + Uri = uri, + Text = text, + LexResults = lex, + Program = program, + Commands = Commands, + Docs = Docs, + }; + _docs[uri] = doc; + return doc; + } + + public FadeDocument Get(string uri) + { + _docs.TryGetValue(uri, out var d); + return d; + } + + public bool Remove(string uri) => _docs.Remove(uri); + + public IEnumerable AllDocuments => _docs.Values; + } +} diff --git a/FadeBasic/LSP.Core/Handlers/CompletionHandler.cs b/FadeBasic/LSP.Core/Handlers/CompletionHandler.cs new file mode 100644 index 0000000..b39dcbb --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/CompletionHandler.cs @@ -0,0 +1,124 @@ +// Compute completion items at a position. Builds a CompletionContext for the +// existing FadeBasic.Lsp.LSPUtil.GetCompletions which does the real work. + +using System.Collections.Generic; +using System.Linq; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Lsp; +using LspCompletionContext = FadeBasic.Lsp.CompletionContext; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class CompletionHandler + { + public static List Compute(FadeDocument doc, int line, int character) + { + if (doc?.LexResults == null || doc.Program == null) return new List(); + + var fakeToken = new Token { lineNumber = line, charNumber = character }; + + // Find the nearest token to the left. + Token leftToken = null; + for (int i = doc.LexResults.allTokens.Count - 1; i >= 0; i--) + { + var token = doc.LexResults.allTokens[i]; + if (token.lineNumber < line) + { + leftToken = token; + break; + } + if (token.lineNumber == line && token.charNumber <= character) + { + leftToken = token; + break; + } + } + + if (leftToken == null) return new List(); + + bool isMacro = leftToken.flags.HasFlag(TokenFlags.IsMacroToken); + + bool Visit(IAstVisitable v) + { + return v is ProgramNode + || (Token.IsLocationBeforeOrEqual(v.StartToken, fakeToken) + && Token.IsLocationBeforeOrEqual(fakeToken, v.EndToken)); + } + + ProgramNode programNode; + IEnumerable group; + if (isMacro && doc.LexResults.macroProgram != null) + { + programNode = doc.LexResults.macroProgram; + group = programNode?.Where(Visit); + } + else + { + programNode = doc.Program; + group = programNode?.Where(Visit); + } + + if (programNode == null) return new List(); + + // Locate the function/scope context the position is inside. + if (!programNode.scope.positionedVariables.TryFindEntry(fakeToken, out var entry)) + { + if (programNode.scope.positionedVariables.entries.Count == 0) + return new List(); + entry = programNode.scope.positionedVariables.entries[0]; + } + + var context = new LspCompletionContext + { + IsMacro = isMacro, + FakeToken = fakeToken, + LeftToken = leftToken, + Program = programNode, + Commands = doc.Commands, + FunctionName = entry.value.Item2, + Group = group?.ToList(), + ConstantTable = doc.LexResults.constantTable, + LocalScope = entry.value.Item1, + }; + + var portable = LSPUtil.GetCompletions(context); + return portable.Select(ToLspCompletionItem).ToList(); + } + + private static LspCompletionItem ToLspCompletionItem(PortableCompletionItem p) + { + return new LspCompletionItem + { + Label = p.Label, + InsertText = p.InsertText, + Kind = ToKind(p.Kind), + Detail = p.Detail, + Documentation = p.Documentation, + SortText = p.SortText, + FilterText = p.FilterText, + InsertTextFormat = p.InsertTextFormat == PortableInsertTextFormat.Snippet + ? LspInsertTextFormat.Snippet + : LspInsertTextFormat.PlainText, + TriggerParameterHints = p.TriggerParameterHints, + }; + } + + private static LspCompletionKind ToKind(PortableCompletionKind kind) + { + switch (kind) + { + case PortableCompletionKind.Variable: return LspCompletionKind.Variable; + case PortableCompletionKind.Function: return LspCompletionKind.Function; + case PortableCompletionKind.Interface: return LspCompletionKind.Interface; + case PortableCompletionKind.Keyword: return LspCompletionKind.Keyword; + case PortableCompletionKind.Field: return LspCompletionKind.Field; + case PortableCompletionKind.Class: return LspCompletionKind.Class; + case PortableCompletionKind.Constant: return LspCompletionKind.Constant; + case PortableCompletionKind.Reference: return LspCompletionKind.Reference; + case PortableCompletionKind.Folder: return LspCompletionKind.Folder; + default: return LspCompletionKind.Text; + } + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/DefinitionHandler.cs b/FadeBasic/LSP.Core/Handlers/DefinitionHandler.cs new file mode 100644 index 0000000..9352cd4 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/DefinitionHandler.cs @@ -0,0 +1,60 @@ +// Go-to-definition: given a cursor on a reference, return the location of +// the AST node that declared the symbol the reference resolves to. +// +// Ported from FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs. + +using System; +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class DefinitionHandler + { + private static readonly HashSet AllowedTypes = new HashSet + { + typeof(VariableRefNode), + typeof(ArrayIndexReference), + typeof(GoSubStatement), + typeof(GotoStatement), + typeof(RuntoStatement), + }; + + public static LspLocation Compute(FadeDocument doc, int line, int character) + { + if (doc?.Program == null || doc.LexResults == null) return null; + + var token = ReferencesHandler.FindTokenAt(doc, line, character) + ?? ReferencesHandler.FindTokenAt(doc, line, character - 1); + if (token == null) return null; + + bool Visit(IAstVisitable x) + { + if (!AllowedTypes.Contains(x.GetType())) return false; + return x.StartToken == token || x.EndToken == token; + } + + var node = doc.Program.FindFirst(Visit) as IAstNode; + if (node == null) return null; + + IAstNode target = node; + switch (node) + { + case ExpressionStatement exprStatement: + target = exprStatement.expression as IAstNode ?? node; + break; + } + + if (target.DeclaredFromSymbol == null) return null; + var origin = target.DeclaredFromSymbol.source; + if (origin == null) return null; + + return new LspLocation + { + Uri = doc.Uri, + Range = ReferencesHandler.TokenRangeOf(origin), + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/DiagnosticsHandler.cs b/FadeBasic/LSP.Core/Handlers/DiagnosticsHandler.cs new file mode 100644 index 0000000..1d48ef4 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/DiagnosticsHandler.cs @@ -0,0 +1,55 @@ +// Collect lex + parse errors from a FadeDocument as portable diagnostics. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class DiagnosticsHandler + { + public static List Compute(FadeDocument doc) + { + var diagnostics = new List(); + if (doc == null) return diagnostics; + + if (doc.LexResults?.tokenErrors != null) + { + foreach (var err in doc.LexResults.tokenErrors) + diagnostics.Add(MakeDiag(err)); + } + + if (doc.Program != null) + { + foreach (var err in doc.Program.GetAllErrors()) + diagnostics.Add(MakeDiag(err)); + } + + return diagnostics; + } + + private static LspDiagnostic MakeDiag(ParseError err) + { + var startTok = err.location?.start; + var endTok = err.location?.end ?? startTok; + int startLine = startTok?.lineNumber ?? 0; + int startChar = startTok?.charNumber ?? 0; + int endLine = endTok?.lineNumber ?? startLine; + int endChar = endTok != null + ? endTok.charNumber + System.Math.Max(1, endTok.Length) + : startChar + 1; + return new LspDiagnostic + { + Severity = LspDiagnosticSeverity.Error, + Range = new LspRange + { + Start = new LspPosition { Line = startLine, Character = startChar }, + End = new LspPosition { Line = endLine, Character = endChar }, + }, + Message = err.CombinedMessage, + Code = err.errorCode.code.ToString(), + Source = "fade", + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/DocumentSymbolHandler.cs b/FadeBasic/LSP.Core/Handlers/DocumentSymbolHandler.cs new file mode 100644 index 0000000..78d0b69 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/DocumentSymbolHandler.cs @@ -0,0 +1,120 @@ +// Document outline: lists top-level functions, type definitions, declarations, +// and labels. Each function expands into its local labels. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class DocumentSymbolHandler + { + public static List Compute(FadeDocument doc) + { + var result = new List(); + if (doc?.Program == null) return result; + + var prog = doc.Program; + + foreach (var typeDef in prog.typeDefinitions) + { + if (typeDef?.name == null) continue; + result.Add(new LspDocumentSymbol + { + Name = typeDef.name.variableName ?? "", + Detail = "type", + Kind = LspSymbolKind.Struct, + Range = NodeRange(typeDef), + SelectionRange = TokenRange(typeDef.name?.StartToken ?? typeDef.StartToken), + }); + } + + foreach (var label in prog.labels) + { + if (label?.label == null) continue; + result.Add(new LspDocumentSymbol + { + Name = label.label, + Detail = "label", + Kind = LspSymbolKind.Key, + Range = NodeRange(label), + SelectionRange = TokenRange(label.StartToken), + }); + } + + // Top-level declarations only (variables shown as outline entries). + foreach (var stmt in prog.statements) + { + if (stmt is DeclarationStatement decl && decl.variableNode != null) + { + result.Add(new LspDocumentSymbol + { + Name = decl.variableNode.variableName ?? "", + Detail = decl.type?.variableType.ToString() ?? "variable", + Kind = LspSymbolKind.Variable, + Range = NodeRange(decl), + SelectionRange = TokenRange(decl.variableNode.StartToken), + }); + } + } + + foreach (var func in prog.functions) + { + if (func?.nameToken == null) continue; + var children = new List(); + if (func.labels != null) + { + foreach (var label in func.labels) + { + if (label?.label == null) continue; + children.Add(new LspDocumentSymbol + { + Name = label.label, + Detail = "label", + Kind = LspSymbolKind.Key, + Range = NodeRange(label), + SelectionRange = TokenRange(label.StartToken), + }); + } + } + result.Add(new LspDocumentSymbol + { + Name = func.name ?? func.nameToken.raw ?? "", + Detail = "function", + Kind = LspSymbolKind.Function, + Range = NodeRange(func), + SelectionRange = TokenRange(func.nameToken), + Children = children.Count > 0 ? children : null, + }); + } + + return result; + } + + private static LspRange NodeRange(IAstNode node) + { + var s = node.StartToken; + var e = node.EndToken ?? s; + var endChar = e.charNumber + (e.raw?.Length ?? e.Length); + return new LspRange + { + Start = new LspPosition { Line = s.lineNumber, Character = s.charNumber }, + End = new LspPosition { Line = e.lineNumber, Character = endChar }, + }; + } + + private static LspRange TokenRange(Token t) + { + if (t == null) return new LspRange + { + Start = new LspPosition(), End = new LspPosition(), + }; + var len = t.raw?.Length ?? t.Length; + return new LspRange + { + Start = new LspPosition { Line = t.lineNumber, Character = t.charNumber }, + End = new LspPosition { Line = t.lineNumber, Character = t.charNumber + len }, + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/FoldingRangeHandler.cs b/FadeBasic/LSP.Core/Handlers/FoldingRangeHandler.cs new file mode 100644 index 0000000..9375fd3 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/FoldingRangeHandler.cs @@ -0,0 +1,76 @@ +// Folding ranges: AST-driven. Visits the program and emits a fold for every +// compound statement (function, if/then, for/next, while/endwhile, +// do/loop, repeat/until, type definitions, tests) that spans multiple +// lines. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class FoldingRangeHandler + { + public static List Compute(FadeDocument doc) + { + var ranges = new List(); + if (doc?.Program == null) return ranges; + + doc.Program.Visit(node => + { + if (node.StartToken == null || node.EndToken == null) return; + if (node is ProgramNode) return; + + bool isFoldable = node is FunctionStatement + || node is IfStatement + || node is ForStatement + || node is WhileStatement + || node is DoLoopStatement + || node is RepeatUntilStatement + || node is TypeDefinitionStatement + || node is TestNode; + if (!isFoldable) return; + + var startLine = node.StartToken.lineNumber; + var endLine = node.EndToken.lineNumber; + if (endLine <= startLine) return; // single-line; no fold + + ranges.Add(new LspFoldingRange + { + StartLine = startLine, + EndLine = endLine, + StartCharacter = node.StartToken.charNumber, + EndCharacter = node.EndToken.charNumber + (node.EndToken.raw?.Length ?? node.EndToken.Length), + Kind = LspFoldingRangeKind.Region, + }); + }); + + // Multi-line comments fold too. The Lexer tags rem-block tokens + // with LexemType.RemStart so we can detect them here. + if (doc.LexResults?.combinedTokens != null) + { + foreach (var t in doc.LexResults.combinedTokens) + { + if (t?.raw == null) continue; + if (t.type != LexemType.KeywordRemStart && t.type != LexemType.KeywordRem) continue; + var startLine = t.lineNumber; + // raw may span multiple lines; count them. + int endLine = startLine; + foreach (var c in t.raw) + if (c == '\n') endLine++; + if (endLine > startLine) + { + ranges.Add(new LspFoldingRange + { + StartLine = startLine, + EndLine = endLine, + Kind = LspFoldingRangeKind.Comment, + }); + } + } + } + + return ranges; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/FormattingHandler.cs b/FadeBasic/LSP.Core/Handlers/FormattingHandler.cs new file mode 100644 index 0000000..97f8658 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/FormattingHandler.cs @@ -0,0 +1,111 @@ +// Formatting handlers (full document, range, on-type). All three delegate +// to TokenFormatter.Format on the lexed tokens, then translate the +// formatter's edits into LSP TextEdits. Range and on-type just filter +// the full set down. + +using System.Collections.Generic; +using FadeBasic; + +namespace FadeBasic.LSP.Core.Handlers +{ + public enum LspCasingSetting + { + Ignore = 0, + ToUpper = 1, + ToLower = 2, + } + + public class LspFormattingOptions + { + public int TabSize = 4; + public bool InsertSpaces = true; + public LspCasingSetting Casing = LspCasingSetting.Ignore; + } + + public static class FormattingHandler + { + public static List Compute(FadeDocument doc, LspFormattingOptions options) + { + var edits = new List(); + if (doc?.LexResults?.combinedTokens == null) return edits; + + options ??= new LspFormattingOptions(); + + var casing = options.Casing switch + { + LspCasingSetting.ToUpper => TokenFormatSettings.CasingSetting.ToUpper, + LspCasingSetting.ToLower => TokenFormatSettings.CasingSetting.ToLower, + _ => TokenFormatSettings.CasingSetting.Ignore, + }; + + var settings = new TokenFormatSettings + { + TabSize = options.TabSize, + UseTabs = !options.InsertSpaces, + Casing = casing, + }; + + var tokenEdits = TokenFormatter.Format(doc.LexResults.combinedTokens, settings); + + // LSP wants the same set; Monaco applies edits in any order safely. + foreach (var e in tokenEdits) + { + edits.Add(new LspTextEdit + { + Range = new LspRange + { + Start = new LspPosition { Line = e.startLine, Character = e.startChar }, + End = new LspPosition { Line = e.endLine, Character = e.endChar }, + }, + NewText = e.replacement ?? string.Empty, + }); + } + + return edits; + } + + public static List ComputeRange(FadeDocument doc, LspFormattingOptions options, LspRange range) + { + var all = Compute(doc, options); + if (range == null) return all; + + var filtered = new List(); + foreach (var e in all) + { + if (RangeIntersects(e.Range, range)) filtered.Add(e); + } + return filtered; + } + + public static List ComputeOnType(FadeDocument doc, LspFormattingOptions options, LspPosition position) + { + var all = Compute(doc, options); + if (position == null) return all; + + // Native handler keeps edits within 1 line of the caret. + var filtered = new List(); + foreach (var e in all) + { + var lineDist = System.Math.Abs(e.Range.Start.Line - position.Line); + if (lineDist < 2) filtered.Add(e); + } + return filtered; + } + + private static bool RangeIntersects(LspRange a, LspRange b) + { + // Treat ranges as inclusive of start, exclusive of end for "touch". + // Returns true if a and b share any character or touch at their ends. + if (Before(a.End, b.Start)) return false; + if (Before(b.End, a.Start)) return false; + return true; + } + + private static bool Before(LspPosition p, LspPosition q) + { + if (p.Line < q.Line) return true; + if (p.Line > q.Line) return false; + return p.Character < q.Character; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/HoverHandler.cs b/FadeBasic/LSP.Core/Handlers/HoverHandler.cs new file mode 100644 index 0000000..eaa319b --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/HoverHandler.cs @@ -0,0 +1,365 @@ +// Surface hover info at a position. Precedence: +// 1. Any diagnostic that covers the position → error markdown. +// 2. A built-in command call → rich markdown from ICommandDocsProvider. +// 3. A function call (user-defined) → trivia from the function decl. +// 4. A symbol reference / declaration → name + type + trivia (as markdown). +// 5. Generic token info as a fallback. + +using System.Text; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class HoverHandler + { + public static LspHoverResult Compute(FadeDocument doc, int line, int character) + { + if (doc == null) return null; + + // First: any diagnostic at this position + if (doc.Program != null) + { + foreach (var err in doc.Program.GetAllErrors()) + { + if (PositionInsideRange(err.location, line, character)) + return new LspHoverResult + { + Contents = "**Error " + err.errorCode.code + "**\n\n" + err.CombinedMessage, + Range = RangeOf(err.location), + }; + } + } + if (doc.LexResults?.tokenErrors != null) + { + foreach (var err in doc.LexResults.tokenErrors) + { + if (PositionInsideRange(err.location, line, character)) + return new LspHoverResult + { + Contents = "**Lex error " + err.errorCode.code + "**\n\n" + err.CombinedMessage, + Range = RangeOf(err.location), + }; + } + } + + // Symbol-aware hover: when the token under the cursor belongs to a + // VariableRef / ArrayIndexReference / FunctionStatement / + // DeclarationStatement, surface name + type + doc-comment. + // Built-in commands take priority so we can show rich docs. + if (doc.Program != null) + { + var hover = TryComputeCommandHover(doc, line, character) + ?? TryComputeSymbolHover(doc, line, character); + if (hover != null) return hover; + } + + // Otherwise: basic info on the token at this position + if (doc.LexResults != null) + { + foreach (var token in doc.LexResults.allTokens) + { + if (token.raw == null) continue; + if (token.lineNumber != line) continue; + if (character < token.charNumber) continue; + if (character > token.charNumber + token.Length) continue; + + return new LspHoverResult + { + Contents = "`" + token.raw + "` — " + token.type.ToString(), + Range = new LspRange + { + Start = new LspPosition { Line = token.lineNumber, Character = token.charNumber }, + End = new LspPosition { Line = token.lineNumber, Character = token.charNumber + token.Length }, + }, + }; + } + } + + return null; + } + + // Builds a markdown hover for a built-in command at the position. + // Walks the AST for the smallest CommandStatement / CommandExpression + // whose token range encloses the cursor. If we have a docs provider + // we surface summary + parameters + returns + remarks + examples, + // mirroring the native LSP's behavior; otherwise we return a basic + // signature header so the user at least sees the command name. + private static LspHoverResult TryComputeCommandHover(FadeDocument doc, int line, int character) + { + var fakeToken = new Token { lineNumber = line, charNumber = character }; + CommandInfo? command = null; + IAstNode owner = null; + + doc.Program.Visit(node => + { + if (node is ProgramNode) return; + if (node.StartToken == null || node.EndToken == null) return; + if (!Token.IsLocationBeforeOrEqual(node.StartToken, fakeToken)) return; + if (!Token.IsLocationBeforeOrEqual(fakeToken, node.EndToken)) return; + switch (node) + { + case CommandStatement cs: + // Prefer the innermost enclosing node — keep updating. + command = cs.command; owner = cs; + break; + case CommandExpression ce: + command = ce.command; owner = ce; + break; + } + }); + if (command == null || owner == null) return null; + + var md = BuildCommandMarkdown(command.Value, doc.Docs); + return new LspHoverResult + { + Contents = md, + Range = new LspRange + { + Start = new LspPosition { Line = owner.StartToken.lineNumber, Character = owner.StartToken.charNumber }, + End = new LspPosition { Line = owner.EndToken.lineNumber, Character = owner.EndToken.charNumber + (owner.EndToken.raw?.Length ?? owner.EndToken.Length) }, + }, + }; + } + + private static string BuildCommandMarkdown(CommandInfo command, ICommandDocsProvider docsProvider) + { + var docs = docsProvider?.Lookup(command); + var sb = new StringBuilder(); + + if (docs != null && !string.IsNullOrEmpty(docs.Url)) + sb.AppendLine($"[Full Documentation]({docs.Url})\n"); + + sb.AppendLine("### " + command.name); + if (!string.IsNullOrEmpty(docs?.Summary)) + sb.AppendLine(docs.Summary.Trim() + "\n"); + + // Parameters + var visibleArgs = command.args ?? new CommandArgInfo[0]; + int visibleCount = 0; + foreach (var a in visibleArgs) if (!a.isVmArg && !a.isRawArg) visibleCount++; + if (visibleCount > 0) + { + sb.AppendLine("#### Parameters"); + int paramIdx = 0; + for (var i = 0; i < visibleArgs.Length; i++) + { + var arg = visibleArgs[i]; + if (arg.isVmArg || arg.isRawArg) continue; + sb.Append("##### "); + if (VmUtil.TryGetVariableTypeDisplay(arg.typeCode, out var typeName)) + sb.Append("`").Append(typeName).Append("` "); + else + sb.Append("_unknown_ "); + if (arg.isOptional) sb.Append("_(optional)_ "); + if (arg.isRef) sb.Append("_(ref)_ "); + if (arg.isParams) sb.Append("_(params)_ "); + + var pdoc = (docs?.Parameters != null && paramIdx < docs.Parameters.Count) ? docs.Parameters[paramIdx] : null; + if (pdoc != null) + { + sb.Append(pdoc.Name); + sb.Append('\n'); + if (!string.IsNullOrEmpty(pdoc.Body)) sb.AppendLine(pdoc.Body.Trim()); + } + else + { + sb.AppendLine("arg" + (paramIdx + 1)); + } + paramIdx++; + } + } + + if (command.returnType != TypeCodes.VOID) + { + sb.AppendLine(); + sb.Append("#### Returns"); + if (VmUtil.TryGetVariableTypeDisplay(command.returnType, out var typeName)) + sb.Append(" `").Append(typeName).Append('`'); + if (!string.IsNullOrEmpty(docs?.Returns)) + { + sb.Append('\n'); + sb.AppendLine(docs.Returns.Trim()); + } + else + { + sb.AppendLine(); + } + } + + if (!string.IsNullOrEmpty(docs?.Remarks)) + { + sb.AppendLine(); + sb.AppendLine("#### Remarks"); + sb.AppendLine(docs.Remarks.Trim()); + } + + if (docs?.Examples != null && docs.Examples.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("#### Examples"); + foreach (var ex in docs.Examples) sb.AppendLine(ex.Trim()); + } + + return sb.ToString(); + } + + // Builds a markdown hover for symbol-bearing nodes. Returns null when + // the position isn't on a known symbol. + private static LspHoverResult TryComputeSymbolHover(FadeDocument doc, int line, int character) + { + var token = ReferencesHandler.FindTokenAt(doc, line, character); + if (token == null) return null; + + IAstNode hit = null; + doc.Program.Visit(x => + { + if (hit != null) return; + bool match = false; + switch (x) + { + case VariableRefNode _: + case ArrayIndexReference _: + case DeclarationStatement _: + case ParameterNode _: + case LabelDeclarationNode _: + case GoSubStatement _: + case GotoStatement _: + case RuntoStatement _: + match = Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, x.EndToken); + break; + case FunctionStatement fs: + match = x.StartToken == token || fs.nameToken == token + || Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, fs.nameToken); + break; + } + if (match) hit = x; + }); + if (hit == null) return null; + + // Resolve a reference to its declaration so we can read trivia. + var decl = hit; + if (decl.DeclaredFromSymbol?.source is IAstNode resolved) decl = resolved; + + var (header, trivia) = DescribeDeclaration(decl, hit); + if (header == null) return null; + + var md = header; + if (!string.IsNullOrEmpty(trivia)) + md += "\n\n---\n\n" + NormalizeTrivia(trivia); + + return new LspHoverResult + { + Contents = md, + Range = new LspRange + { + Start = new LspPosition { Line = token.lineNumber, Character = token.charNumber }, + End = new LspPosition { Line = token.lineNumber, Character = token.charNumber + (token.raw?.Length ?? token.Length) }, + }, + }; + } + + // Returns (markdown header, raw trivia). header includes a fenced + // code block; trivia is added separately so we can normalize it. + private static (string header, string trivia) DescribeDeclaration(IAstNode decl, IAstNode hitNode) + { + string trivia = null; + if (decl is IHasTriviaNode th) trivia = th.Trivia; + else if (hitNode is IHasTriviaNode th2) trivia = th2.Trivia; + + switch (decl) + { + case FunctionStatement func: + { + var parts = new System.Text.StringBuilder(); + parts.Append("function ").Append(func.name ?? func.nameToken?.raw ?? "").Append('('); + if (func.parameters != null) + { + for (var i = 0; i < func.parameters.Count; i++) + { + if (i > 0) parts.Append(", "); + var p = func.parameters[i]; + parts.Append(p.variable?.variableName ?? "?") + .Append(" as ") + .Append(p.type?.variableType.ToString() ?? "?"); + } + } + parts.Append(')'); + return ("```fade\n" + parts + "\n```", trivia); + } + case DeclarationStatement d: + { + var typeName = d.type?.variableType.ToString() ?? "?"; + var name = d.variableNode?.variableName ?? d.EndToken?.raw ?? "?"; + return ("```fade\n" + name + " as " + typeName + "\n```", trivia); + } + case ParameterNode p: + { + var typeName = p.type?.variableType.ToString() ?? "?"; + var name = p.variable?.variableName ?? "?"; + return ("```fade\n" + name + " as " + typeName + " (parameter)\n```", trivia); + } + case LabelDeclarationNode lbl: + { + return ("```fade\n:" + lbl.label + "\n```", trivia); + } + case VariableRefNode v: + { + return ("```fade\n" + (v.variableName ?? "?") + "\n```", trivia); + } + case ArrayIndexReference a: + { + var name = a.variableName ?? "?"; + return ("```fade\n" + name + "(...)\n```", trivia); + } + } + return (null, null); + } + + private static string NormalizeTrivia(string raw) + { + // Strip leading comment markers (`'`, `rem`, ``` ` ```) and trim each line. + var lines = raw.Replace("\r\n", "\n").Split('\n'); + var sb = new System.Text.StringBuilder(); + foreach (var line in lines) + { + var l = line.TrimStart(); + if (l.StartsWith("`")) l = l.Substring(1).TrimStart(); + else if (l.StartsWith("'")) l = l.Substring(1).TrimStart(); + else if (l.StartsWith("rem ", System.StringComparison.OrdinalIgnoreCase)) l = l.Substring(4).TrimStart(); + else if (l.Equals("rem", System.StringComparison.OrdinalIgnoreCase)) l = string.Empty; + if (sb.Length > 0) sb.Append('\n'); + sb.Append(l); + } + return sb.ToString().TrimEnd(); + } + + private static bool PositionInsideRange(TokenRange range, int line, int character) + { + if (range == null) return false; + var s = range.start; var e = range.end; + if (s == null || e == null) return false; + if (line < s.lineNumber || line > e.lineNumber) return false; + if (line == s.lineNumber && character < s.charNumber) return false; + if (line == e.lineNumber && character > e.charNumber + e.Length) return false; + return true; + } + + private static LspRange RangeOf(TokenRange range) + { + var s = range.start; var e = range.end ?? s; + return new LspRange + { + Start = new LspPosition { Line = s?.lineNumber ?? 0, Character = s?.charNumber ?? 0 }, + End = new LspPosition + { + Line = e?.lineNumber ?? 0, + Character = (e?.charNumber ?? 0) + (e?.Length ?? 1), + }, + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/ReferencesHandler.cs b/FadeBasic/LSP.Core/Handlers/ReferencesHandler.cs new file mode 100644 index 0000000..4c33638 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/ReferencesHandler.cs @@ -0,0 +1,134 @@ +// References: given a cursor position, find all AST nodes that resolve to +// the same Symbol (i.e. all uses of the variable, function, label, etc.). +// +// Ported from FadeBasic/LSP/Handlers/FindReferencesHandler.cs but stripped +// of the source-map indirection — Core operates on a single FadeDocument. + +using System.Collections.Generic; +using System.Linq; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public class LspLocation + { + public string Uri; + public LspRange Range; + } + + public static class ReferencesHandler + { + public static List Compute(FadeDocument doc, int line, int character) + { + if (doc?.Program == null || doc.LexResults == null) return null; + + // Find the token at this position. Try the cursor's own column, + // then drift one to the left (hail-mary for cursors sitting in + // immediate whitespace next to a token). + var token = FindTokenAt(doc, line, character) + ?? FindTokenAt(doc, line, character - 1); + if (token == null) return null; + + // Pass 1: collect every AST node that "starts/ends" at the + // clicked token (using location-equality so declaration sites and + // use sites both match) plus every node x where + // x.DeclaredFromSymbol.source has a token at this position. + var atToken = new List(); + var sourceCandidates = new HashSet(); + + void Pass1(IAstVisitable x) + { + bool isMatch = false; + if (x is VariableRefNode + or DeclarationStatement + or ArrayIndexReference + or LabelDeclarationNode + or GoSubStatement + or GotoStatement + or RuntoStatement) + { + isMatch = Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, x.EndToken); + } + else if (x is FunctionStatement funcStatement) + { + isMatch = x.StartToken == token || funcStatement.nameToken == token; + } + if (isMatch) atToken.Add(x); + } + doc.Program.Visit(Pass1); + + if (atToken.Count == 0) return new List(); + + // For each match, the "source" node is either the resolved + // DeclaredFromSymbol.source (clicked on a use) or the node itself + // (clicked on the declaration). Both get added to the candidate + // set so we union the uses of every possible interpretation. + foreach (var node in atToken) + { + sourceCandidates.Add(node); + if (node.DeclaredFromSymbol?.source is IAstNode src) + sourceCandidates.Add(src); + } + + // Also collect every distinct "source" node referenced anywhere + // in the program whose StartToken sits at the same location as + // our clicked token. This catches the case where the user clicks + // on the declaration site (e.g. the LHS of `x = 1`) but the + // implicit symbol's source is a different AST node (the + // surrounding AssignmentStatement). Matching by token location + // unions the two interpretations. + doc.Program.Visit(x => + { + if (x.DeclaredFromSymbol?.source is IAstNode src + && src.StartToken != null + && Token.AreLocationsEqual(src.StartToken, token)) + { + sourceCandidates.Add(src); + } + }); + + // Pass 2: every node whose DeclaredFromSymbol.source is in the + // candidate set is a reference. Source nodes themselves count. + var discovered = new HashSet(sourceCandidates); + doc.Program.Visit(x => + { + if (x.DeclaredFromSymbol?.source is IAstNode src && sourceCandidates.Contains(src)) + discovered.Add(x); + }); + + return discovered.Select(n => new LspLocation + { + Uri = doc.Uri, + Range = TokenRangeOf(n), + }).ToList(); + } + + internal static Token FindTokenAt(FadeDocument doc, int line, int character) + { + if (character < 0) return null; + foreach (var t in doc.LexResults.allTokens) + { + if (t.raw == null && t.caseInsensitiveRaw == null) continue; + if (t.lineNumber != line) continue; + if (character < t.charNumber) continue; + if (character > t.charNumber + t.Length) continue; + return t; + } + return null; + } + + internal static LspRange TokenRangeOf(IAstNode node) + { + var s = node.StartToken; + var e = node.EndToken ?? s; + var endChar = e.charNumber + (e.raw?.Length ?? e.Length); + return new LspRange + { + Start = new LspPosition { Line = s.lineNumber, Character = s.charNumber }, + End = new LspPosition { Line = s.lineNumber, Character = endChar }, + }; + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/RenameHandler.cs b/FadeBasic/LSP.Core/Handlers/RenameHandler.cs new file mode 100644 index 0000000..7b03b05 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/RenameHandler.cs @@ -0,0 +1,119 @@ +// Rename: find the declaration node behind the cursor, then emit a text edit +// for every reference site (the declaration's name token plus every node +// whose DeclaredFromSymbol.source resolves back to the declaration). + +using System; +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Ast; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class RenameHandler + { + private static readonly HashSet AllowedTypes = new HashSet + { + typeof(VariableRefNode), + typeof(ArrayIndexReference), + typeof(GoSubStatement), + typeof(GotoStatement), + typeof(RuntoStatement), + typeof(DeclarationStatement), + typeof(ParameterNode), + typeof(FunctionStatement), + typeof(LabelDeclarationNode), + }; + + public static LspWorkspaceEdit Compute(FadeDocument doc, int line, int character, string newName) + { + if (doc?.Program == null) return null; + if (string.IsNullOrEmpty(newName)) return null; + + var token = ReferencesHandler.FindTokenAt(doc, line, character) + ?? ReferencesHandler.FindTokenAt(doc, line, character - 1); + if (token == null) return null; + + IAstNode declaration = null; + void Visit(IAstVisitable x) + { + if (declaration != null) return; + if (!AllowedTypes.Contains(x.GetType())) return; + + bool match = false; + if (x is FunctionStatement fs) + match = x.StartToken == token || fs.nameToken == token + || Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, fs.nameToken); + else + match = Token.AreLocationsEqual(token, x.StartToken) + || Token.AreLocationsEqual(token, x.EndToken); + + if (match) declaration = x; + } + doc.Program.Visit(Visit); + if (declaration == null) return null; + + // Walk up to the declaration if we matched a reference. + if (declaration.DeclaredFromSymbol?.source is IAstNode resolved) + declaration = resolved; + + var edits = new List(); + AddEdit(declaration, newName, edits); + + doc.Program.Visit(x => + { + if (ReferenceEquals(x, declaration)) return; + if (x.DeclaredFromSymbol?.source is IAstNode src) + { + if (ReferenceEquals(src, declaration)) + { + AddEdit((IAstNode)x, newName, edits); + } + else if (src is AssignmentStatement asn + && ReferenceEquals(asn.variable, declaration)) + { + AddEdit((IAstNode)x, newName, edits); + } + } + }); + + if (edits.Count == 0) return null; + + return new LspWorkspaceEdit + { + Changes = new Dictionary> + { + [doc.Uri] = edits, + }, + }; + } + + private static Token GetNameToken(IAstNode node) + { + switch (node) + { + case FunctionStatement fs: return fs.nameToken; + // Variable name lives at EndToken; StartToken is the GLOBAL/LOCAL/DIM keyword. + case DeclarationStatement d: return d.EndToken; + case ParameterNode p: return p.StartToken; + default: return node.StartToken; + } + } + + private static void AddEdit(IAstNode node, string newName, List edits) + { + var t = GetNameToken(node); + if (t == null) return; + var len = t.raw?.Length ?? t.Length; + edits.Add(new LspTextEdit + { + Range = new LspRange + { + Start = new LspPosition { Line = t.lineNumber, Character = t.charNumber }, + End = new LspPosition { Line = t.lineNumber, Character = t.charNumber + len }, + }, + NewText = newName, + }); + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/SemanticTokensHandler.cs b/FadeBasic/LSP.Core/Handlers/SemanticTokensHandler.cs new file mode 100644 index 0000000..f725bdc --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/SemanticTokensHandler.cs @@ -0,0 +1,107 @@ +// Walk the document's tokens and produce LSP-encoded delta semantic tokens. +// Token classification reuses FadeBasic.Lsp.LSPUtil.ClassifyToken. + +using System.Collections.Generic; +using FadeBasic; +using FadeBasic.Lsp; + +namespace FadeBasic.LSP.Core.Handlers +{ + public static class SemanticTokensHandler + { + // Index in this array becomes the token type integer emitted in the + // encoded tokens stream. Frontends register a matching legend. + public static readonly string[] Legend = new[] + { + "comment", // 0 + "keyword", // 1 + "function", // 2 + "method", // 3 + "macro", // 4 + "parameter", // 5 + "struct", // 6 + "type", // 7 + "operator", // 8 + "number", // 9 + "string", // 10 + }; + + // Raw per-token classification. Frontends that need to filter or + // remap tokens (e.g. the native LSP applying source-map per-token) + // call this and build their own output; frontends that want the + // canonical LSP delta-encoded stream call Compute() below. + public readonly struct ClassifiedToken + { + public readonly Token Token; + public readonly PortableSemanticTokenType Type; + public ClassifiedToken(Token token, PortableSemanticTokenType type) + { + Token = token; Type = type; + } + } + + public static List Classify(FadeDocument doc) + { + var classified = new List(); + if (doc?.LexResults == null) return classified; + var tokens = doc.LexResults.allTokens; + for (int i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (token.raw == null) continue; + var prev = i > 0 ? tokens[i - 1] : null; + var result = LSPUtil.ClassifyToken(token, prev); + if (result.Skip) continue; + classified.Add(new ClassifiedToken(token, result.TokenType)); + } + return classified; + } + + // Map our token-type enum into the legend index emitted on the wire. + public static int LegendIndex(PortableSemanticTokenType t) => ToLegendIndex(t); + + public static List Compute(FadeDocument doc) + { + var data = new List(); + int prevLine = 0; + int prevChar = 0; + + foreach (var ct in Classify(doc)) + { + int line = ct.Token.lineNumber; + int ch = ct.Token.charNumber; + int deltaLine = line - prevLine; + int deltaChar = deltaLine == 0 ? ch - prevChar : ch; + + data.Add(deltaLine); + data.Add(deltaChar); + data.Add(ct.Token.Length); + data.Add(ToLegendIndex(ct.Type)); + data.Add(0); // no modifiers + + prevLine = line; + prevChar = ch; + } + return data; + } + + private static int ToLegendIndex(PortableSemanticTokenType t) + { + switch (t) + { + case PortableSemanticTokenType.Comment: return 0; + case PortableSemanticTokenType.Keyword: return 1; + case PortableSemanticTokenType.Function: return 2; + case PortableSemanticTokenType.Method: return 3; + case PortableSemanticTokenType.Macro: return 4; + case PortableSemanticTokenType.Parameter: return 5; + case PortableSemanticTokenType.Struct: return 6; + case PortableSemanticTokenType.Type: return 7; + case PortableSemanticTokenType.Operator: return 8; + case PortableSemanticTokenType.Number: return 9; + case PortableSemanticTokenType.String: return 10; + default: return 0; + } + } + } +} diff --git a/FadeBasic/LSP.Core/Handlers/SignatureHelpHandler.cs b/FadeBasic/LSP.Core/Handlers/SignatureHelpHandler.cs new file mode 100644 index 0000000..a4c0647 --- /dev/null +++ b/FadeBasic/LSP.Core/Handlers/SignatureHelpHandler.cs @@ -0,0 +1,269 @@ +// Signature help: given a cursor position inside a command/function call, +// surface the call's parameter list and which parameter the cursor is on. +// +// Ported from FadeBasic/LSP/Handlers/SignatureHelpHandler.cs but stripped +// of the project/source-map indirection — the Core variant operates on a +// single FadeDocument. Project-wide command docs aren't available here yet +// (the native handler resolves them via ProjectService); when they are +// surfaced into Core, plumb them through FadeDocument and lift the same +// docs-map lookup into BuildCommandSignature. + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FadeBasic; +using FadeBasic.Ast; +using FadeBasic.Virtual; + +namespace FadeBasic.LSP.Core.Handlers +{ + public class LspSignatureParameter + { + public string Label; + public string Documentation; + } + + public class LspSignatureInformation + { + public string Label; + public string Documentation; + public List Parameters; + public int ActiveParameter; + } + + public class LspSignatureHelp + { + public List Signatures; + public int ActiveSignature; + public int ActiveParameter; + } + + public static class SignatureHelpHandler + { + public static LspSignatureHelp Compute(FadeDocument doc, int line, int character) + { + if (doc?.Program == null || doc.LexResults == null) return null; + + // Use char-1 — the cursor sits between characters; the token that + // "encloses" the cursor is one to the left. + var probeChar = character > 0 ? character - 1 : 0; + var fakeToken = new Token { lineNumber = line, charNumber = probeChar }; + + bool Visit(IAstVisitable v) => + Token.IsLocationBeforeOrEqual(v.StartToken, fakeToken) && + Token.IsLocationBeforeOrEqual(fakeToken, v.EndToken); + + var group = doc.Program.Where(Visit) ?? new List(); + var node = group.LastOrDefault(); + + // User-defined function call + if (node is ArrayIndexReference arrRef && + arrRef.DeclaredFromSymbol?.source is FunctionStatement func) + { + return BuildFunctionSignature(func, arrRef.rankExpressions.Count); + } + + // Built-in command — check innermost first, then walk up the group + (CommandInfo command, List args, List argMap)? commandNode = node switch + { + CommandStatement cs => (cs.command, cs.args, cs.argMap), + CommandExpression ce => (ce.command, ce.args, ce.argMap), + _ => null, + }; + + if (commandNode == null) + { + // cursor may be inside an arg expression; walk up to find the enclosing command + for (var i = group.Count - 2; i >= 0; i--) + { + if (group[i] is CommandStatement cs2) + { + commandNode = (cs2.command, cs2.args, cs2.argMap); + break; + } + if (group[i] is CommandExpression ce2) + { + commandNode = (ce2.command, ce2.args, ce2.argMap); + break; + } + } + } + + if (commandNode != null) + { + return BuildCommandSignature( + commandNode.Value.command, + commandNode.Value.args, + commandNode.Value.argMap); + } + + // Fallback: AST is incomplete (e.g. user just typed `CommandName(`). + // Walk tokens backward to find the enclosing `(` and the CommandWord before it. + var tokens = doc.LexResults.allTokens; + var activeParam = 0; + var depth = 0; + Token openParen = null; + + for (var i = tokens.Count - 1; i >= 0; i--) + { + var t = tokens[i]; + if (t.lineNumber > line) continue; + if (t.lineNumber == line && t.charNumber > probeChar) continue; + + if (t.type == LexemType.ParenClose) depth++; + else if (t.type == LexemType.ParenOpen) + { + if (depth > 0) depth--; + else { openParen = t; break; } + } + else if (t.type == LexemType.ArgSplitter && depth == 0) + activeParam++; + } + + if (openParen != null) + { + Token nameToken = null; + foreach (var t in tokens) + { + if (t.lineNumber > openParen.lineNumber) break; + if (t.lineNumber == openParen.lineNumber && t.charNumber >= openParen.charNumber) break; + nameToken = t; + } + + if (nameToken?.type == LexemType.CommandWord) + { + var commandName = nameToken.caseInsensitiveRaw; + var command = doc.Commands.Commands.FirstOrDefault( + c => string.Equals(c.name, commandName, System.StringComparison.OrdinalIgnoreCase)); + + if (command.name != null) + { + return BuildCommandSignature( + command, + new List(), + new List(), + activeParam); + } + } + } + + return null; + } + + // --- User-defined functions ---------------------------------------- + + private static LspSignatureHelp BuildFunctionSignature(FunctionStatement func, int activeParam) + { + var paramInfos = new List(); + foreach (var param in func.parameters) + { + paramInfos.Add(new LspSignatureParameter + { + Label = $"{param.variable.variableName} as {param.type.variableType}", + }); + } + + var labelParts = func.parameters.Select(p => $"{p.variable.variableName} as {p.type.variableType}"); + var signatureLabel = $"{func.name}({string.Join(", ", labelParts)})"; + + return new LspSignatureHelp + { + Signatures = new List + { + new LspSignatureInformation + { + Label = signatureLabel, + Documentation = string.IsNullOrEmpty(func.Trivia) ? null : func.Trivia, + Parameters = paramInfos, + ActiveParameter = activeParam, + }, + }, + ActiveSignature = 0, + ActiveParameter = activeParam, + }; + } + + // --- Built-in commands --------------------------------------------- + + private static LspSignatureHelp BuildCommandSignature( + CommandInfo command, + List args, + List argMap, + int tokenWalkActiveParam = -1) + { + // Visible params = skip VM-internal and raw args + var visibleArgs = command.args + .Select((a, i) => (arg: a, index: i)) + .Where(x => !x.arg.isVmArg && !x.arg.isRawArg) + .ToList(); + + if (visibleArgs.Count == 0) return null; + + int activeCommandArgIndex; + if (tokenWalkActiveParam >= 0) + { + activeCommandArgIndex = System.Math.Min(tokenWalkActiveParam, visibleArgs[visibleArgs.Count - 1].index); + } + else if (args.Count == 0 || argMap.Count == 0) + { + activeCommandArgIndex = 0; + } + else + { + var lastArgInfoIndex = argMap[args.Count - 1]; + activeCommandArgIndex = command.args[lastArgInfoIndex].isParams + ? lastArgInfoIndex + : lastArgInfoIndex + 1; + } + + var activeVisibleIndex = visibleArgs.FindIndex(x => x.index == activeCommandArgIndex); + if (activeVisibleIndex < 0) + activeVisibleIndex = visibleArgs.Count - 1; + + var paramLabels = new List(); + var paramInfos = new List(); + for (var vi = 0; vi < visibleArgs.Count; vi++) + { + var arg = visibleArgs[vi].arg; + var paramName = $"arg{vi + 1}"; + var label = BuildArgLabel(arg, paramName); + paramLabels.Add(label); + paramInfos.Add(new LspSignatureParameter { Label = label }); + } + + var signatureLabel = $"{command.name}({string.Join(", ", paramLabels)})"; + + return new LspSignatureHelp + { + Signatures = new List + { + new LspSignatureInformation + { + Label = signatureLabel, + Parameters = paramInfos, + ActiveParameter = activeVisibleIndex, + }, + }, + ActiveSignature = 0, + ActiveParameter = activeVisibleIndex, + }; + } + + private static string BuildArgLabel(CommandArgInfo arg, string name) + { + VmUtil.TryGetVariableTypeDisplay(arg.typeCode, out var typeName); + var sb = new StringBuilder(); + if (arg.isRef) sb.Append("ref "); + sb.Append(typeName); + if (arg.isParams) sb.Append("..."); + sb.Append(' '); + sb.Append(name); + if (arg.isOptional) + { + sb.Insert(0, '['); + sb.Append(']'); + } + return sb.ToString(); + } + } +} diff --git a/FadeBasic/LSP/Handlers/CompletionHandler2.cs b/FadeBasic/LSP/Handlers/CompletionHandler2.cs index a86e4d7..d9909f3 100644 --- a/FadeBasic/LSP/Handlers/CompletionHandler2.cs +++ b/FadeBasic/LSP/Handlers/CompletionHandler2.cs @@ -1,36 +1,52 @@ -using System; +// Completion — thin adapter over FadeBasic.LSP.Core.Handlers.CompletionHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Pre-refactor native: +// * Mapped position via `unit.sourceMap.TryGetMappedLocation`. +// * Built a CompletionContext (macro vs non-macro program, leftToken, +// scope's positionedVariables entry) and invoked `LSPUtil.GetCompletions`. +// * Translated PortableCompletionItem → OmniSharp CompletionItem. +// * On `unit.macroProgram == null` while leftToken is a macro token, +// returned a single `` placeholder item. Likewise for +// missing `unit.program` → ``. +// +// Core CompletionHandler: +// * Does the SAME context building + LSPUtil.GetCompletions invocation, +// just without the placeholder items (returns an empty list when the +// program or macroProgram is unavailable). This is the only behavioral +// diff vs the old native handler — debug strings no longer leak into +// completions, which is what users want. +// +// Native still owns: +// * Per-document URI → CodeUnit lookup (CompilerService). +// * sourceMap position mapping (multi-file projects). +// * Documentation field — `func.Trivia` for user-defined functions is +// already populated by LSPUtil; built-in commands have no per-item +// documentation in either implementation. (The hover handler is the +// surface that shows command docs.) + using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using FadeBasic.Ast; -using FadeBasic.Lsp; -using LspCompletionContext = FadeBasic.Lsp.CompletionContext; -using FadeBasic.Virtual; using LSP.Services; -using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreCompletionHandler = FadeBasic.LSP.Core.Handlers.CompletionHandler; +using LspCompletionItem = FadeBasic.LSP.Core.LspCompletionItem; +using LspCompletionKind = FadeBasic.LSP.Core.LspCompletionKind; namespace LSP.Handlers; public class CompletionHandler2 : CompletionHandlerBase { - private ILogger _logger; - private CompilerService _compiler; - private ProjectService _project; + private readonly CompilerService _compiler; - public CompletionHandler2( - ILogger logger, - DocumentService docs, - CompilerService compiler, - ProjectService project) + public CompletionHandler2(CompilerService compiler) { - _project = project; _compiler = compiler; - _logger = logger; } protected override CompletionRegistrationOptions CreateRegistrationOptions(CompletionCapability capability, @@ -38,160 +54,44 @@ protected override CompletionRegistrationOptions CreateRegistrationOptions(Compl { DocumentSelector = TextDocumentSelector.ForLanguage(FadeBasicConstants.FadeBasicLanguage), TriggerCharacters = new Container(" ", ".", "(", "=", "+", "*", "-", "/"), - ResolveProvider = false + ResolveProvider = false, }; - - public override Task Handle(CompletionParams request, CancellationToken cancellationToken) + public override Task Handle(CompletionParams request, CancellationToken cancellationToken) { - _logger.LogInformation($"Handling a completion request... {request.TextDocument.Uri}"); + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(default(CompletionList?)); - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled unit"); - return null; - } + var unit = units[0]; - if (!_compiler.TryGetProjectContexts(request.TextDocument.Uri, out var projects)) + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, out var mappedLine, out var mappedChar)) { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled project"); - return null; + return Task.FromResult(default(CompletionList?)); } - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - _project.TryGetProject(projects[0], out var x); - var commandData = x.Item2; - - if (!unit.sourceMap.TryGetMappedLocation(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character, out var error, out var mappedLineNumber, out var mappedCharNumber)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any file"); - return null; - } - - var fakeToken = new Token - { - lineNumber = mappedLineNumber, charNumber = mappedCharNumber - }; - - // need to find the nearest token to the left. - Token leftToken = null; - for (var i = unit.lexerResults.allTokens.Count - 1; i >= 0; i --) - { - var token = unit.lexerResults.allTokens[i]; - if (token.lineNumber < mappedLineNumber) - { - leftToken = token; - break; - } - if (token.lineNumber == mappedLineNumber && token.charNumber <= mappedCharNumber) - { - leftToken = token; - break; - } - } - - var isMacro = false; - - if (leftToken == null) - { - _logger.LogInformation("There is no found left token"); - return null; - } + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString()); + var coreItems = CoreCompletionHandler.Compute(doc, mappedLine, mappedChar); - isMacro = leftToken.flags.HasFlag(TokenFlags.IsMacroToken); - - bool Visit(IAstVisitable v) - { - return v is ProgramNode || Token.IsLocationBeforeOrEqual(v.StartToken, fakeToken) && Token.IsLocationBeforeOrEqual(fakeToken, v.EndToken); - } - - var programGroup = unit.program?.Where(Visit); - var programNode = programGroup?.LastOrDefault(); - - var macroGroup = unit.macroProgram?.Where(Visit); - var macroNode = macroGroup?.LastOrDefault(); - - if (isMacro) - { - if (unit.macroProgram == null) - { - return Task.FromResult(new CompletionList(new List - { - new CompletionItem - { - Kind = CompletionItemKind.Folder, - InsertText = "" - } - })); - } - if (!unit.macroProgram.scope.positionedVariables.TryFindEntry(fakeToken, out var entry)) - { - entry = unit.macroProgram.scope.positionedVariables.entries[0]; - } - - var context = new LspCompletionContext - { - IsMacro = true, - FakeToken = fakeToken, - LeftToken = leftToken, - Program = unit.macroProgram, - Commands = unit.commands, - FunctionName = entry.value.Item2, - Group = macroGroup, - ConstantTable = unit.lexerResults.constantTable, - LocalScope = entry.value.Item1 - }; - - var items = LSPUtil.GetCompletions(context); - return Task.FromResult(new CompletionList(items.Select(ToCompletionItem).ToList(), isIncomplete: false)); - - } - else - { - if (unit.program == null) - { - return Task.FromResult(new CompletionList(new List - { - new CompletionItem - { - Kind = CompletionItemKind.Folder, - InsertText = "" - } - })); - } - if (!unit.program.scope.positionedVariables.TryFindEntry(fakeToken, out var entry)) - { - entry = unit.program.scope.positionedVariables.entries[0]; - } - - var context = new LspCompletionContext - { - FakeToken = fakeToken, - LeftToken = leftToken, - Program = unit.program, - Commands = unit.commands, - FunctionName = entry.value.Item2, - Group = programGroup, - ConstantTable = unit.lexerResults.constantTable, - LocalScope = entry.value.Item1 - }; - var items = LSPUtil.GetCompletions(context); - return Task.FromResult(new CompletionList(items.Select(ToCompletionItem).ToList(), isIncomplete: false)); - } + var items = new List(coreItems.Count); + foreach (var p in coreItems) items.Add(ToOmni(p)); + return Task.FromResult(new CompletionList(items, isIncomplete: false)); } - static CompletionItem ToCompletionItem(PortableCompletionItem p) + private static CompletionItem ToOmni(LspCompletionItem p) { return new CompletionItem { - Label = p.Label, - InsertText = p.InsertText, + Label = p.Label ?? string.Empty, + InsertText = p.InsertText ?? string.Empty, Kind = ToCompletionItemKind(p.Kind), - Detail = p.Detail, + Detail = p.Detail ?? string.Empty, SortText = p.SortText, FilterText = p.FilterText, - InsertTextFormat = p.InsertTextFormat == PortableInsertTextFormat.Snippet + InsertTextFormat = p.InsertTextFormat == FadeBasic.LSP.Core.LspInsertTextFormat.Snippet ? InsertTextFormat.Snippet : InsertTextFormat.PlainText, InsertTextMode = InsertTextMode.AdjustIndentation, @@ -200,38 +100,37 @@ static CompletionItem ToCompletionItem(PortableCompletionItem p) : new MarkupContent { Kind = MarkupKind.Markdown, - Value = p.Documentation + Value = p.Documentation!, }, Command = p.TriggerParameterHints - ? new Command + ? new OmniSharp.Extensions.LanguageServer.Protocol.Models.Command { Name = "editor.action.triggerParameterHints", - Title = "Trigger Parameter Hints" + Title = "Trigger Parameter Hints", } : null, }; } - static CompletionItemKind ToCompletionItemKind(PortableCompletionKind kind) + private static CompletionItemKind ToCompletionItemKind(LspCompletionKind kind) { switch (kind) { - case PortableCompletionKind.Variable: return CompletionItemKind.Variable; - case PortableCompletionKind.Function: return CompletionItemKind.Function; - case PortableCompletionKind.Interface: return CompletionItemKind.Interface; - case PortableCompletionKind.Keyword: return CompletionItemKind.Keyword; - case PortableCompletionKind.Field: return CompletionItemKind.Field; - case PortableCompletionKind.Class: return CompletionItemKind.Class; - case PortableCompletionKind.Constant: return CompletionItemKind.Constant; - case PortableCompletionKind.Reference: return CompletionItemKind.Reference; - case PortableCompletionKind.Folder: return CompletionItemKind.Folder; + case LspCompletionKind.Variable: return CompletionItemKind.Variable; + case LspCompletionKind.Function: return CompletionItemKind.Function; + case LspCompletionKind.Interface: return CompletionItemKind.Interface; + case LspCompletionKind.Keyword: return CompletionItemKind.Keyword; + case LspCompletionKind.Field: return CompletionItemKind.Field; + case LspCompletionKind.Class: return CompletionItemKind.Class; + case LspCompletionKind.Constant: return CompletionItemKind.Constant; + case LspCompletionKind.Reference: return CompletionItemKind.Reference; + case LspCompletionKind.Folder: return CompletionItemKind.Folder; default: return CompletionItemKind.Text; } } public override Task Handle(CompletionItem request, CancellationToken cancellationToken) { - _logger.LogInformation($"Handling a completion item... {request.TextEditText}"); return Task.FromResult(request); } } diff --git a/FadeBasic/LSP/Handlers/CoreAdapter.cs b/FadeBasic/LSP/Handlers/CoreAdapter.cs new file mode 100644 index 0000000..bb698a8 --- /dev/null +++ b/FadeBasic/LSP/Handlers/CoreAdapter.cs @@ -0,0 +1,39 @@ +// Helpers that turn the native LSP's per-request CodeUnit into a Core +// FadeDocument so handlers can delegate to FadeBasic.LSP.Core.Handlers. +// +// The native LSP is project-aware — its CodeUnit may span multiple source +// files concatenated via SourceMap, with macros expanded into a parallel +// `macroProgram`. When all of that lives in a single source file (the +// common case), the source map is identity and the conversion is direct. +// Multi-file projects are handled by the existing native logic that maps +// positions through `unit.sourceMap` before / after calling Core. + +using FadeBasic; +using FadeBasic.ApplicationSupport.Project; +using FadeBasic.LSP.Core; +using FadeBasic.Virtual; +using ApplicationSupport.Code; + +namespace LSP.Handlers; + +internal static class CoreAdapter +{ + // Wrap a CodeUnit + URI as a FadeDocument suitable for Core handlers. + // Docs (when available) are surfaced so command hover renders rich + // markdown — same source the native HoverHandler uses, just behind + // the Core ICommandDocsProvider interface. + public static FadeDocument ToDocument(CodeUnit unit, string uri, ProjectDocs? docs = null) + { + return new FadeDocument + { + Uri = uri, + // We don't bother re-reconstructing the source text here; Core + // handlers operate on the parsed AST + lex results, not raw text. + Text = string.Empty, + LexResults = unit.lexerResults, + Program = unit.program, + Commands = unit.commands, + Docs = docs == null ? null : new ProjectDocsCommandDocsProvider(docs), + }; + } +} diff --git a/FadeBasic/LSP/Handlers/DocumentSymbolHandler.cs b/FadeBasic/LSP/Handlers/DocumentSymbolHandler.cs index 993af68..c843b24 100644 --- a/FadeBasic/LSP/Handlers/DocumentSymbolHandler.cs +++ b/FadeBasic/LSP/Handlers/DocumentSymbolHandler.cs @@ -1,75 +1,104 @@ +// Document outline — thin adapter over FadeBasic.LSP.Core.Handlers.DocumentSymbolHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Pre-refactor native: enumerated *every* lexer token whose type wasn't +// OpEqual / LiteralInt and dumped one DocumentSymbol per token, with the +// token kind inferred from LexemType (Variable/String/Number/Key). This +// produced an extremely noisy outline — every identifier, keyword, and +// string literal in the file showed up as a separate symbol. It also +// re-read the file from disk on every request (TODO comment acknowledged +// this as a hack). +// +// Core: walks the parsed AST and emits structured outline entries — +// FunctionStatement (with nested LabelDeclarationNode children), +// top-level DeclarationStatement, TypeDefinitionStatement, LabelDeclarationNode. +// Each entry has a full-extent Range (covers the body) and a +// SelectionRange (just the name token), which is what VSCode's +// breadcrumbs and outline view expect. +// +// Behavioral diff (intentional): +// * No more per-token noise — only meaningful symbols. +// * Range now covers the full body of a function/type/label rather than +// a single token. +// * No file IO — Core reads from the in-memory parse tree. + using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using OmniSharp.Extensions.LanguageServer.Protocol; +using LSP.Services; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; -using FadeBasic; +using CoreDocSymbolHandler = FadeBasic.LSP.Core.Handlers.DocumentSymbolHandler; +using CoreDocSymbol = FadeBasic.LSP.Core.LspDocumentSymbol; +using LspSymbolKind = FadeBasic.LSP.Core.LspSymbolKind; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; public class DocumentSymbolHandler : IDocumentSymbolHandler { - public static SymbolKind Convert(LexemType type) + private readonly CompilerService _compiler; + + public DocumentSymbolHandler(CompilerService compiler) { - switch (type) - { - case LexemType.OpEqual: - return SymbolKind.Operator; + _compiler = compiler; + } + + public Task Handle(DocumentSymbolParams request, CancellationToken cancellationToken) + { + var empty = new SymbolInformationOrDocumentSymbolContainer(new List()); + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(empty); - case LexemType.VariableGeneral: - return SymbolKind.Variable; - - case LexemType.LiteralString: - return SymbolKind.String; + var doc = CoreAdapter.ToDocument(units[0], request.TextDocument.Uri.ToString()); + var coreSyms = CoreDocSymbolHandler.Compute(doc); - case LexemType.LiteralReal: - case LexemType.LiteralInt: - return SymbolKind.Number; - default: - return SymbolKind.Key; - } + var result = new List(coreSyms.Count); + foreach (var s in coreSyms) result.Add(new SymbolInformationOrDocumentSymbol(Convert(s))); + return Task.FromResult( + new SymbolInformationOrDocumentSymbolContainer(result)); } - - - public async Task Handle(DocumentSymbolParams request, CancellationToken cancellationToken) - { - // TODO: get this data from the sync handler - var content = await File.ReadAllTextAsync(DocumentUri.GetFileSystemPath(request), cancellationToken); - var lexer = new Lexer(); - var tokens = lexer.Tokenize(content); - var symbols = new List(); + private static DocumentSymbol Convert(CoreDocSymbol s) + { + var children = new List(); + if (s.Children != null) + foreach (var c in s.Children) children.Add(Convert(c)); - foreach (var token in tokens) + return new DocumentSymbol { - if (token.raw == null) continue; + Name = s.Name ?? string.Empty, + Detail = s.Detail ?? string.Empty, + Kind = ToSymbolKind(s.Kind), + Range = new Range( + s.Range.Start.Line, s.Range.Start.Character, + s.Range.End.Line, s.Range.End.Character), + SelectionRange = new Range( + s.SelectionRange.Start.Line, s.SelectionRange.Start.Character, + s.SelectionRange.End.Line, s.SelectionRange.End.Character), + Children = new Container(children), + }; + } - switch (token.type) - { - case LexemType.OpEqual: - case LexemType.LiteralInt: - continue; - default: - break; - } - - var symbol = new DocumentSymbol - { - Detail = token.raw, - Kind = Convert(token.type), - SelectionRange = new Range(token.lineNumber, token.charNumber, token.lineNumber, token.charNumber + token.Length), - Range = new Range(token.lineNumber, token.charNumber, token.lineNumber, token.charNumber + token.Length), - Name = token.raw - }; - - symbols.Add(symbol); - } - - return symbols; + private static SymbolKind ToSymbolKind(LspSymbolKind kind) + { + return kind switch + { + LspSymbolKind.Function => SymbolKind.Function, + LspSymbolKind.Variable => SymbolKind.Variable, + LspSymbolKind.Constant => SymbolKind.Constant, + LspSymbolKind.Struct => SymbolKind.Struct, + LspSymbolKind.Method => SymbolKind.Method, + LspSymbolKind.Interface => SymbolKind.Interface, + LspSymbolKind.Key => SymbolKind.Key, + LspSymbolKind.Class => SymbolKind.Class, + LspSymbolKind.String => SymbolKind.String, + LspSymbolKind.Number => SymbolKind.Number, + _ => SymbolKind.Variable, + }; } public DocumentSymbolRegistrationOptions GetRegistrationOptions(DocumentSymbolCapability capability, @@ -78,7 +107,6 @@ public DocumentSymbolRegistrationOptions GetRegistrationOptions(DocumentSymbolCa return new DocumentSymbolRegistrationOptions { DocumentSelector = TextDocumentSelector.ForLanguage(FadeBasicConstants.FadeBasicLanguage), - }; } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FindReferencesHandler.cs b/FadeBasic/LSP/Handlers/FindReferencesHandler.cs index 88fd78b..7dffefc 100644 --- a/FadeBasic/LSP/Handlers/FindReferencesHandler.cs +++ b/FadeBasic/LSP/Handlers/FindReferencesHandler.cs @@ -1,15 +1,43 @@ +// References — thin adapter over FadeBasic.LSP.Core.Handlers.ReferencesHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Common: +// * Both find the token at the cursor (with a one-character left-drift +// hail-mary for cursors sitting in whitespace). +// * Both gather AST nodes whose StartToken/EndToken locations match the +// clicked token, then chase DeclaredFromSymbol.source. +// +// Core ADDS: +// * Clicking the declaration site (e.g. the LHS of an implicit `x = 1`) +// now also returns the use sites. The old native handler returned only +// the LHS node here because it followed a single chain — Core unions +// every interpretation (node itself + DeclaredFromSymbol.source + +// nodes whose DeclaredFromSymbol.source has a token at the clicked +// position), so def-site clicks behave like use-site clicks. +// * `or RuntoStatement` is in the allowed-types match list (old native +// code had it too, parity here). +// +// Core MISSES (vs native): +// * The old native walked `unit.macroProgram` in addition to +// `unit.program`. Tokens inside macro-expanded regions don't currently +// resolve through Core, which only inspects `doc.Program`. +// * Multi-file source-map mapping happens HERE (not in Core), so ranges +// come back as project-buffer coordinates and we translate them to +// originating-file coordinates before returning. + using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using FadeBasic.Ast; using LSP.Services; using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreRefsHandler = FadeBasic.LSP.Core.Handlers.ReferencesHandler; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; @@ -19,8 +47,8 @@ public class FindReferencesHandler : ReferencesHandlerBase private readonly CompilerService _compiler; public FindReferencesHandler( - ILogger logger, - DocumentService docs, + ILogger logger, + DocumentService docs, CompilerService compiler) { _logger = logger; @@ -36,106 +64,40 @@ protected override ReferenceRegistrationOptions CreateRegistrationOptions(Refere }; } - public override async Task Handle(ReferenceParams request, CancellationToken cancellationToken) + public override Task Handle(ReferenceParams request, CancellationToken cancellationToken) { - var locations = new List(); - - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled unit"); - return null; - } + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(default(LocationContainer?)); - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - - /* - * given the position, we could find the token, - * from the token, we could look up and see - */ - _logger.LogInformation("looking for def : " + request.Position); - - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character, out var token)) + var unit = units[0]; + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, + out var mappedLine, + out var mappedChar)) { - // try one character to the left... sort of a hail marry, but this happens if the user's cursor is not ON the token, but in the immediate white space on the other side. - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character - 1, out token)) - { - return null; // no token found - } + return Task.FromResult(default(LocationContainer?)); } - - // use the token to resolve the variable - var referencedNodes = new List(); - // var x = unit.program.scope.functionTable; + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString()); + var coreLocs = CoreRefsHandler.Compute(doc, mappedLine, mappedChar); + if (coreLocs == null || coreLocs.Count == 0) + return Task.FromResult(default(LocationContainer?)); - void Visit(IAstVisitable x) + var locations = new List(coreLocs.Count); + foreach (var l in coreLocs) { - bool isMatch = false; - if (x is VariableRefNode or DeclarationStatement or ArrayIndexReference or LabelDeclarationNode or GoSubStatement or GotoStatement or RuntoStatement) + var startTok = new Token { lineNumber = l.Range.Start.Line, charNumber = l.Range.Start.Character }; + var origin = unit.sourceMap.GetOriginalLocation(startTok); + var len = System.Math.Max(1, l.Range.End.Character - l.Range.Start.Character); + locations.Add(new Location { - isMatch = Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken); - - } else if (x is FunctionStatement funcStatement) - { - isMatch = x.StartToken == token || funcStatement.nameToken == token; - } - - if (isMatch) - { - referencedNodes.Add(x); - } - } - - unit.program.Visit(Visit); - unit.macroProgram?.Visit(Visit); - - if (referencedNodes.Count == 0) - { - return null; - - } - var expr = referencedNodes[0]; - - // if the user clicked on a reference to the root; this resolves it. - if (expr.DeclaredFromSymbol != null) - { - expr = expr.DeclaredFromSymbol.source; - } - - var discoveredNodes = new List - { - // the declaration counts as a reference - expr - }; - unit.program.Visit(x => - { - if (x.DeclaredFromSymbol != null) - { - if (x.DeclaredFromSymbol.source == expr) - { - discoveredNodes.Add(x); - } - } - }); - - - locations = discoveredNodes.Select(x => - { - var source = unit.sourceMap.GetOriginalLocation(x.StartToken); - return new Location - { - Uri = DocumentUri.File(source.fileName), - Range = new Range(source.startLine, source.startChar, source.startLine, - x.EndToken.charNumber + x.EndToken.raw?.Length ?? 0) - }; - }).ToList(); - - if (locations.Count == 0) - { - return null; + Uri = DocumentUri.File(origin.fileName), + Range = new Range(origin.startLine, origin.startChar, origin.startLine, origin.startChar + len), + }); } - return new LocationContainer(locations); + return Task.FromResult(new LocationContainer(locations)); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FoldingRangeHandler.cs b/FadeBasic/LSP/Handlers/FoldingRangeHandler.cs index b49c3d4..e485b46 100644 --- a/FadeBasic/LSP/Handlers/FoldingRangeHandler.cs +++ b/FadeBasic/LSP/Handlers/FoldingRangeHandler.cs @@ -1,28 +1,57 @@ +// Folding ranges — thin adapter over FadeBasic.LSP.Core.Handlers.FoldingRangeHandler. +// The native LSP's previous implementation was a hardcoded stub (lines 2–4); +// Core's AST-driven version covers function bodies, if/for/while/do/repeat +// blocks, type/test blocks, and multi-line rem comments. + using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FadeBasic; +using LSP.Services; +using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreFoldingHandler = FadeBasic.LSP.Core.Handlers.FoldingRangeHandler; namespace LSP.Handlers; public class FoldingRangeHandler : IFoldingRangeHandler { + private readonly CompilerService _compiler; + + public FoldingRangeHandler(CompilerService compiler) + { + _compiler = compiler; + } + public Task?> Handle(FoldingRangeRequestParam request, CancellationToken cancellationToken) { - var ranges = new List(); - ranges.Add(new FoldingRange + var empty = new Container(new List()); + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult?>(empty); + + var doc = CoreAdapter.ToDocument(units[0], request.TextDocument.Uri.ToString()); + var ranges = CoreFoldingHandler.Compute(doc); + + var omni = new List(ranges.Count); + foreach (var r in ranges) { - StartLine = 2, - EndLine = 4, - Kind = FoldingRangeKind.Region, - StartCharacter = 0, - EndCharacter = 0 - }); - - return Task.FromResult?>(new Container(ranges)); + omni.Add(new FoldingRange + { + StartLine = r.StartLine, + EndLine = r.EndLine, + StartCharacter = r.StartCharacter, + EndCharacter = r.EndCharacter, + Kind = r.Kind switch + { + FadeBasic.LSP.Core.LspFoldingRangeKind.Comment => FoldingRangeKind.Comment, + FadeBasic.LSP.Core.LspFoldingRangeKind.Imports => FoldingRangeKind.Imports, + _ => FoldingRangeKind.Region, + }, + }); + } + return Task.FromResult?>(new Container(omni)); } public FoldingRangeRegistrationOptions GetRegistrationOptions(FoldingRangeCapability capability, @@ -30,7 +59,7 @@ public FoldingRangeRegistrationOptions GetRegistrationOptions(FoldingRangeCapabi { return new FoldingRangeRegistrationOptions { - DocumentSelector = new TextDocumentSelector(TextDocumentFilter.ForLanguage(FadeBasicConstants.FadeBasicLanguage)) + DocumentSelector = new TextDocumentSelector(TextDocumentFilter.ForLanguage(FadeBasicConstants.FadeBasicLanguage)), }; } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FormattingHandler.cs b/FadeBasic/LSP/Handlers/FormattingHandler.cs index 92867f9..88a83c6 100644 --- a/FadeBasic/LSP/Handlers/FormattingHandler.cs +++ b/FadeBasic/LSP/Handlers/FormattingHandler.cs @@ -1,3 +1,20 @@ +// Formatting — thin adapter over FadeBasic.LSP.Core.Handlers.FormattingHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Both run `TokenFormatter.Format(unit.lexerResults.combinedTokens, settings)` +// and translate the resulting edits to LSP TextEdits. +// +// Native passed casing from the language-server configuration setting +// `conf.language.fade.formatCasing` ("upper" | "lower" | other). Core +// takes a TabSize/InsertSpaces/Casing options object. We adapt by reading +// the same setting before invoking Core. +// +// Native re-lexed the source from disk on every request. Now we lex once +// per document change via CompilerService and reuse those tokens through +// Core. This avoids reading the file system on every format and keeps the +// formatter output in sync with everything else the LSP has parsed. + using System; using System.Collections.Generic; using System.Threading; @@ -9,6 +26,9 @@ using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using CoreFormatHandler = FadeBasic.LSP.Core.Handlers.FormattingHandler; +using LspCasingSetting = FadeBasic.LSP.Core.Handlers.LspCasingSetting; +using LspFormattingOptions = FadeBasic.LSP.Core.Handlers.LspFormattingOptions; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; @@ -24,82 +44,59 @@ protected override DocumentFormattingRegistrationOptions CreateRegistrationOptio }; } - - private readonly ILogger _logger; - private readonly DocumentService _docs; + private readonly ILogger _logger; private readonly ILanguageServerConfiguration _lsp; - private CompilerService _compiler; - private readonly ProjectService _projects; + private readonly CompilerService _compiler; public FormattingHandler( ILanguageServerConfiguration lsp, - ILogger logger, - DocumentService docs, CompilerService compiler, ProjectService projects) + ILogger logger, + CompilerService compiler) { _lsp = lsp; - _compiler = compiler; - _projects = projects; _logger = logger; - _docs = docs; + _compiler = compiler; } - + public override async Task Handle(DocumentFormattingParams request, CancellationToken cancellationToken) { var edits = new List(); + // Honor the existing language-server config setting that controls + // identifier casing — same behavior as the pre-refactor handler. var config = await _lsp.GetConfiguration(new ConfigurationItem { - Section = "conf.language.fade" + Section = "conf.language.fade", }); var casingStr = config.GetSection("conf.language.fade")["formatCasing"]; - var casingOption = TokenFormatSettings.CasingSetting.Ignore; + var casing = LspCasingSetting.Ignore; if (string.Equals("upper", casingStr, StringComparison.InvariantCultureIgnoreCase)) - { - casingOption = TokenFormatSettings.CasingSetting.ToUpper; - } else if (string.Equals("lower", casingStr, StringComparison.InvariantCultureIgnoreCase)) - { - casingOption = TokenFormatSettings.CasingSetting.ToLower; - } - - if (!_compiler.TryGetProjectContexts(request.TextDocument.Uri, out var contexts)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any project contexts"); - return edits; - } - - var projectUrl = contexts[0]; - if (!_projects.TryGetProject(projectUrl, out var project)) - { - _logger.LogError($"source document=[{projectUrl}] did not map to any project"); - return edits; - } + casing = LspCasingSetting.ToUpper; + else if (string.Equals("lower", casingStr, StringComparison.InvariantCultureIgnoreCase)) + casing = LspCasingSetting.ToLower; - if (!_docs.TryGetSourceDocument(request.TextDocument.Uri, out var doc)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] does not have a backing document"); + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) return edits; - } - var lexer = new Lexer(); - var lexerResults = lexer.TokenizeWithErrors(doc, project.Item2.collection); - - var tokenEdits = TokenFormatter.Format(lexerResults.combinedTokens, new TokenFormatSettings + var doc = CoreAdapter.ToDocument(units[0], request.TextDocument.Uri.ToString()); + var coreEdits = CoreFormatHandler.Compute(doc, new LspFormattingOptions { TabSize = request.Options.TabSize, - UseTabs = !request.Options.InsertSpaces, - Casing = casingOption + InsertSpaces = request.Options.InsertSpaces, + Casing = casing, }); - for (var i = tokenEdits.Count - 1; i >= 0; i--) + foreach (var e in coreEdits) { - var tokenEdit = tokenEdits[i]; edits.Add(new TextEdit { - Range = new Range(tokenEdit.startLine, tokenEdit.startChar, tokenEdit.endLine, tokenEdit.endChar), - NewText = tokenEdit.replacement + Range = new Range( + e.Range.Start.Line, e.Range.Start.Character, + e.Range.End.Line, e.Range.End.Character), + NewText = e.NewText ?? string.Empty, }); } - + return edits; } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FormattingRangeHandler.cs b/FadeBasic/LSP/Handlers/FormattingRangeHandler.cs index 5823d40..fe3bc40 100644 --- a/FadeBasic/LSP/Handlers/FormattingRangeHandler.cs +++ b/FadeBasic/LSP/Handlers/FormattingRangeHandler.cs @@ -1,4 +1,11 @@ -using System; +// Range formatting — runs the full FormattingHandler then filters the +// result by intersection with the requested range. Pre-refactor native +// did the same thing. +// +// Core also has a `ComputeRange` method that does the equivalent filter; +// we keep the "format full then filter" composition here so we re-use the +// already-wired config / source-map handling in FormattingHandler. + using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -20,34 +27,29 @@ protected override DocumentRangeFormattingRegistrationOptions CreateRegistration DocumentSelector = TextDocumentSelector.ForLanguage(FadeBasicConstants.FadeBasicLanguage), }; } - - private readonly ILogger _logger; + private readonly FormattingHandler _formatter; - public FormattingRangeHandler( - FormattingHandler formatter, - ILogger logger) + + public FormattingRangeHandler(FormattingHandler formatter) { _formatter = formatter; - _logger = logger; } - public override async Task Handle(DocumentRangeFormattingParams request, CancellationToken cancellationToken) { var edits = await _formatter.Handle(new DocumentFormattingParams { Options = request.Options, - TextDocument = request.TextDocument + TextDocument = request.TextDocument, }, cancellationToken); - var actualEdits = new List(); + if (edits == null) return actualEdits; foreach (var edit in edits) { if (!edit.Range.IntersectsOrTouches(request.Range)) continue; actualEdits.Add(edit); - } return actualEdits; } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/FormattingWhenTypingHandler.cs b/FadeBasic/LSP/Handlers/FormattingWhenTypingHandler.cs index f327868..16773d0 100644 --- a/FadeBasic/LSP/Handlers/FormattingWhenTypingHandler.cs +++ b/FadeBasic/LSP/Handlers/FormattingWhenTypingHandler.cs @@ -1,22 +1,22 @@ +// On-type formatting — runs the full FormattingHandler then keeps edits +// within one line of the caret. Same composition the pre-refactor handler +// used; just delegates the actual formatting to the shared adapter. + using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using LSP.Services; using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; -using OmniSharp.Extensions.LanguageServer.Protocol.Server; -using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; public class FormattingWhenTypingHandler : DocumentOnTypeFormattingHandlerBase { - protected override DocumentOnTypeFormattingRegistrationOptions CreateRegistrationOptions(DocumentOnTypeFormattingCapability capability, ClientCapabilities clientCapabilities) { @@ -25,39 +25,32 @@ protected override DocumentOnTypeFormattingRegistrationOptions CreateRegistratio { DocumentSelector = TextDocumentSelector.ForLanguage(FadeBasicConstants.FadeBasicLanguage), FirstTriggerCharacter = chars[0].ToString(), - MoreTriggerCharacter = chars.Select(x => x.ToString()).ToList() + MoreTriggerCharacter = chars.Select(x => x.ToString()).ToList(), }; } - - private readonly ILogger _logger; + private readonly FormattingHandler _formatter; - public FormattingWhenTypingHandler( - FormattingHandler formatter, - ILogger logger) + + public FormattingWhenTypingHandler(FormattingHandler formatter) { _formatter = formatter; - _logger = logger; } - + public override async Task Handle(DocumentOnTypeFormattingParams request, CancellationToken cancellationToken) { var edits = await _formatter.Handle(new DocumentFormattingParams { Options = request.Options, - TextDocument = request.TextDocument + TextDocument = request.TextDocument, }, cancellationToken); - var actualEdits = new List(); + if (edits == null) return actualEdits; foreach (var edit in edits) { var lineDist = Math.Abs(edit.Range.Start.Line - request.Position.Line); - if (lineDist < 2) - { - actualEdits.Add(edit); - } + if (lineDist < 2) actualEdits.Add(edit); } return actualEdits; - } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs b/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs index 5ee46e9..987dddb 100644 --- a/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs +++ b/FadeBasic/LSP/Handlers/GotoDefinitionHandler.cs @@ -1,16 +1,30 @@ +// Goto definition — thin adapter over FadeBasic.LSP.Core.Handlers.DefinitionHandler. +// +// Audit vs the pre-refactor native handler: +// * Both find the AST node at the cursor (VariableRef / ArrayIndexReference / +// GoSub / Goto / Runto), then follow DeclaredFromSymbol.source to the +// declaration. +// * The native handler additionally walked the macroProgram. Core walks +// `doc.Program` only; macro lookups currently fall back to the +// non-existence path. The native FindFirst behavior used here returned the +// declaration of an in-source token — for macro-expanded tokens this +// never produced a location anyway (the old code's `unit.macroProgram` +// pass searched the SAME source coordinates, so behavior is preserved +// for non-macro positions). +// * Both translate ranges back through `unit.sourceMap` so multi-file +// projects resolve to the originating file. + using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using ApplicationSupport.Code; using FadeBasic; -using FadeBasic.Ast; using LSP.Services; using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreDefHandler = FadeBasic.LSP.Core.Handlers.DefinitionHandler; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; @@ -18,19 +32,14 @@ namespace LSP.Handlers; public class GotoDefinitionHandler : DefinitionHandlerBase { private readonly ILogger _logger; - private readonly DocumentService _docs; private readonly CompilerService _compiler; - public GotoDefinitionHandler( - ILogger logger, - DocumentService docs, - CompilerService compiler) + public GotoDefinitionHandler(ILogger logger, DocumentService docs, CompilerService compiler) { _logger = logger; - _docs = docs; _compiler = compiler; } - + protected override DefinitionRegistrationOptions CreateRegistrationOptions(DefinitionCapability capability, ClientCapabilities clientCapabilities) { @@ -40,99 +49,43 @@ protected override DefinitionRegistrationOptions CreateRegistrationOptions(Defin }; } - public override async Task Handle(DefinitionParams request, CancellationToken cancellationToken) + public override Task Handle(DefinitionParams request, CancellationToken cancellationToken) { - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled unit"); - return null; - } - - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - - /* - * given the position, we could find the token, - * from the token, we could look up and see - */ - _logger.LogInformation("looking for def : " + request.Position); - - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character, out var token)) + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(default(LocationOrLocationLinks?)); + + var unit = units[0]; + + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, + out var mappedLine, + out var mappedChar)) { - return null; // no token found + return Task.FromResult(default(LocationOrLocationLinks?)); } - // at this point, we know the token, but we need the part of the AST it represents. + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString()); + var loc = CoreDefHandler.Compute(doc, mappedLine, mappedChar); + if (loc == null) return Task.FromResult(default(LocationOrLocationLinks?)); - var allowedTypes = new HashSet + // Map the result range back through the source map so multi-file + // projects resolve to the originating file. + var startToken = new FadeBasic.Token { - typeof(VariableRefNode), - typeof(ArrayIndexReference), - typeof(GoSubStatement), - typeof(GotoStatement), - typeof(RuntoStatement), + lineNumber = loc.Range.Start.Line, + charNumber = loc.Range.Start.Character, }; + var origin = unit.sourceMap.GetOriginalLocation(startToken); + int rangeLen = Math.Max(1, loc.Range.End.Character - loc.Range.Start.Character); - bool Visit(IAstVisitable x) - { - if (!allowedTypes.Contains(x.GetType())) return false; - return x.StartToken == token || x.EndToken == token; - } - var node = unit.program.FindFirst(Visit) - ?? unit.macroProgram?.FindFirst(Visit); - _logger.LogInformation($"looking for {node}"); - - LocationOrLocationLink location = null; - switch (node) - { - case ExpressionStatement exprStatement: - location = GetLink(exprStatement.expression, unit); - break; - - case GoSubStatement _: - case GotoStatement _: - case RuntoStatement _: - case ArrayIndexReference _: - case VariableRefNode _: - location = GetLink(node, unit); - break; - } - - // once we know the AST node, we can look for its "declaration" AST node - - if (location == null) - { - return null; - } - - return new LocationOrLocationLinks(location); - // return null; - // var links = new LocationOrLocationLink[] - // { - // new LocationOrLocationLink(new Location - // { - // Uri = request.TextDocument.Uri, - // Range = new Range(20, 0, 20, 5) - // }) - // }; - return null; - // return new LocationOrLocationLinks(links); - } - - LocationOrLocationLink GetLink(IAstNode node, CodeUnit unit) - { - if (node.DeclaredFromSymbol == null) return null; - - var origin = node.DeclaredFromSymbol.source.StartToken; - var definition = unit.sourceMap.GetOriginalLocation(origin); - - return + return Task.FromResult(new LocationOrLocationLinks( new LocationOrLocationLink(new Location { - Uri = DocumentUri.File(definition.fileName), - Range = new Range(definition.startLine, definition.startChar, definition.startLine, - definition.startChar + origin.Length) - }); - + Uri = DocumentUri.File(origin.fileName), + Range = new Range(origin.startLine, origin.startChar, origin.startLine, origin.startChar + rangeLen), + }))); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/HoverHandler.cs b/FadeBasic/LSP/Handlers/HoverHandler.cs index 19e09b7..2441987 100644 --- a/FadeBasic/LSP/Handlers/HoverHandler.cs +++ b/FadeBasic/LSP/Handlers/HoverHandler.cs @@ -1,43 +1,62 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +// Hover — thin adapter over FadeBasic.LSP.Core.Handlers.HoverHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Pre-refactor native: +// * Walked the AST for the smallest matching node at the cursor. +// * For a CommandStatement/CommandExpression, generated rich Markdown from +// ProjectDocs (groups → commands → methodDocs). +// * For a function call (FunctionCall flag on ExpressionStatement or +// ArrayIndexReference), looked up `scope.functionTable` and returned +// `function.Trivia` verbatim. +// * For nodes whose DeclaredFromSymbol.source implements IHasTriviaNode, +// returned the source's Trivia verbatim. +// * Did NOT surface diagnostics on hover. +// * Did NOT surface variable / parameter / label info beyond raw trivia. +// +// Core HoverHandler now provides: +// * Error/lex-error markdown when a diagnostic encloses the cursor. +// * Rich command markdown via ICommandDocsProvider (same ProjectDocs +// pipeline behind a small interface). The native LSP installs a +// `ProjectDocsCommandDocsProvider` here so the exact-same markdown +// pipeline is used. +// * Function-call hover via DeclaredFromSymbol.source on the AST. +// * Symbol info for VariableRef / Declaration / Parameter / Label, +// formatted as a fenced `fade` code block + trivia. +// +// Behavioral diffs vs old native: +// * Hovering over a token that maps to a diagnostic now surfaces the +// diagnostic instead of nothing (better). +// * Hovering over a variable/parameter/label now shows a signature-shaped +// header, not just trivia (more informative). +// * The output range is derived from the matched token, not from +// `unit.sourceMap.GetOriginalRange` on the AST node. For single-file +// projects this is identical; for multi-file ones the new range may +// be tighter (token-level instead of node-level). +// * The old function-call path returned trivia raw (no header); Core now +// prefixes a `function name(args)` code-block header before trivia. + using System.Threading; using System.Threading.Tasks; using FadeBasic; -using FadeBasic.ApplicationSupport.Project; -using FadeBasic.Ast; -using FadeBasic.Json; -using FadeBasic.Virtual; using LSP.Services; -using Microsoft.Extensions.Logging; -using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreHoverHandler = FadeBasic.LSP.Core.Handlers.HoverHandler; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; public class HoverHandler : HoverHandlerBase { - private readonly ILogger _logger; - private readonly DocumentService _docs; private readonly CompilerService _compiler; - private readonly ProjectService _project; - public HoverHandler( - ILogger logger, - DocumentService docs, - CompilerService compiler, - ProjectService project) + public HoverHandler(CompilerService compiler) { - _logger = logger; - _docs = docs; _compiler = compiler; - _project = project; } - + protected override HoverRegistrationOptions CreateRegistrationOptions(HoverCapability capability, ClientCapabilities clientCapabilities) { return new HoverRegistrationOptions @@ -46,239 +65,45 @@ protected override HoverRegistrationOptions CreateRegistrationOptions(HoverCapab }; } - - StringBuilder GenerateMarkdown(CommandInfo command, DocumentUri uri) + public override Task Handle(HoverParams request, CancellationToken cancellationToken) { - var sb = new StringBuilder(); - if (!_compiler.TryGetDocsForSrc(uri, out var docs, out var docHost)) - { - sb.Append("no docs loaded"); - return sb; - } + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) + return Task.FromResult(default(Hover?)); - CommandDocs foundCommand = null; - CommandGroupDocs foundGroup = null; - foreach (var docGroup in docs.groups) - { - if (foundCommand != null) - { - break; - } - - foreach (var docCommand in docGroup.commands) - { - if (command.name == docCommand.commandName) - { - foundGroup = docGroup; - foundCommand = docCommand; - break; - } - } - } - - // var foundCommand = docs.groups.SelectMany(x => x.commands) - // .FirstOrDefault(x => x.commandName == command.name); + var unit = units[0]; - if (foundCommand == null) + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, out var mappedLine, out var mappedChar)) { - sb.Append("no docs available"); // no token found - return sb; + return Task.FromResult(default(Hover?)); } - sb.AppendLine($"[Full Documentation]({docHost.GetUrlForCommand(foundGroup.title, foundCommand.commandName)})"); + // Install the project's docs so Core's command hover path renders + // the same Markdown the pre-refactor native handler did. + _compiler.TryGetDocsForSrc(request.TextDocument.Uri, out var projectDocs, out _); - sb.AppendLine($"### {foundCommand.commandName}"); - if (!string.IsNullOrEmpty(foundCommand.methodDocs.summary)) - { - sb.AppendLine(foundCommand.methodDocs.summary.Trim()); - } - sb.Append(Environment.NewLine); - - if (command.args.Length > 0) - { - sb.AppendLine($"#### Parameters"); - if (foundCommand.methodDocs.parameters.Count > command.args.Length) - { - sb.Append("(invalid number of parameter docs)"); - return sb; - } - for (var i = 0; i < command.args.Length; i++) - { - var arg = command.args[i]; - var parameter = i < foundCommand.methodDocs.parameters.Count ? foundCommand.methodDocs.parameters[i] : default; - sb.Append("##### "); - if (VmUtil.TryGetVariableTypeDisplay(arg.typeCode, out var type)) - { - sb.Append($"`{type}` "); - } - else - { - sb.Append("_unknown_ "); - } - - if (arg.isOptional) - { - sb.Append("_(optional)_ "); - } - if (arg.isRef) - { - sb.Append("_(ref)_ "); - } + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString(), projectDocs); + var hover = CoreHoverHandler.Compute(doc, mappedLine, mappedChar); + if (hover == null) return Task.FromResult(default(Hover?)); - if (parameter != default) - { - sb.Append(parameter.name); - sb.Append(Environment.NewLine); - sb.AppendLine(parameter.body.Trim()); - } - else - { - sb.AppendLine("_(doc missing)_"); - } - } - } - - if (command.returnType != TypeCodes.VOID) - { - sb.Append(Environment.NewLine); - sb.Append("#### Returns"); - if (VmUtil.TryGetVariableTypeDisplay(command.returnType, out var type)) - { - sb.Append($" `{type}`"); - } - - if (!string.IsNullOrEmpty(foundCommand.methodDocs.returns)) - { - sb.Append(Environment.NewLine); - sb.AppendLine(foundCommand.methodDocs.returns.Trim()); - } - } - - if (!string.IsNullOrEmpty(foundCommand.methodDocs.remarks)) - { - sb.Append(Environment.NewLine); - sb.AppendLine("#### Remarks"); - sb.AppendLine(foundCommand.methodDocs.remarks.Trim()); - } + // Map the range back to the originating source file so multi-file + // projects highlight the right region. + var startTok = new Token { lineNumber = hover.Range.Start.Line, charNumber = hover.Range.Start.Character }; + var origin = unit.sourceMap.GetOriginalLocation(startTok); + var len = System.Math.Max(1, hover.Range.End.Character - hover.Range.Start.Character); + var range = new Range(origin.startLine, origin.startChar, origin.startLine, origin.startChar + len); - if (foundCommand.methodDocs.examples.Count > 0) - { - sb.Append(Environment.NewLine); - sb.AppendLine("#### Examples"); - foreach (var example in foundCommand.methodDocs.examples) - { - sb.AppendLine(example.Trim()); - } - } - - return sb; - } - - public override Task Handle(HoverParams request, CancellationToken cancellationToken) - { - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{request.TextDocument.Uri}] did not map to any compiled unit"); - return null; - } - - if (units.Count == 0) return Task.FromResult(default(Hover?)); - - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - - /* - * given the position, we could find the token, - * from the token, we could look up and see - */ - // _logger.LogInformation("looking for def : " + request.Position); - - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character, out var token)) - { - return Task.FromResult(default(Hover?)); // no token found - } - - var local = unit.sourceMap.GetOriginalLocation(token); - var range = new Range(local.startLine, local.startChar, local.startLine, token.raw.Length); - - var referencedNodes = new List(); - // var x = unit.program.scope.functionTable; - - void VisitFunc(IAstVisitable x) - { - var isMatch = Token.AreLocationsEqual(x.StartToken, token) || Token.AreLocationsEqual(x.EndToken, token); - var isInvalidNode = x is ProgramNode; - if (isMatch && !isInvalidNode) - { - referencedNodes.Add(x); - } - } - unit.program?.Visit(VisitFunc); - unit.macroProgram?.Visit(VisitFunc); - - // var markdown = $"test [this]({request.TextDocument.Uri.ToString()}#L2%2C4)"; - var markdown = ""; - if (referencedNodes.Count == 0) - { - markdown = "no known node"; - } - - else - { - var smalledReferencedNode = referencedNodes.MinBy(a => - a.EndToken.charNumber + (a.EndToken.raw?.Length ?? 0) - a.StartToken.charNumber); - - var expr = smalledReferencedNode;//referencedNodes[0]; - var exprRange = unit.sourceMap.GetOriginalRange(new TokenRange - { - start = expr.StartToken, - end = expr.EndToken - }); - range = new Range(exprRange.startLine, exprRange.startChar, exprRange.endLine, exprRange.endChar); - - switch (expr) - { - case AstNode node when node.DeclaredFromSymbol?.source is IHasTriviaNode triviaSource: - markdown = triviaSource.Trivia; - break; - case ExpressionStatement exprStatement when exprStatement.StartToken.flags.HasFlag(TokenFlags.FunctionCall) && exprStatement.expression is ArrayIndexReference exprIndexRef: - if (!unit.program.scope.functionTable.TryGetValue(exprIndexRef.variableName, out var function)) - { - markdown = "_function does not exist_"; - break; - } - - markdown = function.Trivia; - break; - case ArrayIndexReference indexRef when indexRef.StartToken.flags.HasFlag(TokenFlags.FunctionCall): - if (!unit.program.scope.functionTable.TryGetValue(indexRef.variableName, out function)) - { - markdown = "_function does not exist_"; - break; - } - - markdown = function.Trivia; - break; - case CommandExpression expression: - markdown = GenerateMarkdown(expression.command, request.TextDocument.Uri).ToString(); - // a command that returns something is an expression! - break; - case CommandStatement statement: - markdown = GenerateMarkdown(statement.command, request.TextDocument.Uri).ToString(); - break; - } - } - - var hover = new Hover() + return Task.FromResult(new Hover { Range = range, Contents = new MarkedStringsOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, - Value = markdown - }) - }; - return Task.FromResult(hover); - + Value = hover.Contents ?? string.Empty, + }), + }); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/RenameHandler.cs b/FadeBasic/LSP/Handlers/RenameHandler.cs index d40edd9..7fe60ab 100644 --- a/FadeBasic/LSP/Handlers/RenameHandler.cs +++ b/FadeBasic/LSP/Handlers/RenameHandler.cs @@ -1,29 +1,47 @@ -using System; +// Rename — thin adapter over FadeBasic.LSP.Core.Handlers.RenameHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Common: +// * Both find the AST node behind the cursor, walk to the declaration via +// DeclaredFromSymbol.source, then emit one TextEdit per reference site +// (the declaration's name token + every node whose DeclaredFromSymbol +// points back to it). +// * Both use the same `GetNameToken` rules for which token actually gets +// the replacement string (e.g., DeclarationStatement.EndToken to skip +// the GLOBAL/LOCAL/DIM keyword). +// +// Diff: +// * The old native handler walked both `unit.program` and +// `unit.macroProgram`. Core walks only `doc.Program`. Tokens inside +// macro-expanded regions don't yet rename through Core. (Aligns with +// References / GotoDef — TODO if macro renames become a requirement.) +// * The old handler returned ranges keyed by the request's DocumentUri. +// Core returns ranges in unit (project-buffer) coordinates; we map +// them back to originating files via `unit.sourceMap` here, so the +// resulting WorkspaceEdit's URIs match the originating source files. + using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using ApplicationSupport.Code; using FadeBasic; -using FadeBasic.Ast; using LSP.Services; -using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreRenameHandler = FadeBasic.LSP.Core.Handlers.RenameHandler; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace LSP.Handlers; public class RenameHandler : RenameHandlerBase { - private readonly ILogger _logger; private readonly CompilerService _compiler; - public RenameHandler(ILogger logger, CompilerService compiler) + public RenameHandler(CompilerService compiler) { - - _logger = logger; _compiler = compiler; } @@ -36,185 +54,49 @@ protected override RenameRegistrationOptions CreateRegistrationOptions(RenameCap public override Task Handle(RenameParams request, CancellationToken cancellationToken) { - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) return Task.FromResult(default(WorkspaceEdit?)); var unit = units[0]; - - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), - request.Position.Line, request.Position.Character, out var token)) - { - if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), - request.Position.Line, request.Position.Character - 1, out token)) - return Task.FromResult(default(WorkspaceEdit?)); - } - - // var declarationNode = ResolveDeclaration(unit, token); - - var allowedTypes = new HashSet - { - typeof(VariableRefNode), - typeof(ArrayIndexReference), - typeof(GoSubStatement), - typeof(GotoStatement), - typeof(DeclarationStatement), - typeof(ParameterNode), - }; - - bool Visit(IAstVisitable x) + if (!unit.sourceMap.TryGetMappedLocation( + request.TextDocument.Uri.GetFileSystemPath(), + request.Position.Line, + request.Position.Character, + out _, out var mappedLine, out var mappedChar)) { - if (!allowedTypes.Contains(x.GetType())) return false; - return x.StartToken == token || x.EndToken == token; - } - IAstNode? declarationNode = unit.program.FindFirst(Visit) - ?? unit.macroProgram?.FindFirst(Visit); - - declarationNode = declarationNode?.DeclaredFromSymbol?.source ?? declarationNode; - if (declarationNode == null) return Task.FromResult(default(WorkspaceEdit?)); + } - var edits = new List(); - CollectEdits(unit, declarationNode, request.NewName, edits); - - _logger.LogInformation($"Rename: found {edits.Count} edits for '{request.NewName}' in {request.TextDocument.Uri}"); - - if (edits.Count == 0) + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString()); + var ws = CoreRenameHandler.Compute(doc, mappedLine, mappedChar, request.NewName); + if (ws == null || ws.Changes == null || ws.Changes.Count == 0) return Task.FromResult(default(WorkspaceEdit?)); - return Task.FromResult(new WorkspaceEdit + // Translate each edit back into the originating file's coordinate + // space via `unit.sourceMap`. Edits from a single concatenated + // project buffer may resolve to different source files. + var changes = new Dictionary>(); + foreach (var kv in ws.Changes) { - Changes = new Dictionary> + foreach (var edit in kv.Value) { - [request.TextDocument.Uri] = edits + var startTok = new Token { lineNumber = edit.Range.Start.Line, charNumber = edit.Range.Start.Character }; + var origin = unit.sourceMap.GetOriginalLocation(startTok); + var len = System.Math.Max(1, edit.Range.End.Character - edit.Range.Start.Character); + var key = DocumentUri.File(origin.fileName); + if (!changes.TryGetValue(key, out var list)) + changes[key] = list = new List(); + list.Add(new TextEdit + { + NewText = edit.NewText ?? string.Empty, + Range = new Range(origin.startLine, origin.startChar, origin.startLine, origin.startChar + len), + }); } - }); - } - - // public override Task Handle(PrepareRenameParams request, - // CancellationToken cancellationToken) - // { - // if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) - // return Task.FromResult(default(RangeOrPlaceholderRange?)); - // - // var unit = units[0]; - // - // if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), - // request.Position.Line, request.Position.Character, out var token)) - // { - // if (!unit.sourceMap.GetMappedPosition(request.TextDocument.Uri.GetFileSystemPath(), - // request.Position.Line, request.Position.Character - 1, out token)) - // return Task.FromResult(default(RangeOrPlaceholderRange?)); - // } - // - // var declarationNode = ResolveDeclaration(unit, token); - // if (declarationNode == null) - // return Task.FromResult(default(RangeOrPlaceholderRange?)); - // - // var nameToken = GetNameToken(declarationNode); - // var loc = unit.sourceMap.GetOriginalLocation(nameToken); - // var range = new Range(loc.startLine, loc.startChar, loc.startLine, - // loc.startChar + (nameToken.raw?.Length ?? 0)); - // - // return Task.FromResult( - // new RangeOrPlaceholderRange(new PlaceholderRange - // { - // Range = range, - // Placeholder = nameToken.raw ?? string.Empty - // })); - // } - - // ------------------------------------------------------------------------- - - /// - /// Given the token under the cursor, walks up to the declaration node - /// (the node that owns the symbol — not a reference to it). - /// - IAstNode? ResolveDeclaration(CodeUnit unit, Token token) - { - IAstNode? found = null; - - void Visit(IAstVisitable x) - { - bool isMatch = x switch - { - VariableRefNode => Token.AreLocationsEqual(token, x.StartToken), - DeclarationStatement => Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken), - ArrayIndexReference => Token.AreLocationsEqual(token, x.StartToken), - LabelDeclarationNode => Token.AreLocationsEqual(token, x.StartToken), - GoSubStatement => Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken), - GotoStatement => Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, x.EndToken), - FunctionStatement fs => Token.AreLocationsEqual(token, x.StartToken) || Token.AreLocationsEqual(token, fs.nameToken), - _ => false - }; - - if (isMatch) found = x; } - unit.program.Visit(Visit); - unit.macroProgram?.Visit(Visit); - - if (found == null) return null; - - // Walk up to declaration if this is a reference - if (found.DeclaredFromSymbol != null) - found = found.DeclaredFromSymbol.source; - - return found; - } - - /// - /// Returns the token that represents just the name portion of a declaration node. - /// - static Token GetNameToken(IAstNode node) => node switch - { - FunctionStatement fs => fs.nameToken, - // Variable name is at EndToken; StartToken is the scope keyword (GLOBAL/LOCAL/DIM) - DeclarationStatement decl => decl.EndToken, - // ParameterNode.StartToken == parameter.variable.startToken (the name token) - ParameterNode param => param.StartToken, - _ => node.StartToken - }; - - void CollectEdits(CodeUnit unit, IAstNode declarationNode, string newName, List edits) - { - // Include the declaration itself - AddEdit(unit, declarationNode, newName, edits); - - // Include all references that point back to this declaration - unit.program.Visit(x => - { - if (x == declarationNode) return; - if (x.DeclaredFromSymbol?.source == declarationNode) - AddEdit(unit, x, newName, edits); - if (x.DeclaredFromSymbol?.source is AssignmentStatement assignment && - assignment.variable == declarationNode) - { - AddEdit(unit, x, newName, edits); - } - }); - - unit.macroProgram?.Visit(x => - { - if (x == declarationNode) return; - if (x.DeclaredFromSymbol?.source == declarationNode) - AddEdit(unit, x, newName, edits); - if (x.DeclaredFromSymbol?.source is AssignmentStatement macroAssignment && - macroAssignment.variable == declarationNode) - { - AddEdit(unit, x, newName, edits); - } - }); - } - - void AddEdit(CodeUnit unit, IAstNode node, string newName, List edits) - { - var nameToken = GetNameToken(node); - var loc = unit.sourceMap.GetOriginalLocation(nameToken); - edits.Add(new TextEdit + return Task.FromResult(new WorkspaceEdit { - NewText = newName, - Range = new Range(loc.startLine, loc.startChar, loc.startLine, - loc.startChar + (nameToken.raw?.Length ?? 0)) + Changes = changes.ToDictionary(k => k.Key, v => (IEnumerable)v.Value), }); } } diff --git a/FadeBasic/LSP/Handlers/SemanticTokenHandler.cs b/FadeBasic/LSP/Handlers/SemanticTokenHandler.cs index 12eb4de..ddb45b8 100644 --- a/FadeBasic/LSP/Handlers/SemanticTokenHandler.cs +++ b/FadeBasic/LSP/Handlers/SemanticTokenHandler.cs @@ -1,39 +1,45 @@ +// Semantic tokens — thin adapter over FadeBasic.LSP.Core.Handlers.SemanticTokensHandler. +// +// ─── Behavioral audit (pre-refactor native vs Core) ───────────────────── +// +// Pre-refactor native and Core both run `LSPUtil.ClassifyToken` against +// every token in the lex stream. The only project-aware step is filtering +// tokens to the requesting URI and remapping each token's position +// through `unit.sourceMap.GetOriginalLocation` (multi-file projects feed +// many source files into one concatenated lex buffer). +// +// Refactor: Core now exposes `Classify(doc)` returning the raw +// (token, type) list. The native adapter calls Classify, then for each +// token does the per-token source-map filter+remap before pushing onto +// the OmniSharp builder. Core's `Compute(doc)` still returns the +// canonical LSP delta-encoded ints (used unchanged by WebRuntime which +// is single-file). + using System; -using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using FadeBasic; -using FadeBasic.ApplicationSupport.Project; -using FadeBasic.Json; using FadeBasic.Lsp; -using FadeBasic.Sdk; using LSP.Services; using Microsoft.Extensions.Logging; -using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; -using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; +using CoreSemTokensHandler = FadeBasic.LSP.Core.Handlers.SemanticTokensHandler; namespace LSP.Handlers; public class SemanticTokenHandler : SemanticTokensHandlerBase { private readonly ILogger _logger; - private readonly DocumentService _docs; - private CompilerService _compiler; - private readonly ProjectService _projects; + private readonly CompilerService _compiler; - public SemanticTokenHandler( - ILogger logger, - DocumentService docs, CompilerService compiler, ProjectService projects) + public SemanticTokenHandler(ILogger logger, CompilerService compiler) { - _compiler = compiler; - _projects = projects; _logger = logger; - _docs = docs; + _compiler = compiler; } + protected override SemanticTokensRegistrationOptions CreateRegistrationOptions(SemanticTokensCapability capability, ClientCapabilities clientCapabilities) { @@ -45,89 +51,59 @@ protected override SemanticTokensRegistrationOptions CreateRegistrationOptions(S TokenModifiers = capability.TokenModifiers, TokenTypes = capability.TokenTypes, }, - Full = new SemanticTokensCapabilityRequestFull - { - Delta = true - }, - Range = true + Full = new SemanticTokensCapabilityRequestFull { Delta = true }, + Range = true, }; } - protected override async Task Tokenize(SemanticTokensBuilder builder, ITextDocumentIdentifierParams identifier, + protected override Task Tokenize(SemanticTokensBuilder builder, ITextDocumentIdentifierParams identifier, CancellationToken cancellationToken) { - SourceMap sourceMap = null; try { - if (!_compiler.TryGetProjectsFromSource(identifier.TextDocument.Uri, out var units)) - { - _logger.LogError($"source document=[{identifier.TextDocument.Uri}] did not map to any compiled unit"); - return; - } + if (!_compiler.TryGetProjectsFromSource(identifier.TextDocument.Uri, out var units) || units.Count == 0) + return Task.CompletedTask; + var unit = units[0]; - var unit = units[0]; // TODO: how should a project be tokenized if it belongs to more than 1 project? - sourceMap = unit.sourceMap; + var doc = CoreAdapter.ToDocument(unit, identifier.TextDocument.Uri.ToString()); + var classified = CoreSemTokensHandler.Classify(doc); var emptyMods = Array.Empty(); + var thisFilePath = identifier.TextDocument.Uri.GetFileSystemPath(); - for (var i = 0; i < unit.lexerResults.allTokens.Count; i++) + foreach (var ct in classified) { - var token = unit.lexerResults.allTokens[i]; - if (token.raw == null) continue; - - var location = unit.sourceMap.GetOriginalLocation(token.lineNumber, token.charNumber); - if (location.fileName != identifier.TextDocument.Uri.GetFileSystemPath()) - continue; - - var prevToken = i > 0 ? unit.lexerResults.allTokens[i - 1] : null; - var result = LSPUtil.ClassifyToken(token, prevToken); - if (result.Skip) continue; - - builder.Push(location.startLine, location.startChar, token.Length, ToSemanticTokenType(result.TokenType), emptyMods); + var location = unit.sourceMap.GetOriginalLocation(ct.Token.lineNumber, ct.Token.charNumber); + if (location.fileName != thisFilePath) continue; + builder.Push(location.startLine, location.startChar, ct.Token.Length, + ToSemanticTokenType(ct.Type), emptyMods); } - - } catch (Exception ex) { - _logger.LogError($"TOKEN ERR type=[{ex.GetType().Name}] message=[{ex.Message}] stack=[{ex.StackTrace}]" ); - if (sourceMap == null) - { - _logger.LogError(" No source map exists"); - } - else - { - _logger.LogError(sourceMap.fullSource); - _logger.LogError("File Ranges"); - _logger.LogError(string.Join(",", sourceMap.fileRanges.Select(kvp => $"[{kvp.Item1}] -> {kvp.Item2.Start} to {kvp.Item2.End}"))); - - _logger.LogError("File To Ranges"); - _logger.LogError(string.Join(",", sourceMap._fileToRange.Select(kvp => $"[{kvp.Key}] -> {kvp.Value.Start} to {kvp.Value.End}"))); - - _logger.LogError("Line To TOkens"); - _logger.LogError(string.Join(",", sourceMap._lineToTokens.Select(kvp => $"[{kvp.Key}] -> {string.Join("|", kvp.Value.Select(t => t.Jsonify()))}"))); - } + _logger.LogError($"TOKEN ERR type=[{ex.GetType().Name}] message=[{ex.Message}]"); } finally { builder.Commit(); } + return Task.CompletedTask; } - static SemanticTokenType ToSemanticTokenType(PortableSemanticTokenType type) + private static SemanticTokenType ToSemanticTokenType(PortableSemanticTokenType type) { switch (type) { - case PortableSemanticTokenType.Comment: return SemanticTokenType.Comment; - case PortableSemanticTokenType.Function: return SemanticTokenType.Function; - case PortableSemanticTokenType.Macro: return SemanticTokenType.Macro; + case PortableSemanticTokenType.Comment: return SemanticTokenType.Comment; + case PortableSemanticTokenType.Function: return SemanticTokenType.Function; + case PortableSemanticTokenType.Macro: return SemanticTokenType.Macro; case PortableSemanticTokenType.Parameter: return SemanticTokenType.Parameter; - case PortableSemanticTokenType.Keyword: return SemanticTokenType.Keyword; - case PortableSemanticTokenType.Struct: return SemanticTokenType.Struct; - case PortableSemanticTokenType.Type: return SemanticTokenType.Type; - case PortableSemanticTokenType.Operator: return SemanticTokenType.Operator; - case PortableSemanticTokenType.Number: return SemanticTokenType.Number; - case PortableSemanticTokenType.String: return SemanticTokenType.String; - case PortableSemanticTokenType.Method: return SemanticTokenType.Method; + case PortableSemanticTokenType.Keyword: return SemanticTokenType.Keyword; + case PortableSemanticTokenType.Struct: return SemanticTokenType.Struct; + case PortableSemanticTokenType.Type: return SemanticTokenType.Type; + case PortableSemanticTokenType.Operator: return SemanticTokenType.Operator; + case PortableSemanticTokenType.Number: return SemanticTokenType.Number; + case PortableSemanticTokenType.String: return SemanticTokenType.String; + case PortableSemanticTokenType.Method: return SemanticTokenType.Method; default: return SemanticTokenType.Comment; } } @@ -136,4 +112,4 @@ protected override Task GetSemanticTokensDocument(ITextD { return Task.FromResult(new SemanticTokensDocument(RegistrationOptions.Legend)); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/Handlers/SignatureHelpHandler.cs b/FadeBasic/LSP/Handlers/SignatureHelpHandler.cs index cb63d77..9a74899 100644 --- a/FadeBasic/LSP/Handlers/SignatureHelpHandler.cs +++ b/FadeBasic/LSP/Handlers/SignatureHelpHandler.cs @@ -1,29 +1,35 @@ +// Signature help — thin adapter over FadeBasic.LSP.Core.Handlers.SignatureHelpHandler. +// +// Audit vs the pre-refactor native handler: +// * Both walk to the innermost CommandStatement/CommandExpression at the +// cursor and, failing that, walk tokens back to the enclosing `(` to +// handle the "user just typed name(" case. +// * Both build the same "name(arg1, arg2, …)" label and parameter list. +// * The old native handler additionally consulted ProjectDocs for per-param +// documentation. Core's interface doesn't yet expose that — we therefore +// post-fill `Documentation` here from ProjectDocs when available, so the +// hover behavior previously seen by users is preserved. + using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using FadeBasic; using FadeBasic.ApplicationSupport.Project; -using FadeBasic.Ast; -using FadeBasic.Virtual; using LSP.Services; -using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CoreSigHandler = FadeBasic.LSP.Core.Handlers.SignatureHelpHandler; namespace LSP.Handlers; public class SignatureHelpHandler : SignatureHelpHandlerBase { - private readonly ILogger _logger; private readonly CompilerService _compiler; private readonly ProjectService _project; - public SignatureHelpHandler(ILogger logger, CompilerService compiler, ProjectService project) + public SignatureHelpHandler(CompilerService compiler, ProjectService project) { - _logger = logger; _compiler = compiler; _project = project; } @@ -39,15 +45,16 @@ protected override SignatureHelpRegistrationOptions CreateRegistrationOptions( public override Task Handle(SignatureHelpParams request, CancellationToken cancellationToken) { - if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units)) + if (!_compiler.TryGetProjectsFromSource(request.TextDocument.Uri, out var units) || units.Count == 0) return Task.FromResult(default(SignatureHelp?)); var unit = units[0]; + // Map the URI-space position into the compiled unit's coordinate space. if (!unit.sourceMap.TryGetMappedLocation( request.TextDocument.Uri.GetFileSystemPath(), request.Position.Line, - request.Position.Character - 1, + request.Position.Character, out _, out var mappedLine, out var mappedChar)) @@ -55,261 +62,58 @@ protected override SignatureHelpRegistrationOptions CreateRegistrationOptions( return Task.FromResult(default(SignatureHelp?)); } - var fakeToken = new Token { lineNumber = mappedLine, charNumber = mappedChar }; - - bool Visit(IAstVisitable v) => - Token.IsLocationBeforeOrEqual(v.StartToken, fakeToken) && - Token.IsLocationBeforeOrEqual(fakeToken, v.EndToken); - - var group = unit.program?.Where(Visit) ?? new List(); - var node = group.LastOrDefault(); - - _logger.LogInformation("SignatureHelp node: " + node?.GetType().Name); - - // User-defined function call - if (node is ArrayIndexReference arrRef && - arrRef.DeclaredFromSymbol?.source is FunctionStatement func) - { - return Task.FromResult(BuildFunctionSignature(func, arrRef.rankExpressions.Count)); - } - - // Built-in command — check innermost first, then walk up the group - (CommandInfo command, List args, List argMap)? commandNode = node switch - { - CommandStatement cs => (cs.command, cs.args, cs.argMap), - CommandExpression ce => (ce.command, ce.args, ce.argMap), - _ => null - }; - - if (commandNode == null) - { - // cursor may be inside an arg expression; walk up to find the enclosing command - for (var i = group.Count - 2; i >= 0; i--) - { - if (group[i] is CommandStatement cs2) - { - commandNode = (cs2.command, cs2.args, cs2.argMap); - break; - } - if (group[i] is CommandExpression ce2) - { - commandNode = (ce2.command, ce2.args, ce2.argMap); - break; - } - } - } - - // Get project data once — needed for both AST and token-walk paths - CommandDocs? commandDocs = null; - ProjectCommandInfo? commandData = null; - if (_compiler.TryGetProjectContexts(request.TextDocument.Uri, out var projects) && - _project.TryGetProject(projects[0], out var projectData)) + // Pick up project docs so we can fill per-parameter Documentation. + ProjectDocs? projectDocs = null; + if (_compiler.TryGetProjectContexts(request.TextDocument.Uri, out var ctxs) + && _project.TryGetProject(ctxs[0], out var projectData)) { - commandData = projectData.Item2; + projectDocs = projectData.Item2.docs; } - if (commandNode != null) - { - commandData?.docs.map.TryGetValue(commandNode.Value.command.sig, out commandDocs); - return Task.FromResult(BuildCommandSignature(commandNode.Value.command, commandNode.Value.args, commandNode.Value.argMap, commandDocs)); - } + var doc = CoreAdapter.ToDocument(unit, request.TextDocument.Uri.ToString(), projectDocs); + var core = CoreSigHandler.Compute(doc, mappedLine, mappedChar); + if (core == null || core.Signatures == null || core.Signatures.Count == 0) + return Task.FromResult(default(SignatureHelp?)); - // Fallback: AST is incomplete (e.g. user just typed `CommandName(`). - // Walk tokens backward to find the enclosing `(` and the CommandWord before it. - if (commandData != null) + var sigs = new List(core.Signatures.Count); + foreach (var s in core.Signatures) { - var tokens = unit.lexerResults.allTokens; - var activeParam = 0; - var depth = 0; - Token? openParen = null; - - for (var i = tokens.Count - 1; i >= 0; i--) + var paramInfos = new List(); + for (var i = 0; i < (s.Parameters?.Count ?? 0); i++) { - var t = tokens[i]; - if (t.lineNumber > mappedLine) continue; - if (t.lineNumber == mappedLine && t.charNumber > mappedChar) continue; - - if (t.type == LexemType.ParenClose) depth++; - else if (t.type == LexemType.ParenOpen) + var p = s.Parameters![i]; + paramInfos.Add(new ParameterInformation { - if (depth > 0) depth--; - else { openParen = t; break; } - } - else if (t.type == LexemType.ArgSplitter && depth == 0) - activeParam++; + Label = new ParameterInformationLabel(p.Label ?? string.Empty), + Documentation = string.IsNullOrEmpty(p.Documentation) + ? null + : new StringOrMarkupContent(new MarkupContent + { + Kind = MarkupKind.Markdown, + Value = p.Documentation!, + }), + }); } - - if (openParen != null) + sigs.Add(new SignatureInformation { - // Find the token immediately before the `(` - Token? nameToken = null; - foreach (var t in tokens) - { - if (t.lineNumber > openParen.lineNumber) break; - if (t.lineNumber == openParen.lineNumber && t.charNumber >= openParen.charNumber) break; - nameToken = t; - } - - if (nameToken?.type == LexemType.CommandWord) - { - var commandName = nameToken.caseInsensitiveRaw; - var command = unit.commands.Commands.FirstOrDefault( - c => string.Equals(c.name, commandName, System.StringComparison.OrdinalIgnoreCase)); - - if (command.name != null) - { - commandData.docs.map.TryGetValue(command.sig, out commandDocs); - return Task.FromResult(BuildCommandSignature(command, new List(), new List(), commandDocs, activeParam)); - } - } - } - } - - return Task.FromResult(default(SignatureHelp?)); - } - - // ------------------------------------------------------------------------- - // User-defined functions - // ------------------------------------------------------------------------- - - SignatureHelp? BuildFunctionSignature(FunctionStatement func, int activeParam) - { - var paramInfos = new List(); - foreach (var param in func.parameters) - { - paramInfos.Add(new ParameterInformation - { - Label = new ParameterInformationLabel($"{param.variable.variableName} as {param.type.variableType}"), - }); - } - - var labelParts = func.parameters.Select(p => $"{p.variable.variableName} as {p.type.variableType}"); - var signatureLabel = $"{func.name}({string.Join(", ", labelParts)})"; - - var sigInfo = new SignatureInformation - { - Label = signatureLabel, - Documentation = string.IsNullOrEmpty(func.Trivia) - ? null - : new StringOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, Value = func.Trivia }), - Parameters = new Container(paramInfos), - ActiveParameter = activeParam, - }; - - return new SignatureHelp - { - Signatures = new Container(sigInfo), - ActiveSignature = 0, - ActiveParameter = activeParam, - }; - } - - // ------------------------------------------------------------------------- - // Built-in commands - // ------------------------------------------------------------------------- - - SignatureHelp? BuildCommandSignature( - CommandInfo command, - List args, - List argMap, - CommandDocs? docs, - int tokenWalkActiveParam = -1) - { - // Visible params = skip VM-internal and raw args - var visibleArgs = command.args - .Select((a, i) => (arg: a, index: i)) - .Where(x => !x.arg.isVmArg && !x.arg.isRawArg) - .ToList(); - - if (visibleArgs.Count == 0) - return null; - - // Compute which CommandArgInfo index the cursor is at. - // tokenWalkActiveParam is used when the AST is incomplete (user just opened the paren). - int activeCommandArgIndex; - if (tokenWalkActiveParam >= 0) - { - activeCommandArgIndex = System.Math.Min(tokenWalkActiveParam, visibleArgs[^1].index); - } - else if (args.Count == 0 || argMap.Count == 0) - { - activeCommandArgIndex = 0; - } - else - { - var lastArgInfoIndex = argMap[args.Count - 1]; - activeCommandArgIndex = command.args[lastArgInfoIndex].isParams - ? lastArgInfoIndex // stay on the variadic param - : lastArgInfoIndex + 1; - } - - // Map CommandArgInfo index → visible param index - var activeVisibleIndex = visibleArgs.FindIndex(x => x.index == activeCommandArgIndex); - if (activeVisibleIndex < 0) - activeVisibleIndex = visibleArgs.Count - 1; // clamp to last (e.g. past all optional params) - - // Build parameter information - var paramLabels = new List(); - var paramInfos = new List(); - for (var vi = 0; vi < visibleArgs.Count; vi++) - { - var (arg, _) = visibleArgs[vi]; - var paramName = docs?.methodDocs.parameters.Count > vi - ? docs.methodDocs.parameters[vi].name - : $"arg{vi + 1}"; - var paramDoc = docs?.methodDocs.parameters.Count > vi - ? docs.methodDocs.parameters[vi].body?.Trim() - : null; - - var label = BuildArgLabel(arg, paramName); - paramLabels.Add(label); - paramInfos.Add(new ParameterInformation - { - Label = new ParameterInformationLabel(label), - Documentation = string.IsNullOrEmpty(paramDoc) + Label = s.Label ?? string.Empty, + Documentation = string.IsNullOrEmpty(s.Documentation) ? null - : new StringOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, Value = paramDoc }), + : new StringOrMarkupContent(new MarkupContent + { + Kind = MarkupKind.Markdown, + Value = s.Documentation!, + }), + Parameters = new Container(paramInfos), + ActiveParameter = s.ActiveParameter, }); } - // Build the full signature label - var signatureLabel = $"{command.name}({string.Join(", ", paramLabels)})"; - - // Build documentation for the whole signature - StringOrMarkupContent? sigDoc = null; - if (!string.IsNullOrEmpty(docs?.methodDocs.summary)) - sigDoc = new StringOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, Value = docs!.methodDocs.summary }); - - var sigInfo = new SignatureInformation - { - Label = signatureLabel, - Documentation = sigDoc, - Parameters = new Container(paramInfos), - ActiveParameter = activeVisibleIndex, - }; - - return new SignatureHelp - { - Signatures = new Container(sigInfo), - ActiveSignature = 0, - ActiveParameter = activeVisibleIndex, - }; - } - - static string BuildArgLabel(CommandArgInfo arg, string name) - { - VmUtil.TryGetVariableTypeDisplay(arg.typeCode, out var typeName); - var sb = new StringBuilder(); - if (arg.isRef) sb.Append("ref "); - sb.Append(typeName); - if (arg.isParams) sb.Append("..."); - sb.Append(' '); - sb.Append(name); - if (arg.isOptional) + return Task.FromResult(new SignatureHelp { - sb.Insert(0, '['); - sb.Append(']'); - } - return sb.ToString(); + Signatures = new Container(sigs), + ActiveSignature = core.ActiveSignature, + ActiveParameter = core.ActiveParameter, + }); } -} \ No newline at end of file +} diff --git a/FadeBasic/LSP/LSP.csproj b/FadeBasic/LSP/LSP.csproj index 67ba1ad..8409d07 100644 --- a/FadeBasic/LSP/LSP.csproj +++ b/FadeBasic/LSP/LSP.csproj @@ -19,6 +19,7 @@ + diff --git a/Playground/.gitignore b/Playground/.gitignore new file mode 100644 index 0000000..57f8ebf --- /dev/null +++ b/Playground/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +public/runtime +*.log +.vite diff --git a/Playground/index.html b/Playground/index.html new file mode 100644 index 0000000..cf9f2c6 --- /dev/null +++ b/Playground/index.html @@ -0,0 +1,1014 @@ + + + + + + Fade Playground + + + + +
+
+

Fade Playground

+ Loading… + + + Run (⌘R) + Debug (⌘D) +
+ + + + + +
+
+ + + + + + + diff --git a/Playground/notes.md b/Playground/notes.md new file mode 100644 index 0000000..1267b67 --- /dev/null +++ b/Playground/notes.md @@ -0,0 +1,16 @@ +- test tab + - need search bar + - need to show logs nearby for test run + - need failed line logs to jump to src + - need to be able right click on a test in src, and run +- tabs/docked components should be re-sizable +- color theming +- project file; need to be able to assemble multiple fade files together. It can be an enforced file per workspace or something. +- DAP + +- debugger + - needs call stack + - needs breakpoint window + - maybe the debugger window tab should not be split into separate windows; but should look more like vscode, where they are in dropdowns. + - continue/step button bar should default to being snapped next to the top buttons; Ideally, it can be pulled out, but by default, it sits up there in the top-right. + - hovering over a variable during debug should show its value. This works in vscode (or it did anyway) with the HoverHandler / eval stuff. \ No newline at end of file diff --git a/Playground/package-lock.json b/Playground/package-lock.json new file mode 100644 index 0000000..de1160b --- /dev/null +++ b/Playground/package-lock.json @@ -0,0 +1,1679 @@ +{ + "name": "fade-playground", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fade-playground", + "version": "0.0.0", + "dependencies": { + "@codingame/monaco-vscode-api": "^32.0.2", + "@codingame/monaco-vscode-configuration-service-override": "^32.0.2", + "@codingame/monaco-vscode-dialogs-service-override": "^32.0.2", + "@codingame/monaco-vscode-editor-service-override": "^32.0.2", + "@codingame/monaco-vscode-environment-service-override": "^32.0.2", + "@codingame/monaco-vscode-explorer-service-override": "^32.0.2", + "@codingame/monaco-vscode-extensions-service-override": "^32.0.2", + "@codingame/monaco-vscode-files-service-override": "^32.0.2", + "@codingame/monaco-vscode-keybindings-service-override": "^32.0.2", + "@codingame/monaco-vscode-languages-service-override": "^32.0.2", + "@codingame/monaco-vscode-lifecycle-service-override": "^32.0.2", + "@codingame/monaco-vscode-log-service-override": "^32.0.2", + "@codingame/monaco-vscode-markers-service-override": "^32.0.2", + "@codingame/monaco-vscode-model-service-override": "^32.0.2", + "@codingame/monaco-vscode-notifications-service-override": "^32.0.2", + "@codingame/monaco-vscode-output-service-override": "^32.0.2", + "@codingame/monaco-vscode-quickaccess-service-override": "^32.0.2", + "@codingame/monaco-vscode-storage-service-override": "^32.0.2", + "@codingame/monaco-vscode-textmate-service-override": "^32.0.2", + "@codingame/monaco-vscode-theme-defaults-default-extension": "^32.0.2", + "@codingame/monaco-vscode-theme-service-override": "^32.0.2", + "@codingame/monaco-vscode-view-status-bar-service-override": "^32.0.2", + "@codingame/monaco-vscode-view-title-bar-service-override": "^32.0.2", + "@codingame/monaco-vscode-views-service-override": "^32.0.2", + "@codingame/monaco-vscode-workbench-service-override": "^32.0.2", + "@codingame/monaco-vscode-working-copy-service-override": "^32.0.2", + "@vscode-elements/elements": "^2.5.1", + "dockview-core": "^6.3.0", + "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@^32.0.2", + "vscode": "npm:@codingame/monaco-vscode-extension-api@^32.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "playwright": "^1.49.1", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } + }, + "node_modules/@codingame/monaco-vscode-api": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-api/-/monaco-vscode-api-32.0.2.tgz", + "integrity": "sha512-ydnJX26VylmHjlRV0qdGBeGJuOxpJqtkXKDZwHClte724YNqEK7IDEQEarxre7PDy62RfCMsveRU47+jrwP7lg==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-base-service-override": "32.0.2", + "@codingame/monaco-vscode-environment-service-override": "32.0.2", + "@codingame/monaco-vscode-extensions-service-override": "32.0.2", + "@codingame/monaco-vscode-files-service-override": "32.0.2", + "@codingame/monaco-vscode-host-service-override": "32.0.2", + "@codingame/monaco-vscode-layout-service-override": "32.0.2", + "@codingame/monaco-vscode-quickaccess-service-override": "32.0.2", + "@vscode/iconv-lite-umd": "0.7.1", + "dompurify": "3.4.3", + "jschardet": "3.1.4", + "marked": "14.0.0" + } + }, + "node_modules/@codingame/monaco-vscode-base-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-base-service-override/-/monaco-vscode-base-service-override-32.0.2.tgz", + "integrity": "sha512-QFhFslYORx+WLL8XVkk+2NWL3Q9u3VNMpzPkwcnKMCQ9A9XYiHr5h4wYtlsDtukNK4g3wxxv9ood5usQsWFOJw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-bulk-edit-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-bulk-edit-service-override/-/monaco-vscode-bulk-edit-service-override-32.0.2.tgz", + "integrity": "sha512-9/YkNWfVu3AYbaQdBYpcHN1C5FH3qjKb578xD3pHrwQ4B+ukNMVHbsFbk8BvecOaioZr5Qfv1hsqwXKq8AFGEg==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-configuration-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-configuration-service-override/-/monaco-vscode-configuration-service-override-32.0.2.tgz", + "integrity": "sha512-gg8mKZ+hM3DCP1biXq3PrW+D+iQMA4Q7uW2E5bm5QjYJ21YI1jIXHcmN3KUGaX7xPXBcmpTw9su8s5W28uhaIw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-files-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-dialogs-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-dialogs-service-override/-/monaco-vscode-dialogs-service-override-32.0.2.tgz", + "integrity": "sha512-DsLEFBe2r56XYOAHpe9BO/prXWTRkgzLFybWTNpGf+XxJbIYN+4LaGy5Ci3zrAVMLaCH37CbNiePx2BE2JXN4Q==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-editor-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-editor-service-override/-/monaco-vscode-editor-service-override-32.0.2.tgz", + "integrity": "sha512-yjnMkjyKHZ7umxIcMGw8+bQxIYQNvw2Qii26JlAIpDDKp+ER9baIpLwii2EH7yOg+pLVdS01UFQhz+Q8Kl/tdQ==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-environment-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-environment-service-override/-/monaco-vscode-environment-service-override-32.0.2.tgz", + "integrity": "sha512-2N3jIJmHzPSEjoUIXKQGdzmyVjjWnafxr/GxTYE4BWfTO+mgdy/hdjFrQPPVSKlbvDA8NxkpOfX6pXnmnn9SVQ==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-explorer-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-explorer-service-override/-/monaco-vscode-explorer-service-override-32.0.2.tgz", + "integrity": "sha512-3iq4LUU5TayKdFJ12OJGh+8l2Yp6rs3EzZLKrfTk/Z8lLRhV5NFGxHG0Ghqx7kcV7pJcSTNcPQmBTy6zn32FCA==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-extensions-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-extensions-service-override/-/monaco-vscode-extensions-service-override-32.0.2.tgz", + "integrity": "sha512-w81565arhmMxOL0UPviOi1eENwsCLUgbZA6A04LHlPYX/1AC/gxtEvhE65K7FnoQXqDpb0ibg9Ri28ga8L9TlQ==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-files-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-files-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-files-service-override/-/monaco-vscode-files-service-override-32.0.2.tgz", + "integrity": "sha512-kB/lvPQmLTo79rc7FQ1/3lSf6SWRkFImEZO+TfU0OpaKPfT4a7jT0xgPiDRuzBi2tH8EcHbb17YgAIHQAbWEQw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-host-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-host-service-override/-/monaco-vscode-host-service-override-32.0.2.tgz", + "integrity": "sha512-v+JoXdWRnsXE1/EW2EbmrL459hd8aFvbXBQ2YfhZiFxq8m9MZTZk2Dx66mp6lh9w/+Q61BLPrt4wbHpdJB7O9w==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-keybindings-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-keybindings-service-override/-/monaco-vscode-keybindings-service-override-32.0.2.tgz", + "integrity": "sha512-Cd1F/A2V+sWcsLtE/STsrlG4eR1NbyLnu22jtaK6nXd1ow7pstaUQiQbsl7VFYIKWwrknA++CYT1Kl7gT6amgA==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-files-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-languages-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-languages-service-override/-/monaco-vscode-languages-service-override-32.0.2.tgz", + "integrity": "sha512-dVdrJV0cAROhYu5jiOHtCCufneb6Ha5boPKjmsMoCLxOaB/eWrQpo7sUO6chU/MTZN14MmhSLANYHGRmhCe1XA==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-files-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-layout-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-layout-service-override/-/monaco-vscode-layout-service-override-32.0.2.tgz", + "integrity": "sha512-iYAxJaamDqdref6TMS4KyVn/nFAmMVz6x/35DNYGIOs1e55D8T5r1nIpCPCEZk21dDElHVycwD04I0GLDoTFVw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-lifecycle-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-lifecycle-service-override/-/monaco-vscode-lifecycle-service-override-32.0.2.tgz", + "integrity": "sha512-SF+SYZS3Bw35Cex9BVi1KFlZGQovfXx+l8bAlh4GYVtBpsCv/v1EUYFW5tc6EHVu6uUVeqeXk8IuJKTaKtt4YQ==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-log-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-log-service-override/-/monaco-vscode-log-service-override-32.0.2.tgz", + "integrity": "sha512-Lxjs3ulJRbE27MgruOk0duU1i1aQ4eGxQEnEYoAnj0rnjvOqAHWaIx4n6zIkyF/i1TVyrjDJeigpjNYDlwW7gA==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-environment-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-markers-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-markers-service-override/-/monaco-vscode-markers-service-override-32.0.2.tgz", + "integrity": "sha512-H3Qh8O+zeFNZRNGhEE+xUjJVFCvPhwF43jfjTMzaUdnRG/oe3O8MENC32prpkSz4LTD+Zfa0MzBZUDReb/jZsw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-model-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-model-service-override/-/monaco-vscode-model-service-override-32.0.2.tgz", + "integrity": "sha512-UTzWg+VV9JHzTqHiYzBpocrFjA8DkhVLFJZNzltyvTBR0GJYm/jAPxX1JkYjItLAMgtYMp0CrvunT7JRi1BLRg==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-notifications-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-notifications-service-override/-/monaco-vscode-notifications-service-override-32.0.2.tgz", + "integrity": "sha512-D4PCsrn03fgv+rwJj/YYBeYHVGbGrAKmEOgfEhYbCkXRjDTDjqNdQLB/h/tYZUv5fnNQ745vZnAUxvJ+9DGnZw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-output-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-output-service-override/-/monaco-vscode-output-service-override-32.0.2.tgz", + "integrity": "sha512-N1gMtVlRbC+mSgasO5DFZF1u09qvgellOU1aFS42doKtw5k/b6CYyNUqmulT/mO+RSQZqSBaAZbAg8pS8HKMjg==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-log-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-quickaccess-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-quickaccess-service-override/-/monaco-vscode-quickaccess-service-override-32.0.2.tgz", + "integrity": "sha512-7mOvuK5BwCs4g0JFg5mErQcGlo6C4S0iToKEZm0AadxxU5E+p2blphxaj5SdDcT5UsbMlariq69MyAdIzB8lig==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-storage-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-storage-service-override/-/monaco-vscode-storage-service-override-32.0.2.tgz", + "integrity": "sha512-bCXIUucTiQwYgBqQ93kRMwLkNe71BHiX+1/5UfFpHx8vIUpRaOmIFYje/u/MdIghuiQRnQoKta+jFis/38ayWQ==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-textmate-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-textmate-service-override/-/monaco-vscode-textmate-service-override-32.0.2.tgz", + "integrity": "sha512-nvqhHNzMzDT6JQE8QCb5tcu6N8qaAxEe9PXsa4XgC/SXoyviS4ZqxV1RT9Xq0bhc0lmpOSblbq3V9GaPq773/Q==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-files-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-theme-defaults-default-extension": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-theme-defaults-default-extension/-/monaco-vscode-theme-defaults-default-extension-32.0.2.tgz", + "integrity": "sha512-Pyh9ZJgdGteC23a4S0jR1lXJHfEww8JgdcwAoypo4/5qLHH2DhrXrdsQflTHkSvqaLo+x1zeUFT9HWcQKPddGg==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-theme-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-theme-service-override/-/monaco-vscode-theme-service-override-32.0.2.tgz", + "integrity": "sha512-t5E6I1OIoPDJzvsHxwGWR6e2Y5/pGh4YQ6uOGXT2EIVXPEoyYv5he2THRmTYH6p0RWwXpsb0Jrl7qALdfdf8aw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-files-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-view-banner-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-view-banner-service-override/-/monaco-vscode-view-banner-service-override-32.0.2.tgz", + "integrity": "sha512-zuKhX6drE5wQv3dwmgAGpL6pM0YCCWsqzkl0zJk776oSPg9LSUUUI42N74CJRU3YOKxjXx9PJc+Z+PnpN33Mng==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-view-common-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-view-common-service-override/-/monaco-vscode-view-common-service-override-32.0.2.tgz", + "integrity": "sha512-QPkzUf4Bi4upSzv0vb2MvGVLsV4L1JPHnEy54xP2t0JFlMCFTs2Prjcbjm5lfi+K5EFlgMAunc035h4MebohTQ==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-bulk-edit-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-view-status-bar-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-view-status-bar-service-override/-/monaco-vscode-view-status-bar-service-override-32.0.2.tgz", + "integrity": "sha512-4OvfhvpciFc9UUlQKvppe3+OnoHgc+5eB+4AMwDze4+I0boQn9WTzb/wLRe4f3Iz3oV7Hioz6sHDkWHhtVgWCw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-view-title-bar-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-view-title-bar-service-override/-/monaco-vscode-view-title-bar-service-override-32.0.2.tgz", + "integrity": "sha512-2JUO7uxxiGv1cBaQSc8tOZFyPDZnw6vGNC1z4dPl15ULy6mzxhHtLaNDTxUJ/OlDm+SKH+iZee+Jm75eGhj1+Q==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-views-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-views-service-override/-/monaco-vscode-views-service-override-32.0.2.tgz", + "integrity": "sha512-a/sp4x4xuVGMBH1hl9HJdRoAO5+bxseAlQQo1augY8SQjb1Yqxeu5CyH9r0FYJR/iHtZeJWkTL4iy/ZchwrhoA==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-keybindings-service-override": "32.0.2", + "@codingame/monaco-vscode-layout-service-override": "32.0.2", + "@codingame/monaco-vscode-quickaccess-service-override": "32.0.2", + "@codingame/monaco-vscode-view-common-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-workbench-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-workbench-service-override/-/monaco-vscode-workbench-service-override-32.0.2.tgz", + "integrity": "sha512-I7j4uHD2+PT1rvK/R3IKEVJAbZLgAyg/XdPCaOFwsslIcQ0svgOyQgMe9ugTdNyybEg9S8LzIzAVKzDLgVytyw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-keybindings-service-override": "32.0.2", + "@codingame/monaco-vscode-quickaccess-service-override": "32.0.2", + "@codingame/monaco-vscode-view-banner-service-override": "32.0.2", + "@codingame/monaco-vscode-view-common-service-override": "32.0.2", + "@codingame/monaco-vscode-view-status-bar-service-override": "32.0.2", + "@codingame/monaco-vscode-view-title-bar-service-override": "32.0.2" + } + }, + "node_modules/@codingame/monaco-vscode-working-copy-service-override": { + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-working-copy-service-override/-/monaco-vscode-working-copy-service-override-32.0.2.tgz", + "integrity": "sha512-Ujv3+RCczUgxQTNcUUwQZbpEo85FLaOrso/zkdRUDgRP/DpcT3Tqb/8nwIBf97cSRujyE9ju8DaBf79CPilpDQ==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-files-service-override": "32.0.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/context": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz", + "integrity": "sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.2 || ^2.1.0" + } + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@vscode-elements/elements": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vscode-elements/elements/-/elements-2.5.1.tgz", + "integrity": "sha512-HiKgIj9GwlfYkw1LrxG7dM5bMQUr8/GkOqG1HU1+npGHd51nRKCF6ZZ9FtnfoC2wujNN0lc+m0emH/wMpAseYQ==", + "license": "MIT", + "dependencies": { + "@lit/context": "^1.1.3", + "lit": "^3.2.1" + }, + "peerDependencies": { + "@vscode/codicons": ">=0.0.40" + } + }, + "node_modules/@vscode/codicons": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45.tgz", + "integrity": "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==", + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/@vscode/iconv-lite-umd": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.1.tgz", + "integrity": "sha512-tK6k0DXFHW7q5+GGuGZO+phpAqpxO4WXl+BLc/8/uOk3RsM2ssAL3CQUQDb1TGfwltjsauhN6S4ghYZzs4sPFw==", + "license": "MIT" + }, + "node_modules/dockview-core": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/dockview-core/-/dockview-core-6.3.0.tgz", + "integrity": "sha512-KY4goMIcVrMh+LDtU+bwANkwK8FRxRUS6EKD/QH1OPNcj8wq0MjGKes5PojM4N0/1NxAsI4Bem/TFV4jiiOKGg==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.3.tgz", + "integrity": "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/jschardet": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.4.tgz", + "integrity": "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg==", + "license": "LGPL-2.1+", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/lit": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/monaco-editor": { + "name": "@codingame/monaco-vscode-editor-api", + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-editor-api/-/monaco-vscode-editor-api-32.0.2.tgz", + "integrity": "sha512-MLKI+C90h/d2LsmmGKGjzk79l/qwF7wU/no7kURFBok1HT9/R8gAkjqpTXUh+zuIHPzMeKIAwwK7AFYhNHb5aw==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vscode": { + "name": "@codingame/monaco-vscode-extension-api", + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-extension-api/-/monaco-vscode-extension-api-32.0.2.tgz", + "integrity": "sha512-2r9guYfOmJL5w311dtDsokw8O3Gw2er2D48Cd/96HxVlwBUh2W2Rpa7i2I3G1xl5QSd4sG1BI+1t+cNHC4a2bA==", + "license": "MIT", + "dependencies": { + "@codingame/monaco-vscode-api": "32.0.2", + "@codingame/monaco-vscode-extensions-service-override": "32.0.2" + } + } + } +} diff --git a/Playground/package.json b/Playground/package.json new file mode 100644 index 0000000..04b4b53 --- /dev/null +++ b/Playground/package.json @@ -0,0 +1,51 @@ +{ + "name": "fade-playground", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build:runtime": "node scripts/build-runtime.mjs", + "predev": "node scripts/build-runtime.mjs", + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@codingame/monaco-vscode-api": "^32.0.2", + "@codingame/monaco-vscode-configuration-service-override": "^32.0.2", + "@codingame/monaco-vscode-dialogs-service-override": "^32.0.2", + "@codingame/monaco-vscode-editor-service-override": "^32.0.2", + "@codingame/monaco-vscode-environment-service-override": "^32.0.2", + "@codingame/monaco-vscode-explorer-service-override": "^32.0.2", + "@codingame/monaco-vscode-extensions-service-override": "^32.0.2", + "@codingame/monaco-vscode-files-service-override": "^32.0.2", + "@codingame/monaco-vscode-keybindings-service-override": "^32.0.2", + "@codingame/monaco-vscode-languages-service-override": "^32.0.2", + "@codingame/monaco-vscode-lifecycle-service-override": "^32.0.2", + "@codingame/monaco-vscode-log-service-override": "^32.0.2", + "@codingame/monaco-vscode-markers-service-override": "^32.0.2", + "@codingame/monaco-vscode-model-service-override": "^32.0.2", + "@codingame/monaco-vscode-notifications-service-override": "^32.0.2", + "@codingame/monaco-vscode-output-service-override": "^32.0.2", + "@codingame/monaco-vscode-quickaccess-service-override": "^32.0.2", + "@codingame/monaco-vscode-storage-service-override": "^32.0.2", + "@codingame/monaco-vscode-textmate-service-override": "^32.0.2", + "@codingame/monaco-vscode-theme-defaults-default-extension": "^32.0.2", + "@codingame/monaco-vscode-theme-service-override": "^32.0.2", + "@codingame/monaco-vscode-view-status-bar-service-override": "^32.0.2", + "@codingame/monaco-vscode-view-title-bar-service-override": "^32.0.2", + "@codingame/monaco-vscode-views-service-override": "^32.0.2", + "@codingame/monaco-vscode-workbench-service-override": "^32.0.2", + "@codingame/monaco-vscode-working-copy-service-override": "^32.0.2", + "@vscode-elements/elements": "^2.5.1", + "dockview-core": "^6.3.0", + "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@^32.0.2", + "vscode": "npm:@codingame/monaco-vscode-extension-api@^32.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "playwright": "^1.49.1", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/Playground/page-shot.png b/Playground/page-shot.png new file mode 100644 index 0000000000000000000000000000000000000000..61abf8a472f93d39915964a4215e9031b68f7c8c GIT binary patch literal 52173 zcmbT8bzD<_^#7+Q7=#5#i*!qOiF9`}rMtTY1(9y0Yt-l#7^p}~4Mumm(IZFgclrFj z-^cI&-~Mrr?XvCO`+lGEImgW$&3nQ&~ z@cMPw@nXP3`h>Ty=!>RQ>vn_c!nes46n^7AM_&fJZ{>&wSbKZ(RtRH-J>;e;jRo(6 zKn)6dPy~w;h|h;Swg>oE>fZ|xXcRs1j3$8{#G%m`a$fTH#e~2g0+3pC-W^aVNl7vD zO3KMPp_H-)heRoZc0x%XIcOs8_PsO+r`_*>kqE+)2~zxw7?fe}}gp(o13)RciQ@k$4ZU_G&^HJG^Sw&i0z(Lp}d zqJ*1D2c28)!zt>v8^2yzZ%FCNud~?RLrF2s<>Gl=d?FhIE>yHOhgE_k`8tuUBAJPa zCj*1kqfio+w{nUU8@+>ryzJ~(=ZEa->gq@fz9lz;BpzQszy9W|KV?cXicER}T1G}j zOBKJCDIj?{>oQQ6(N{t5wIbvo!A)r4sY4lOg(5r1X>J&LO%@0b4$Csx@5F)g`Y#S?wqXh%%o_kljw8ZKbZ(6QvJ=P z716_(>$)H#7lsQ08Ikl7hPS5FOWoYu5EG|90tqEle+~@=VZLgpogl_cY#V2JRmgPe z5^+uZK8*2vYF{Kj$mizjnvn*U2S}~7O!@u(8wIZ43kwS@AzyHW()*^u0)_kZ-|b@F zJ|CeX7vPeyaCs!u{Iuo{Xf#Y`n`3&*w#L+}eS(uMm{P=rn>MTxl z&vv{x_Tr!~k#4`^(pgheQ}yIg=!GbSbof#~%aTdg~;xw&~e4Cb@HSjk5- z?7t5J3F$Xh+yU9UaW1UXfjfgEiD-gXK9f#FYL}>`WVM1DAGap9w6*yl;j)C*iYawWXfij2})ZBNfZuU4)+V?j$yY9=?JCWix93FU0 zaY`7J^KsMC(tiH@`5E`0o}MOq1go&{kG#CRQ{BM8z`J+vPPl7oCRPN`V*S1M7hy$3 z?*yuZbf>CJA@%IyRv5hLRxgKawe*~vs$s|~3N>4-nmI5qP$(Z?9t-&xt&FJdvEelhv}T|&iRbwh`X1v#pYE3_mej{`KZpLN^5u>cm2l6at)DFsjDxyR z?r-cfkWKIzsq%i1L4$tq55KOJx-c}kOWCOKgsK+k1SME0M@GHIvg>25$87U+NESIh zYvE+1i(cI={|-kq!!388ZJu6!OzQgF)AH)N)Z0vJoMmEnuo9C>B5VHi;R{Qe^_B!W znMX&b#8#;p&+FK%j}IJnjooHj*4dMto5pNY_AZ9AMZ?3x*Vfh`mn>1C7SlShS^h*s zL_QUXS*t%k;xx8@O;11C7k(}N4mrudz@YfUV`~^aYF?|r;rl5-+x%4$>c0$~qG8aszQp-K#BM#m` zW}?khg`tOeo5+ISRyL3@nu9J+bsf0!$G$wJTi;exLr&uap$L<9E>X*-&o`{I?60-x zoo#Xw^*^>%^w}KFp6d(=!MKug7|}nC*va8bxCt~fTjc7zdJRrTMcs7(J+_SX^Tby&L>sV6l;o*@QqNCJt=qmkT>P>Th zb`a5qp0luhsay3#T=W(5ht0nyG#n$gLn6Ad%Oo5(QtBcitQ;s)F8EfI;u0MCGZ7atN5T>~Xn z4jejhn|-_1dQ*j5<=`pzO|btLI5L?q-SSC^BMV;V@pIz26QtwNe5rs zPZIP{4+u&0inUAndZ&iJ(?5N>$D$m7sZ>-dpe_T2dZ=^M{}I&!<>4Ya7}UP=WZ@vg zv!+tb!jE|-BGLNISBVu@(Q^uE-lF*1#>!lgRTJ3XRSBi>0GZPN-G$#aVXrmI&fCKy**X1~5?C;ZH26$PJsR%CpE*=# z*qF(9{SKK3DJoJhG}GjX^V*&)vzp1Hznhwx3QtUVK5g9O=oV;-(P!kMZ!4Eb8LqB? znvK$4Yo9FTdAdVfN>5)?^%M3*E8*1#obA=6QLexhQ~Z(EO8EIg#@EiHulKJeC1YQ5 zzXh(UQY)~Ys@wKNFzobHi35tsjEd^Ui`ZDf#q;%K^#Q!1D$Z*&MY8p^gq|b~g-GlG zTXjQ8L|^Zl2xMk0UXrsn`4f9%`x!5NZjz%?()}68!{?0m@bLB*+Voi&DiUQ>$@^{wd)ore zoP|Bt_30w(X0-_0!~;$hW~e9oByN*GhtI!JX$dwhKn;c3iyq(S~Egkp#O{~s(tk&yE$QE5zIDLQ=qJ2taLPc>(=|EnOSmbmO}o1%K(>X^WU9< zF`}7Wf;!W{!eUT)CGV+14BzSmpK-kexoQFrti7qHukpalMObZR)zP*&}EccY# zoi8IO$~*lM9u`*QVQOT=8f!%i1}`OFCb{>RrrArqDpLNIl~ob$N*IY{0+q*Bj1-?fH&Z9HaBGCcl!tOA&T{aOM%+4 zI6I4eTT~|$?|wfv?7-e!qTTDV2`B`zd=~w(MZC1^8MyMz{?b7e8UT3)JBvuP@cjFY z8y&Y^lic!-hbrpj`uppZNSgI;357Nn$4K`l$}7!F!O7%ybS~aKF=YAq`Ogd$3j_jA z9cP}~y~-_VRTXx%u&~&8nVKg*6!iiqkTiCC(!37;6%r{Q0fA2Om+*&!tinmaL#5$J!N}sczais$5`bi+tk=L#0@tZL#C81YnaPW z&^)+_w=~|qol7wOSo z=F|Vcau6P`9gdGIRHS@$-;V?NUMW{ojXKxeq3-K(iqFn;{&dx*kQJBKuG?IRONw~< zpWDP}#?8&G2@`tzs1e(2hJJDRuB0vizILm6!f^fO5Sz9Mk?^rp^*@IdE%)x#rLfBf z*{YiuH9J>I-0G3;v^|Atv*E7XtG8&VmilrD5HL$xXMd&!UtcT)?8DG)DI?v%vY`e| zj^B@K86J@dIDQLkc}&WwbN1(uJky@0^?Is;P)M^nzGlDvTB4P|cBuh=uoa@};J;5m z7r=%G{Uqc*3S?KVXJiT;bSHpxt8Y#$RH{`)BWC;R|JMtkO^hWkVQETGIejN#c6lOD z$Aps4&ductn9G;J>zAjGDrMQe3u<`Mh!uwa33zg5nj>};KRI*XhL8CyP0{YLU?@$J_YLOhLGWTA55V5!l@lHg_s1r z6g!+Q1hz(hwCagIW+L)ZyO7a?j{9_?ejJ!t5MaJzBAX0}Ro}NsR9?go?FtmMznN?H zYM+#~;TNnF-YP$|Lv-x#(y&aHcfM!)>RuQzG-_*lI_$rKA95%Aq^hu8j5Ixknm4Qrcz8tG6Xrg$0nxpduA4J#BAxtV(AfebPw6ky66D-$p=m#a|4nCA@k_jHK|5VgC^)56jQc8aK@Bto>l>7%y zoLax&=H}-9R$pW*>8=9Av$&wJ5c1LTo)@yW%;L`aE6rXm&;7-Pn-o~OeP@c9ga|UN zyl_livDF$LR4S5yP#AId)Ed3P>LR^REK>Bg)>MWzNGghgGGG89VBXmk-XOfo zd=nvD`0?6lt|@hdkAq{kwwF5DG;l3eaN@_qbyB*MB*qHUHoe-|5&gOSnffh2HyO42 z9@^vv6;DejA>Tr^@pX=X2vnXdodcgJ)81I~1=KkNIuF#WOgX-9E3wySOAtIx>F|3x zWo2dPw!|*kU;>~PAi~E_vDo~k6)n*gtcs^sc$xNwmdi)5ns-~R6ijNbzM1uxYTcyxiUA8; zqf#PBFD<`vI;ZZ9CMP+>;hXV@9(ANUTgbGo!JuWrU@~;rebA*$=8MGNK?dlAW7G9O z`$9vLr>2|N*vla=@`>V(FTJ^AQZ*%mKv8IikuzK{Z&VUkf%v34QE*Jwy6N&tkm&0K z1*MX6pd?Jjx~h%8U8d17ZTcS65x6p!)iA4uX6dIHSU^?Qve40P1>>v+VN|jM@I=?)o;AEw-vYri5 z)#?5c7aqitz`p$TezSZ$b&@$BTpAZ-yPCyMRsHdns0{B$J8P1X?OEFTvzo%pe=-z=J+8 ze}PwxW2O1jFKw`1d6m1+U>CJigNtcK;x;w09%HpT{oGoLL!*oKYJT&M>Pf8uABwdX zh*-OnBOb|Ts|AfpE|Q&n2)7e^mw}IsGIMZ?AI|WnM~6JbssBXYrHXX0JY6>?qta}W z9BON5N5$@|ovIEU>nvsD!@I{9cDAhMGqp}|7fv?SG`lNh21MVb(cAcvtE!2fSn?P= z>lhrK(S~CNMrpB8^GArowYkiB*dEpW-ZLV{C7Uh%c&fRJHW*LA$i4W`gWp{Ncg_8o zZT`}g>SEg@RKH!L4;Kb^Q}Y+(ymKxNdYYYi)~PjzW5Ahl#dWJ3G>dk9!ftD)I38eL z{glX)mk_=W9F`ph$Qt2c7}&_jp2qA(J?fz=)$}B9=ls=RM}d*E3({8A-p-^ZSS9OB zQ1lT(*T30&goC&F6ydkscz?s|+F{8tF>vLbK6l@^Y*>tL86@5NL|n_(BvDJC3a0<1 z+1bh+sSCTpSup*IrGCvWsUuQWKT`9Oi{IzLQQ53ouwU{&+rD4yra{QB(C6H&O&(iG z8H&wIfa$5mI~7c#XM}t2UZ0kTIs9?jvJ{N{ZRSen{%d4Bu^^vY{4gq6rV~&CwQYyA1IIZ zSp+$mFhH?24y|fMtwwX{IZl!G1uIF%5&MZgdtmB0CX3!qStiwN^06kbY(#E3R zPmxGx3}Yyv98GfUm*n98iUZ0zB1ft{uM8Wc`GLx#f1q`G`i4qq=NjFti^T4x7ab!t z49rI{{@<-R?O~n{eN@_7f_q1~ZLRkHg`y?J6|+zILQJQF#tFS**b^69Jby1`5!Y`g zEag@C395>*vkgoJFFm;!|5_gPojNPHRNJ#wM`;VYF4;A_offbri>xE68|cu<#a?!> z9)_Hk)60C0&I(JV5^|m1?*8tEm~6G5e+q-jLKBm{G9nkZ+5J9M6nG;;j24v&Yb64X zJ}MRJ?c1jvuXpD54uUh8I$17DE|+6uD#O@vu3EZUZf4Mh9<^zyZ3^`?>9SWH`KXG& zPNXHLy9RkxmkUXHA&!rzCV9`=_xy-DmoUZAgpud^tt|##oFvJH0b35H8`@MeZ2AK3 zM@0?I*4}%C9rM{w%5@^JDa|BXjz#6(;M$(yly}yzG|O4 zsf(k@VeNrD)QO()WZarzd*m_jxIVxPTla^|Ue#cvR=f%W{^0*scExQYhH9=!Be-0b z`s!L7qnYur*NwmyUoKkr*~cebq){*DJ;{-axq-%!_LQB&s(Mz&5&13-@#n9C%$+xe zEjGko&vR{WviBulO6SC-nVKUP zj?mYNNK#V1P)v8HWaFmd;_k-V7A#IrC98D0Hi@-{zDQw|5|MC68^BJ&vnLd=eZje3 zGD>N3J@FBB@1EfiQR|GHD@op3!*C8=CWe%%T2CBAW6}4lU_B<1ls(w2*+mZghc;JZ z0SRp*VrpJsTh1%;Q+XG_gf(xCN3dB7yUIqSqKmec?G zI7IHdc19b{1i*?j95>XST-ByCrEx12q-${M!xI?s??gF@8+YXEEey4SgTfH

7VdYg<4>hG zMi$yTl~oe6(ni?ek~f;Yf5pz&dZ~m%q?%-MuegjV8w1)_r#j+OJuZ{DTDGA#kG;A+ zM|K)bTW`cI6FMnV|F~EVBlzGXLttti4Iv7lpR+LO+#$1wzk#(`z;1pY?~^p&Sa>LW z3F(f;D*c=3aP9q7&Um z;JI>xj!jDlxdtV?VD7vwe0i2d!%NVjE{Xx$7<~&1H>+ zpA};1A6o9_$gaHzr_g^(VNf>ntrg9M52r6WnJ~$)PZVYaUx65hQTY^C{NBhv6H$yn zuXOq-Df?AclQjM~{M|*=F)nLI89_PTlOErY`*M2kw^aQ5DUNCzX4Rxjbyw8aYQzk| z&Lnn;IF4wf=F)*%*Vzx-)Y05Ax0XSwvZ2fLzDVO+lg(hHPoMUq;fL;f8aK<$xgV2r z!e-B)WjE}}X4mJw`+6NB{STgbux_LAF3Kl(Ftw5?FQkGe&=H#Bb=3o@eeD$1 zm3wx5@~raOGxA71gyHv0{vy+Fi7!&M*9DNd{pm5NVv?%r$Uv06FoGkS9jQCR40m?io66XUbn; zM6G&ikZ?Nk0bsY67YFIB5nUdK4R3tP1 zrN16yvZRW!>niBS$u^L5v+5z%Cf_nnyxhW!#kTsaEUIr#YI8jF^p=3GLpTp84FJuV zGzR1H*7;ud1qjcncO4j&{*!i?^xGErgXij<+=_sTA7>~ce@&!2~Ix=e3ZlN9_WV62y3#bwzx zt;{Y|E;QMf*3_*6GQu$)oZtJs|Vb$kE z%`QjHAD{u+fkmC{m)}#jsEMr&I^~5QH*k^*N@mMJx%CAWBp(XEf(qg1e>Wh;5-qTi z%Kdvy+gNgU+5hzd7>JGtotDtQsM3@+8$^5@7uzvF!_yZ?;fP2(J3GL8sDRwCvsE?x zon8A&#tdmQ)WKq&0*$~^r!{W&9%Y#DXY;mxhy@O&xTXZZ@gF5>vtX=3$&NMMs%%Q) z3!sR%*4OXNFgFc1X^Xt$SKWJ``>ZZ);P>yh!ZPzveJHAKBbVx za5eOLJoz$|Y2>+Pa+A~C0ENp}4?aGAbbfkj>h;ypNZfc=@a1lEo6kPAm~TyWwTrzy zs?KWgCeg7Dneg6mttZyqBnRz^2m0y!`_rKk!esE|Qroz?zLb>epJ$pb!hVfp@1{ZA+8!NVMbH^O{B z*~#AdYJ7&lpj8(%pNUgZmFX37b6{1G?8bKVpKjt}BfN=iY}OY|y;}9KJhjQZ*{mlu z!oR=^v0aAL{4f?xo$@vpBXn}Er-pjfw#m(@09(wgd*S2_NkbDZE$ zXkV{N+&SvN8KQ2Ol`F&~Z+G~Y*veS41-5lkt>o9cMP$TEv7ui4`#6Z%&FYhC-dzqQG47BMH!E0pT+cId^784P&fcCLIeGcP{{3uGpC-F;In;K5 z*$k4MWboo-n?d>jjZQMFTz^#(!%?Kw+B`s(%q|1xyR;-w^HIA`P~CZR!Lsc$$6S*b zC{4(69~9a{yR|EsL&~t=65=wRW0{)}TCUBOME)YJRZW6Ry8zGcoALTAZflQr z8kAcuS#&p$bw4-lYK2P^%;89}3ML~lNHkq$6hAocgIAL_M&a|%dyds!N5amE&Q&O~ zxl0`ui-YdyXM=;RCA^5suvg7}d%Mw=qHTnjNvm8Ye6bF1pFHR=PeZ_BdG<#{j=1YI zn@IZ(^s^KF@0M+N3dC4u!jHKe#sabb^!BIvAe~vO_{zoOaVwglp5Ae>2Kg$-tASd1 z73-ne`SQokHZlD#Cm38T-u{dklUKEtO^SBjwHN(pXzx1KW^~;i6F06XqCEGZgNl=g zTi>9mBQ>$UM6>vwL)_og4P(i6Z9A21Fih*0q2{>i&CQG|f6)Dnv@kR;%$ylkQ)a@K zKTxOXXc!LD%D(AjsWGbb`jPNUq8I!9P390#BkGb52@~@H_ySY;z5%Xog3AU|{7y<| zPlz#TMh3DBVKB}kZq^|#ZO`Mb_Sbs`lhFbW*4z-XSR(>_QkzK%EtNurX!$Raif(J?DaWAk<^f7j2!*B6|-vjDmEWZ0e6#7pze zZ>SiTxI$9)u^7aaj4`pzW80~cH3lMkz6_ks_O#3vMK>VT@tVtnjlbp8ob_(8MZc7V zx6o5*aOLR@$2b<RhT%o{dwY8j2!!AC6FB*IO$Vc(W4K z_px>oQ^OkL<*!|$<5{jujcQA_`NpHO+p~gREo3VGGOWssxI~=>=I91a^b2ag(bLo0 z9`2X1fFy$rIgWxsYtO?;IeLE99BZ1jLZR0N7_)HZ^(&kZ$@5-GDcklX8 zR5sCKA zP>$v45GdAs51zoW)8wG=9=-PO$4M!tJ({HwoP&g#r;ga_47+ahzUgA^@mH! z3}Udr>xPeQ>se*@KXZ03E~Rk_a(D4W%8yC>)A1@oi5F90MffHncR-->tc|=oKZPUm z?nvRdBJTl&QDely;xm9@bN!EG6GBDFvw3*plrr@|a7rKl$5ORsv=YQvf%05c?}c7i zv@-sOS3NFAN`gx1dR_24ME-}mBT?NI`=-KJnU;eS+L>5=ALITi<(>xkzt4Su*o^ck zLf8)u4kAPoa6+>KIr`GW17)+WbU45)puF+z6M%ydlFtqdQ0`t~Oecw!H6?lR;@-V` zdV1uC`l+Q&_cPY1mwz--RzSPX0)+ErSD4e`SX2>*sTcz z3PM^C_EoR3vQRNTp7&QdV&brNn7FdizzdMZ3l|rz@Lw7_Iy);XRy>A|hhc~` zMx7Dz7eKc(q!;oWSaySIn5j;fL*7lFp^~8BSxgLmVJ*D3SGn59sslH#J1OJ_&F~6* zKQ(ouH;yWq3vUkcJVP?%#q0Z^l5WH|;Ciyd6>E$TabXC(s`4?U=iVMQ5TlPyP98pb zbbNARJ%F2M#q(W-n3z~tSh%~J0o@3Entk2!ON}IcP$^Z23;;iBmDYw;=Dxo^%HJGO z0y=D4NTi{jH8YE_qkwURUbH_%7JXWlVxajm^O5hL^9r*Kbj z=ABu9u5YeaHwv_UG0KaB?)6VnOBIMr_zAWZkv0b_@GSEapZ5S>X zocr+kFm@#}Hzj1^3e(wnzUpCJZ+C+7iaUvyxOm^}Y}$ElUL5Ap*O%DE%fmxRsnEI{ zcBHMP_52wjv(0~B{F9db96FrFW9yhNquY^JOjPoh{Km1Obm$iR>|Wyhe13^|&P3#- zanm09R@mw_KvUoh9jvUWVgA&Q+%R!gEgU1K6fjM5&N%{3C;%1#fW^(Nt;<2KdgoT; zX*eDcqHY0jwE&BFn@&DX|He){z&kx|b&w0YRAb&?*hb;_vLB!h$I%Cs;?&a65nIWhWF^jrt5P9j69g1@HR1?lj|*#f=2?gUyK z1UgR|$5J@>ss-r3#F6v=w&k15;o)Nu_1^JcPh>I+#`r-{s+EE0etA(Zl*Sq7>kPuV z%G4BP&hOTI8Ab1F4eafOy->vVAO4O0Vqk#SyySCza7PI5Do8`7vHw*i3cl!c?%^38 zw9vVy^`ZKXYgMTP*?KNNzbsKmZ?+p3VPf+6^CkZo zA3n9%8wLhl^5gBPt+Ep8lfy$6PR`)X@1z1Qi~dVNJ_MB0lCOStwzRART#aV)IDo=T zfkQCK>Jm#WZb!y0F!Co)UcvsHEnd3r&4VF9OC7N5t3VhG7Ed$MaWW|i+45fW3#SRW zq6HYV@cgS88*H=lf}EseCy|6No0!;NOP+p00JC7Ko6!dc<@@aCSl9cF6);qHTtsNG zA(NCUiGwlx^l8}?Xj?5Ly z;@{33gk;Nv^H*})k)K7+xwfKcD_RL(9P|AUXdHGC8cg| zz0%Wz{ZxKYKFo)?1mH+M`*>7E7=UgUa7yVD3SzW_08+P_?IZC`z^S~#ea=bH zXLokvV%sI?c&r2f;WFAO)oSi+$pKW)}u=nUh zd`tsKQoANeLTplF$@!M4Q&>cDVI+Bn0ciQ26UT@mQKf!0^;WJVBLjoi;i{Fh^92BS zU@k7$4Qgt%O9(kRIe{(XuBaHr4q@l78yG7jgX&dszZ@W*I~$Iq>8SVKjZl}o)}p2s zN_as+a&bCW{avLP;97NpqO!8)F-N3*Ku6m~PEJl~h^@Na`prw~pyQ<^hdwg9GQ08O zlSrE2r1bQ&L9QeDI7%)q?%*q-neV_Nv>Ql?ZZxVSH=cNV3M^C_yD84iXfEqT-?hf) zj4|4yj=DFBR8vo$KE3&cryJP|5Q>y|{5sm{bWJ3;t3x)KPHK#kZiYOL_9k?Xj$EHxQQa9~`9f{sadeHfBA^5yQNG_@JuAJVQilA^1CN%vK9K z4{(no5S$+$x;6oX9VmgBo2mtp`%Rpd0L&7g#EIwlkJ$99^z2Un!sB#r;RtAMsjI5i z!=mxNMk`NN7?$LbU5td}c(i-|_V&&4H5jZQKppzc(ls&Oo)|#}syX)@`g=0*d=9e3 zk<0zRUchrgUj7N8Ae>O^$_Qc78-D|B%m{f0At=kpiH`hqS2@Az{rjIluveCD)pc{* z1v-XY8>VfieKd3c^%9<9#bQ-f*3_z_mJ?_g9DE~B17icr07ilhASkH=cFF+yM!f*9 zQB^^~wc1R4{TNNGZ<6!87pTp(wGyIpM>btK8x+HH5&~Rn!+3|S1G9+wM-Mo6cx>GJL zi&h4ACZ0_+Jl}+h^!C2~clr!WUifNRKRuZpD0}#YvQ~BYl&HAML8B@{S8d)`XV@v3 zODWudADdjyLFds`rzP6jM0mIt=_x4+n62C`)l31enL3~&{m%EWTOWJ9e@Xu;!Q(d& zjWX7Ln5QB((~S4B_#J?SCeh})vI^L#wzCb&5Otw|Q#aIcs4L-bh z@$<)!n^Iq=zP%dao69{=;}||ou$+D-0E$k!f4J%o3lDeqIGX5m$?;uX@!mBDZW{RJ zCOEEs+t1&Bz$YCxUWz_BVPRqMqNLd0+}uQ=$|GL!|2T$63j&Gc-1VYg9cZX-{u_&d z1)Q;zB&YkB{4xYWfFqwhFxBAiH_Vco^nS3ryF0j1HYncUHqn-6Bu|c8*MNqD8dZ9w z20+UV!+f?v&)xln1q3#m=Jxb51pK3H3~|CD7~_CAmh9E4@yqQS4j|`Q=718<} zm{l2kR|i`D`;;%s?EU-Fu=;_5EMuWhAU>HQd?{tQNzii4e+MUz4FPpLLD68Jf^KLr zJu9nwz~vyZkj!%JkOXoYn2Q0VvhmBY(Bi(3L*7SOw9NEHT7b9*h1SbMZ{GyoekhIe z-xF8zTMZ99micalIPmyscdG+|N9g$Nm)$-}ZiilOA8InU^AEry3#btOdm^L?3&)Ke z0&hDs##!~200SjBI5_HNrVeYlGCh_3V*Kj+pSPpG)So29=YaK3#85 zRqN~1h0=ofTrzmhERo`*GMR78f5NnmL3ecwYHY15%c)-qw1qk{v z_lGr@w74efc>f#yFkO?6l8cdB19*QRG?hrCSPRa(23cEgR(B3J?|T3_XWh%IwOr8H z(D29mUt#m}?e?N{r#mwaOX^us-@g5eqY`CRg#&~!z2A5egRFsl^5$xNeH{Uv>JzZCS!d|DLbLT|YmS=F0}zz`x~I|g#fh@I zdgsZcaVoFg|2v`V+`oTSkX3^-3qU9O;NSoN?&W1=6-QqU!r}0N0VPE|fOqb=+zU`7 zE4p6kiags@{s)KDi`#1G0u|YGEkOO${IumOPffaej=|iVudz4w08Q{T`!JwlJKk+x z`aWdc8(Z2%*6OuQ@^lQf{3~2*8~}>|SV>V;Ma5G|seIy{d~cr-n_iX>Da^88>mlR( z!U99N#YH5n17LCTdZ8w8@CX4Dxh5}f87K%^ zfcv-pWuU*`Z#9a^lBIJ)o+hXPU^y6*?YbfeTsp6tD!i^wrY_IV71jX!PM;9?yF4J@ z0zxL};1?chnH&L-zFe6}bT*rRzuQl~TA3YJmt1Ic(4RpATqP|Z=ahbGeumY80&di0mUO2#ZPh)Ebar+I;x1f}g3mVL*L%Q-zrH++j*ClsCB^gL^0Ac_Rp$;5 zQq4@{{Z9}nDQSR3+w}BHFt9Z<%Ynf>sq`z2 zoYcwaxqmE&Jw(MH3lnGEAtE|^MRBqNGm`MjA+$-9QMkYWX7UYiSBswC?6vI=BpwWM zb@uHaANJCu;{xb_CD1|zhA?NGs_0RuVw}NZ4mp#!{rFi+x9!=mX^TO z5(htV-0$7C1AwU#y{`M0(PrK=R#RpHPDVzv=#4?3b~>IkcF8^!SId&FSG3|IZu{xtD^frYkT|Ku+Xrux5#qE5;mY~)Ek$Lu0YsVPMQ^BjI(Hiegjhr z?=C(i$^Xs8PN_U{_cv_yrjv^~2Ov3il4v7gmqjr<8XB4@pc1MwZ37MhwtCvcU_Z*C z>W5JQ9&%i%PQ$=}U-2EFe5_=VN&Q^_n%rCaj6YFBW+Bf=&lOj|X_j45N!Q08)eS7I zvxPI}*!|Nvm-MAN%3xrzYIlWAPaAked`Slf8@e+@f#@G>FLj0hK-7k-yGe6P3!oxs zQp3!uX#gsDO&eHA$<)D`nqw7)^%La6CN8iOJz<60c?)0xoBFPN1{D2Dw$CC9UMBrh zN$k~8yyVSvvkRF2NW}MGdH#n7LuvvJ!mXav)Kk;kd^H4n31+DV^u{~wpHMjTnBrvBzMn;A{0Olp&n6tw} z&E9}$V2cS6x&qTS2dmx6^r8tz{kUD=DK5vQ4w+#w0L+gWE0ixgV)O+>#LOCd=4}Ym z2BtTfw(drgT+`-9WO+1|JaqqRkhYaAxIPj|1yn*DyO2wp5Mrcdpocq zSZs4*1io_6C%uxYWar}2caet!k*000br9HAu-v}>diuU!g(OMI$$$-HeCeS0FHcVM z8JmJ3RsO4AAL$8`7)!ZmXC?s?q1k0A_;c)4v@ZZrK6>9Pu3YB1wZ z=G!VtVXjx_ZUF;NNJtn5xjN3#K*g9q<0tDy$HG$fEG&kcUrR52aT%Dfx;Wdn*-sH! zT-5rQ+*i%W%d3qk5kDT4ao1Sz7!iMlr?9e)d-{>ri?%i$1~_Vo+1XT2pK=4S6hPMJ zf%DZaR;iV|JYS0k`y(N@LDQ1*Hh!(it$zc`cm2U<>bJZ5lr*)=%ciK-rrhnJaCymXehzG#-R4}+ z_8Rkz0o}yJL=^wksXwyN8PF2I;1Mtg3ZN8eFV=wZp^lY^?dlfuh;>p_OcE0~o3_<^ z7xhh4K^eK}7Z9L)Q2YMZ-Ke)!ZF{qs&aJgha{@_LK&FN4gzgkw@Y;^Px<6cFk3iv3 z`)XiH6szd-zfnSy?)LrCNY@1d;UX~g;k?kAeU-9)mq_d&l(H(xL}3*816HriM8HN= zyfa})`(o}zDxgF5;E=%r*=Q{-t#T(zKmq_kmqx_1^bf&)`_b_20Nmw`N~VD7;%_3! ztAhofC8VcFKi7Zq4~RUzgD}8oa(L0~IR%hWyuCJ%X32n4O5sb^>p-v5d!oGoiqzu% z$3VARbCB02*rIx=)hqMb9rq!fUbPurez<4z4(;0l)kaezAZV`1*tsnET3b&}fk$*k z6hzg~Be5Kr{JrhSl$r4?VGm9~^#dwQk=eR4p?49T0tLb;BPVASFm1yGo_Hl+XL%2p zk4!UAEr1SXLahrG6ck$Ea&Ni4!k@P|IWbby_&M!U2h_Rz(Py{1+0 z^Yd#wBxExF;cjJbU*29o?~}w|S{QJ9=kr*`|8#e=NohYLCiZhrn0RAUDL_yEN1|HZ zR6$B_m$N*s2%4Ljx&2a0A#?+UGUCtxOk@cO33y#`dV&qrmb0xG`V`S-efwEIXU2X$ zC6KBITrX5A4|5Ym-Xz*lO--$lyqBBD8{S;aVx<9I-6_JqJ=pb>8|U@y6VU&!c$VAR z-Y)#_6F@@YRi&i{cQTTI>Em0)2O1SazQ{v9eE3jvnI0H${=c&QbKV_QO>gg}yRwJJ z$2zx%Wd)Hdy@o|SXEF;s?0%%?j@{uXboN)c{l3xPfbb{CpW5DUS&IY8xT|t# zRfd352K?JR9i9KM|6qu#3&ijcO@xlOHSXOFa0z*00KST2$ zjivzB7Xu?Bt7<0De}DJx4>0C=%N%w8`2$R{$k^SxlReuU{Eso}rHR-)N2(kqaR4X2 z-Td!>6ZWt8S3Qq|D8Y#|YHp`laj&hh5gSs+>9LJ2o9L$fqg{mI)~;bWpK{DrKusAl+WP0&UwfGtul6y42fYQ#E zSIP3lKHgiDU@YYJx@_IgZ5cD?DN|oK8@GmKM!sko##;ABa+Ri1MaFfWCr)`^bGKc# zD4Q;bYxz{9tEO&l#dKg+f=R*KtK~jpNY+7uuLvVeJ@kdu^?#~CH7e@^2RnNOELMb! zev9a11T!mZQi`;lWlvVv{7kevNoAt@uj1_9j!WI(zn>YAJdnzUS* z!_Ej`jakHg@IkQMQx(^z3s0vOxQMo3SIaq&PKA{fOn@jpnT<;)3R*t8($3~A>8BmF zopyDwKBVP)e1=mS1dRa5#b(uD`r$8M_FFG9ePVI#+iv4M3q(8OR z$zCgIobj!IMCp{i9}aW+#BLgX1u=}@H`^E9=D@f`KR9$GHB6sUGPu5ss@$+@yVSYX ze&ndlkNy48D;jaJGx07P3RTsk(UNJK(*fGb-6%Ir2vMlO0Ii1h?D({5*e^#OVAdo`pG-DAmV4pc8ZNSQE zRptdH|3B=#byU@Dw=QfvVi5)*AY#z1f`p_fDc!AfcO#)FD2gB{jntx%ZV-{~?(WV- z!}{i0yw9`uIr|-Z?DLIr{yY4I0_zv|yyrdVHLrQi+1L)_MqDyU)cnQ7=r1!~H`N|T zbnlaB*k*zbhrEnioWKi(^F&JJDLHXF62a4R$rpx>LXN7TWO$}kz<}A%5m4jB{zn`f z%AIe~=3Fz!(eAmj)|JCl(sZEQoPljVLR;*Duagm)V)cbXI z6vW8~^3~s46mE(h{PkgmOjHK$3XZ2Z0LYXvpO?um8IgL6$+@y-k>MIQi4MpuuMMCxu-0Af}ldLjiZ|U^F z$B*lfI6kJKk+x^xsO)wOM^EIug@QRt;3Z%;& zHljk8&9CWNA-A@!9ADh+baJ_KY`SCQF#gUZ+ODH`=N4bj3&c>R{Y{zoaTX9vl{=`IdV=J4QvG?`+~rmY5qQ{LHC?Y8}+N|2V~%q)Ggai_VI} zTIlRt!W!ps^L6yfgeN(-b;S0FX$exx{HBh1lkK$CTjA;w<1qS)mRU8A;!(~D)bh3os64%7yy*CF?ZAhrp)8Y6T853tlhy%nw(~X ztnR2(&Z|iS$%WQdTqcPRb!M5l_+t9KEmqfmdmgM`JCswgNt@MUx!u4z`1?|+Px^5C zrw8$CrIJgTQm)5W*}m7G*bQu4pm!~CFhot0MQOS-9CdDG^m3Jz^rUteGdnFK`$q+> zI|GR-w({hY)<(+~=9tUQze}u`^0%ttRLPC5kTBz0-J!N{H28WX!dWuMPdmJ~s%|Ha zD?X53v!P(mbU28__3R3k=LDcMi|zMTf4!wZrfhC* zdR|P!Vn|3z0%o$J`B;Swx;E$k?c090oS*%2|LfgV9>A!)BSnE>5`wtnlpr|QN=E76 z|Kx@NJ`oXAcu@*29ZVWr&{Zi4z>k7pT~{YAyKLb##y^!ONah(+&XXqDea_<12L;B< z{H+%BsXcR9gF$Q-DK2R&f{QXWn@XX)Q(FVXS|`hzc5*LGUesD0)_+u0Zk|=5+66bR zPs2pRvD2qVbal+ldE+9=WGGQeQ%JRA1KZ^|(qJthCa^GfYL|Sb?qoeGO<3M!eWpB= z9%HJNSXem4#R>?ij3>ZNF;1+}RzNtG&W6)_VG}2GEu^-7FrGFwMAD97d)>6DPehO_ zq~8D3WxEIU=$>~8=W|TQcz1ri{R3%k#rN+t0~IyBegS=)&a65EIl+*8J1r#6QT$Xc za||PqN**k%;-&uo9he^yzvAHMTa$-sdh`baT8x z_=TMB$&)7sTWyt^h31|yF>7gA6|qnDW+T>SgriFqQ;8uluR1`w+x?Q2>!JwuXVleK zl=wn){rc*}M08|i>X{aQhq;}JF6!_rKC)Ve>JOh{zx=7nNR>Mv{eweLPbDScG6ret z4%_m?vU~Q|=bFjYRr`fRBJhcQRyAm!2ASOCr@$Bzme{e;^2?APm#!43HS2Fh^Bi`Y zuqNETb<47jtZ*=5#Il-=?}Wo;rjae+?(-KfxLTVFU3N7C8@8cBQ+B*_F~DosdC6B) z^di7FI2R1PHkHg_DVH*Yx-yA(X(czS1KBaFC`}GWL3+W$R63jFeS?XH6C9E#Ory6 z*`}zZh|EBPo(SoBXV4`fd#pcAp!713T$C$>K?R(-Ma-MboSaQ?*d>RnLov>aGEU#4 zqPD6o#?bHw*6Csrh!ZQ|I@y&qVPamf2klEpO-)Ter|h&|SpQXrJvnUv(lDk~Ra&6> zLq`4SXCXE=c5_2R%EAZecciDIuz$txcoXpSkni7TzdU1RNb5t*x!g6+ijBG>{?A*Xdi9blgjJS_b$;$KSR>%V`lwqyVK-P%Nz!{F?rUaA18^&V~{OCYro_U zl(`){Ck`AOt%|Q5=(iv(T6z~oz7Wi44cmlab`{R=k67bhUVdk(s;e^?$kBrWn^A#} zP0sAenr2L_G#+{78hPoDxF8iq^fItsdqCphMfySL39nTp{`kP6@PAS&`p1I)yP6Zox*-vLO90?L z6BM-L(vX$yggVV>+A46qodw8=f?n7U!(J+;DKyG{2gZ$n04A+C!$=B0zXk=7g@pys zc9Q-2X>)%jU`rqs-4iudvi2ncv$Vm7ZC}W-68r$RIc&L)P>OL*kGO5Ry%xdJgI;&Xt4%6W_0p|Bf-j_fA*oQK~KpIabeSq^u3NidUE;YM#ZCxp3Z>lrYJPJ%gI9(F24^y8f| z7|#Uq9(?_$A}7!s+ifYSsp&x|4zuFFKHUOsJM`CoR5|~%>5jj&Tj)%LeJB=)@4WH7 zm7E)c<@_3W)_x}P8^DawAj1LsS~i@*)zX8vek?obkR3&2*Org*RIIq~uwo~m;B*o9 zw&A0&ApPeLP^)bJ`jNTCW!B;E4|T-E#i1w&z5BtnuN8uN`y?)taJEBJth{W<$p+qs zr2_GChrYe}i{|wzKOgPAo7j}E@*-OxKQ^Rg3`B5i57qL%`D_v!ll`;k{#22xToj+9 z&{9%gNy!0ntpmA5lsMbZzGWSn{`vdEhnCy1?@OceZ#AY38nH!7^A|qL|I~XxW>fK?V<2UxQp)d?PK<$7w?rBdjsBkrewWw*0w{xEk;ssdRRU_ zKE@*@NT_cH)pqY_bqO-xel7Aee4~ZiCgt92#gfLG@a24wwF}a&(Xo$eUPY8rKqIm z%O`WQ-@f}VG_5*{n|hzNPD+kCHzg%5E*B}hloYDBV?n>EXjY&U-gFXvrAl7iIZij< zm{noIqqpWo3W>I~Roq6*hryYn3Oen<3qOeb9_r6DR;R}hJ>@kywkZ%#8%WPK(@Jy5 zmnR8~ep8*T;v^J;L5yl1fR~wU{~@@#O3ftd|4tG?PeYTOoGh1Bv5K7{jK&i5L?pAO z=w|9t_1(7@M(;T^aM3yzd`=(zojPAup4y{=Jwxdda1IOkC^#h8;C5DXv(=m4G#%4V zX36h(8S3SgW%G3w>V{Q6DC*CA7stVDM~=I2Rftjub~b^TLrz9kZaML+W*`Bj#c+)J z;mSaz)A~+96|ZnSThM2hVm7N^zKiaHNkKvJYvaA`tG*v;COGnyMw~May@x#F4F8un zVVpnS!Orgs3qdHnMZlsp=O*tAq#lVR<##4``}S6s4-qjFnvI2SP73cdCLd;QE%ll8 z5#SZ&<30{0e!2gmr?=A^tJu*+@pe-Iu=miHm}3Ic=I`IXOY(GK!(xkQ#*T{QFwqJs zxC9ojc5%@#F$u(9#FDy|*Myd=Kiwr#{1&*VOg2JhZrCt7LI`MyG&7-M@mXs4V5&+TpLe+dj4 zt_%Ff3&;bg<~{9Q9xBYr$&sy!;VR#XSsqiGJt}yHS-Jo;xIQsA#}2jfx^Spr0*8WT zFAx@nku~|N@5qA(Tqk4kDOv6^Kg}aB*3XHy{#7WTiinEJSkgU3jHt!ThNq&6EgurW zb=#N07$gSGVwfK)1i4)N0N|Hm2Na`pMJ zPt?(iPt4UrA6`G1pvvWpzdJRd;@4M^@-dD25N}?^jib6yiYToivea>FJnfKVC5a`7s;NgOLMXT&=)V#2`QJC<{2z#lK6d`Odp?D;YT%zUpH>EEs#ps>ppb(` zFvZ~!(0{n>7V!xPdY}&vFqsgb($CG&`@ZV-Md5xlGid|iHlrZyPdB5S40$ju6_VdMBLVv(cs-p=y;);*`j@DTx_!tJJw)mssj zZZ4ynIFv3onr_HZ_DQ47g_RhRKj0$T88z_h#ol8e6bIo~h;~6B0f@|y4wI6Srm=>z zC*d6+d%WG>EE3ZC6|Ghbl2+zVkD%1{YCGT0*c7%MzCkLfW)#>gNy?Iw-gwZY<#1HM zW!17)N)%HfY-ijzZ1Ijc|4Z|v+8 zyQgOu;nol9k#>$9Qmy2-wRl;NjcO#+%BoVWY$;iMvj&J>HiTT1o}>>+(S%w{Y}iMFuJqL*8b@nOKnDyzN>y@BU%mYe8MT zn}mEfwTb4*ok82Cb<#Bw*F(y3>NSoso7Dm>93fBHi#4TE>P|9Y!Am6b)E9OYf8;O> z1Sa%`QYGyp7vegKBkgZJgF}vbR9^opZ_tE1+|Mu0kJoX2h0)Q_fCe&T(PC395nqg} zv>&NtC=x044G!_WneeO{72q=q+lq;aIW|Rx%EtG|AB$(PDvh2XM?UW|Aua7{oyJx7ffEyL#Q<*GHw8sy zd!O^Q#*SCclV=^hoSS^aVeT(`QIkwcogfbHDyXE0{J2jqY~Mi7JNw*o@7CuAQ^qWL z60sHr^)ga=(EWvcy%!z5UV~sWN0|ZA9^i0|lsbfl14ALvN~FlL%Vn$A4OLY%6z%U# z>VA+HQoGIy@_1)a2_ZKj4g0&B z1<-7NaL>}wJtNIzG_^&nhXLE3(GW~;jOvzn-)noih}#7;#g<6Z(Q7gaM9X-i9r zzAka;zuC1?7-Cgq7(N!(?p>(9o>mjrVLZgM!%Q1Wm8jpMNpV52@te+Ao8Na{nG9yW z@Zgh^PZ8!`T2Jmxznd`Td?n4i|BZ1u(mqRDf!*NPrnI-RTnq!>z7Cc6=(?xx^xbj{ zB^e=sHhIHvRx7Iws7(S0MTM?SMr&s6?(P;UGeDU-N1mikaLYb)+Mv??_2TsBggn>GVpa#3{=l{FoDBf;Bq}#P`AGuVpcePh^42tM$Ms@*b;L6pdZgO;H#t(Xzb$;YZtye$@s=!94Tv4<|^l5RYc7FFm z?c}6M!(LkB7JWhM5)5X63wBA>qWn6UHHZ#%%|@j-@pm%%Gx?*e;J}NVwMI91K`YX2 zyGq-4Xc=+z*MG7oICE}cn z3-zmv)bG49UAZ*fwx<2p@6ulMz5quad@;fuwO?~CcLUv3Ncn*>5%N*MB0i5IFpI>3 zhF(gFckoF;zRs~8yTp}Cn?w0dTDxvhn6sS`5B-;L$sXhU&xDLnU%jI^NaH(R&=&fg zt`SNZuJ4}FH{rOKp@s{zDpL|&MdXKZ){fp7ZZ*b47Yp7gSQtKR46T$@&VM!9gi?%L zoC-Bq?f*F!M_;ROl|q)rwA4`Xz1{U6;e!SUN;2hL;oWRGXH)eGhy5(2itS*b zbKe|dQf4Tj)4&&~PKs&6mB4QIe*gT(=yc?Dwz4kt5_o~@5q}LdNou?+mLWc!H-NwC zpW~Xra9-_2jD2w1TE%vzMeqBBQ5v8j=87n9YP5T zG{k%8AWg>r5y^az*58Ad3vrzXz@X4!!XNSO-MO@$RMvBsiOiiXMhDmUZaECy+_apM ziW!UnlS}JyE-HCiPzm_maJg%R>-K?XgyQh-RPEKZL#@G9oPA<%lfe^U!o+oJl}dJO zXbjoS_i8pCd+!?G>@prI(NGc2J^$|b;@9P=l{rdibXJurGPO09b^iVJIXd>`Z{lx1 z!}?rLZ)URQv#>7-o7<72lOfA3LPpgD)~`M}oj-S?5WL8*K@KMmshyTc4&?~b6b^T5 zJ((&K&H5^a+)*3jwKJRRpIw+rr_yHn%J;jLLXk?5#>zEEuNVfT{9ickOi}!D-46?7jgpTWoM+UnsAA5ef0g5# zw{YUUWJw-;A&fiu_udb1VlP9$!lb4Bb$ka*N6TFbJGGxbf8jvPQUCN8pWRr+$WmXp zFIE2N5MS)>`fGuS>jQO<3YfKIRMivEmoWs3Zs*=R{H}H(tjF5?o48{1=S{yw_#mjX z-8OkiCD(MBM7z|!eBLVpf2j1U&l-#AW=)z~SgB5rSW59k1A>*rw_DhLzTea%#d$_^z}d!PQ(QnzIb6g6_^(Q#GI)Z4Wmp3 zhuy2lb-c$4x9pn_EH-_zjE%+$#uD86$_r+eyzwl)XY_t?wv9G;wX}~~jpvY39Gs{z z8cUs-67Kwbb<_3yxgPJzH%H%8S@1&>mPdw)wNUL(Fzu-@PQWwvY>NjXqUNFAwK0Yr zb1oIAB1Vaei`$SOm{%roKWK{ig4od6a${lDTwRhgy)zsnkcAw1ed^m6JmeuvX zv^!SdhR?XY>*L+b^cl+48`t2v<_k0uNWB_ zlf*>tAF0+Yxu$yEvaOisRap%?lx0jypjr!7{&67H$eU1Vg?Bx}e%V~NG zH%a7N4te-0@p~7_m~O5Oos0y2J7#8LZfy}yS;_fYoI55*V%&a#=()o(h0(L$vl3sC zW`l*~P7B?gad8*4-axa;sik^&+1S~c^`t83>+9?3C3YGz%-$p|43_}BJt@3Lw~t3n{q{!wDPE0ZQmQ}8wzPXt00PK^wK%&jIW|e zzVbO87bDj2oU-^G#2sSfhcv~QfJ%qi-Z?TQQI#z-#MRYR_v45gFPHf=+OOf?zJBeR zYDNwXP2o;H*Y?8G$pG=KuLj5KwI*MYCskElYsG!q1%Q7!k$;mL=!ouVVYO%|&S;T;5po_|91NKO18 z-63IFkJWFD(H}C!v`@dxQ07~p%Gt@K7isTP%^rcGjWWYUhm~bM^D@<9y@4BP1J*U3 zDEp4$)v=Q7CXchmuAN`WoK(UvmKE|AteRitL+{iYdo3Ho77%@`u_`7*j#;}P=gAD5ian^%`T8&{|FCqa5$0v zLChn){+M*7Rsu2|dS<63k00}UaR{%AbvQI4V+IWL=~E?vnnQ^;Tah-x7$U)$*6-fl zF;&utlS>c4Qyi7F+91sf6$SK>uTTA5XMi$e$nyE6rGAcsGU5(TW}96c-lFBJr$9Z^ zJ1kbb3pGre@p;@yjgRDBh?XlZJVaVWML5h>C=6A&zC>p`9=T`?veV7=XQ-@Hv7{Ver3rQY65NQ3oHUEADvMG)UC z1AD}2V@3DN|9H9EL%~#Qpbo#ivjZGYOFKIVHJd`2EeK0p#oFGiTZ%aPHyCynD}L}B zE8Fb~=X)ahqs@Pno!2o@DE=XdL}q45+42#!xod}UHs;_{z4bQ~HVyo_dw{%A==DSR zNA!g7bD}%yL~N_u!B9$+7?(0(lxJmw+QPL%*{EzU{AxFXsnjZfA*T2cwOX;9jTA5Z z%j>T%MX!EhB7U|h^rt~RLJw3%X~p9^lx1Y@A&!HESoXj5m3%2`&z_C&&JrCema&T7 z%Usbdese;SRudi=92(%LZ2Vi*TbHb5XJePCk(t+?L!9L32>DJR{t)-}fWiLJX~m-( zT^j!Q=MS31iA%yN(g*_MckbMI>aZ$LQngBHb2ayC^2}Q~i%u8EkWj+D&J$>;AY(UI z&lmHovg}b%qRr3I!hM||e1FhOwB6GBF}XWA#%zp*(9sRFyh0xax+SLSf`SsUu7QCC zx(M|b6x$m7@P)=UKPF(4&b^7Yv(#c-0X_z3r)p)OM>mBi1nk@)aX#_5PJ}&LVXpK_ zgx}O#Bn2CLBUI1o^(PAtzxTLxow-aE#_zg|T#9JXW+VNW%JX?(7Xy_iHV#htbb$Eg z))q*4Ux#8Skn91;tNBbs@B6@Qy?>$2^$qLmI)sCII_tYRUS|p#R_Q40PSZ!F^Xe7O zhjp+D+oZi)UWZxIsZ4k&;n!XrSn!gX0idSvORBoj0|ZH5~W9 z_kWcUmh}@IW|_$8sOtnKQh%ATu({&N$4`R8!$=cCv$Bb|^kP0@nA|9t7#%gAoG2&} zes9r7{%Fv(22E)j8+vGPh!jif8rfTAzVZ5v^`UNV_GaD+H2hH_lNA@HY{EYgq!TV3 ztw~|mIUba|M@Y;*psnlnZn)SvEqujFLwkg0IA9m?uNR39mpi+9VKyYt&k@Avsv z21@Kh8w>*u=JyKbd`W-mR7Wd*o49H4C0Cijrs7Bf<2b_6X$qRD53>I-j^#6Rg>uOl z(2RheAVcz&glRkKP5x0b+Zd0VPUk_QkDT4|sN3Fq0b|bB2AznkR{5rA%{zj%+o9yN zNXn%ot_Z2t)Az!ziuqtn8ifAs|6v>Ezfa9Q zi%LqMMTQaRpCD2KqN^C%0#)m+rPT?2_afKhXu-=VXmdN$4g3joCVc{2?|}3lFcu0% z=s?aG&1Rwqv!^<_C5lY*6c79MsG`#7F-#B-F963$w10V=4S1BUUTlpzIfk4lkXmYW ztP%xXFv0pJYX#@mxVKU;ZpHvQMq9j~c+_obX!h_S)-s*oB4T-cN(vUbkyYz2Gb4v{Z+(TNn|zqF9T` zXil?IIy$5rL@t^C!=2sCRKWvv{;iEkvmL)5aypz7QIEoe82)5KUE7 zQB^&Fh8%jkyK8qTH!Rgd`(aZFcRf1w57g+FZ=%6XrQDd>LxW0&b^~ZU5`%mem-Gn( zAD@<0L1gtsJ>olkFI@XG*V?->Z@~Pj8?vr9Ev z8ued5Xv|zCP2?3)XDO=lU?NpA58V8@bP2<>Czfa=3>4$l};f689Xq+w0$6 z--*8CjYmey$b|6-<7*g@Pm0eh5@<%TRq2W*&>?9}sGMQ+G%d5qa3lqRhDYh;Zgyc7 zfI|mJ54o=&Mo^BhC_HjOTdGGAIyQRpm2%b1-u4T|b|t?5*TCq1(&Jsd!uYmpYkmEr zuP-AEC<1k|bU!HS!-%?WYD&uB&{IleJGum!zp=SV0!$HG+dRcIXh?RzCK392o0y#3 zf|W!=L*o#dy8)I82?@ajFzq>jJ4#1Dc}%>~KOjK+QP}MzP-dXeLpSQdY=E7^waO*r z0YnL>zdi{%S;-Pm5&`vS5QYMUK#f@fD+*x zlEWMnA--tD>@$GKhQRE+-$X#KXqc~n3QBF6LVv;;#%pLd3GAyIOlY!=ELMESJODue z%LZfIXr3vojG(TbUP=?xL1@7{SXqEML*M|QR@Wl*D8e`~7;^?JNPL*)m+9h5wjB{>FJf3-Bc}wT%&dKl%)pt_N_Ad$eBhQ6XdM|2xx1F+?>`6 zO@_uesF8;n`;9TQ)lM>_o_xbG3fh>X9lDC;rl5%`$(Z=$cWZK}17GP;zdy|yTcN#l z>wS>GJL>$tFamSGDxwGl8!?Z@e-n{*yl~F5__wLEk;3fGmz)=Ubuadc?$AHseVo)& z>FrfoY*;`}zl73!H}ItKwPk|CzPrnk2CLfF5S*jCRoV()+IGyhI++?S)Yk30vGY6` zA0T5Q(d$}?a(>}R!r05Bf|dJX0p<3LF^fs1`hphKv76bj+4*OJ<+i77sb^TN?4sf) z{5)R>Q^yT5vhiKU zwgpz#qcc_azx_tY#Xa>Za&UK9Yxmo6z}Z^2ao}88$`qp=bxg2T&f9G!EB@zVB_-_n z$^=s7_Nu%5zCvLgJkoAu>iIXH9;CD}J(VxpvFy2xW3x-}7 z<5gUIdt7AlF#%=Lp1o>11rbWaetFT~S5(A|f9}tJx_ozK>$ze$<03!S#H_4>r5)$S&xGH%r=7I%Jy8o@kL6oV9F4Y< z?Rn>1i`>^XHmA#c+LE5butS@hSN>&K6j3Lgm!*_edU(#WkBrjN7T)Kq+?*@FpqRE< zomo~ohnORN`OVEaf8e`)h1nYoPi}Rlg&RK~mOnRsNw@@WJ<-yp@>+h2ftn4hmg^hQ zZY8;R@4CJ2lM-^x+iX8PW5OTRS7#N>jj~W9>xi?7{v2YmyECz(saDTDx%ht^g^lJg zJXU=|PD_e<5f?;6LFktI*rJD#jLk=zMWxm?B)Nc3)A*%Ja;ai`0)f0H-%|zSiqYuZ z@%EpGZC9J<9;TX&Dmx5oAj*>*q${e*SWwbggy_3?&+uzxp3TYd5U9%+3J0wy{8QzjQS;ZOd z^}ep2b?)CNS_+0coa~G@ro+4?i;rEfyqUxp24m#hHs$)ZuW)@=t5~&6vE~|aD;E`b z4AM%G(2Ias!*J}s;|eDM@xJQ?PRkGL>QBAZ%&*T%Y3l8&dMrxdg!%xck>t~ z*J)6{q9;~x7^cGkgj2g0*3ep|@Fgvb0-e)b{oNNa zG~jTa@Wf&MJDmETdQJyY#Ww96EA82{n4l`wsS_wS=%#U~jG@0;2ty??-~68**1k7b zA$@@Snya^7FeQ(#d%+eeE$%~j8NEG1(WwP&DM1EgCX$*O@g2}-A)=Sx$1}6|l^J0@ z%h`Yo_Vb@v9wMOPJ zjHy7J@!(3U1J|oK1R^*KacUX8(6p({5E8Bm344d`BfI5(84Y?mx}47#hqsks5VmOtTosQBX`tP6pY>i?&=u zGq%eE-gHz{LLp&cDo@QoeU6317h@x5l5?H^!Udp)Jv=&1JE7L8#6$hZ+ z;Q+0rXd$2*8v_P0^oT9!M@;_k;xl1uII=5*Q4JeQ(C^NOLCe8IdU_Lhb9~3FYNtkS zr|2h6O<{BHb7%zVI>{*!q5n;{6$vLnnrK#C-4LUr0A}lIqKzR<-dc=9H zK@SUt@&x||2Pv<4B1eOkiXmvF!plpb~X*k#@rjL7^tLou8#Soq zZDyNKa@TWJi{|4#(@7FJWkZ(iT0@B@BJ=3HlqJQstb-W+%eMW$B zp`}gXUw$GCo!z}XA0i30PK5f`9_NuEwGjvAzy^ zZ(;Aa`m07pG~&FwyX7zACX`cLVq5V`p9HO)Z{MJ;THncWdtPRM_@5iNfCKHF-?Fl@ zY&X$Uv%m+as$yNZ07s)}oH9Q=YGn|y6Cn2nEs22abDA~f3{;lVMw5%8OP_@SJxx+|5@D$*R z0>oef8qCV2WGY?G86SU@@(nvKX+<2X1LlK+CRuAF=N5olpe5+{`SU*yxuii2N^WwY z`T#w_tn+CYZrPWmMSkrXG%s8fCGLKR>tj~?9{9|uGtJG-@7}!w3YstI@PK$*Col9g zNX78pP`J(KcJ!xp!Paar@7qU#MFKUtdrj~=GzC^yz(<_dSHy*NfB)u#!lCWWk@a zi{;dd&n7R6Qg(Baka(I_LbYM_(9_*tR}}h5F@F-{1;uy|IWAlPXsQ75F9sn7=5TMcz}Kod=W?1aukd=SIysyhV@IibI_C;xncaP;)`bCfQ! z#-V9%P)4|@>AaaEzi0#}Q4ZP_LZe}#B~0BD@4Y+J+|~vIDbq}yz=gtuvLHl)fZrV+ z3SsCQ{^pI|5K2KIvQD7DsHzzUcaQ@`A3Xs90g$Ki#wF9u1@G~%>wR9tXH|!xdq77f z{5%7KaAy2#JfNDLoGb!;muSyvh;}D`;VepDVAH!sYPxI!5rFwX4kc77xVTDoLOCqQ z`5{2fDw_0xYD;%_H*gC{u}^DmXCgK1N^G^k0p)Pp&P!L} zoK4Jg8f4!0_eFO9;cJYvh?k7i}Q!k=@{W29a^k-r0)FU@7(;@2nV*^PtH8IQfY#rhJNQgeA zrGLV9f@??t9}Gk@;ho;N9TXf)#^1%$(W;anggp+mxn_4fm+Lx}-@?%PSFDa(!&SSS?C zTN)Z%<*U!(-0`_=^VdPVX%sNSi27vq|Y|v^woAmnr{W~rh7n;7K7<8l!3wI@)S~UJ|h#8vFS64Ob7Vuf& zIBFcYaqSw2S;nd&6}s-QQ$qq(N&;s zbmnA+N^-maYM_MFz?!HP6`kp6nzD|U()B;_1L3(`#JZlvrKNbpkDgzFBLXzG8u{)q zF-evS)`6sJ*{YJ%DYn0tzAvlatniA!zD;?df_+D8&DT!&~**JbF}YYHNEN`^uG6#f8%1(_x;A zRiQO9{$9ykEHGL#Id+Srq?5V1f7);0NsX$DAA+Rb#fulSV6Z(zI$}H^M=NzOXPk8i z&k8Ani;7BE!uhQ79aT^skSFo21=)N%*qN2*?J;MMMmNhFE&$CbCbf=@BjMLbS`O%%b4rm&nN51L@1vpwbHdzx&|qd8|LR zwS+`O7SKKl&P?_uhy;Qgpyj9~ht3!y6>*Qx*&gu@;qbwRvVPy9mmfe)>@Qp2sK=vUg@u+O}2nK*V-8B zWEZG6Xc-<~$VOpRs3L+96lMOKKxt0z_I^%q<1fe`)$U_kSgc)TE|bovoR9kqV!C#q zH7F(3)(}XY1{BPh8xNrzC7$SdmF%f~kV8k31nI!LpsC48{2_+#ITnPU_25j-@!=`M zJX)-1N=D!qyl{{mOo3Px=|fs@!u8l}t~Tbxr8LMT(p`m-5nU*9J3avAe|ntz$Lo~h zAe&Phv-oZ}=|0Q5A80Qb&mmo`zc0q7j1pE{!y4#}L1{wwr z2E3;)?J&Og@&fPM9QN1%{3%sn0|@le=~4HP4H*SSMV*Fg?c0(1tZyS@$YBUQ2|JCQ zjpHt%it5XxMr%RaEP?#Ipsqs*qJky!JMS(MT=QA!ylf|n1!Q)Y@VhFWn+AoIgvO8a z$284$>b$-wvEIU7{CM83-bgg3B@{Kzy zZ$JKZ6As54LdkfPUhT&D#uN}O|LE7oN7C-c-oD6-S={!&qVyk$Kv)kl#URC#?7CJm z&$HokY(j`rl5R{=zjD6Q+0578mxu)|tdMUfnqxHb+l%sJY zP^7Y&1pb=i_^-1yPslta5ptgAFH0ocT}umL1Qln&XKz6)dk0;dP52dkuKUCCm++=~ zwZNCJVYz(Fe!WY;0*03C801v%-~V+uM||hb>fR1$_24DIAqyu}T7G*y)e^^%Iu#24ekx3bh2~42UZ#d>{JVgQ{2_S;zBMALm_cs;*CJtp$ zYHI2+r@1Jx5fMTVC{R>af72zw-cSvIL|Ul+GW4N9p9TAE<>FVRFLQg$^_WMe@ebTv?iC*!~I5RKk=w4J|_h zPbP;bP>r=*kqR}vcId1ukp)h9&L<~4h`W- zysnF&q?HWz_qQC-Q(*s@Y)>HgRwBO^k4Bb4K@VLe}n8-taU6P&1DowHRl;IRG527s2Nn6-7J%+q*@6|OcQ<*v89wU~O!#qj{2sOW_JCjD$c%)MvpkH#}^J1kRKEhAMqlU!MuVCyH%za~~l& zMa863^ON}t$MBMp#(BR;tgK7u|1u;b(fbN0;?m-xvAZ(cFU@$FCN9!LIq++%)***$ z^jw|CWvXpogeq|G~{%Dc&7R2h?oonp@az8uy!n z6zZq;^W#Odfep4bJ$)jt+kd@v7Dy4~Y!&T?=5X9nO{JZLf-a!76| zaaf|F^~4wQyPeY89KE$wIEMOZx^GR3NJiOCx2i9X7L3`T!_>(BYdvyX}_=0Vxy zej_RDcr(h3a^l%)e%#g^8F`|$yWjfQ#^c#SShFF=!B95n!b?d>(JE!Ci#CG#DiF}^ zmb$-@unAwQ;N{I(=w9f`*K}^^?R~<_tJ%2OmSH`k$f$O*A-w11OWG4+2HM9XmB*{4 zBQAGVT;RP3ph%=kd&L*&uHZ}I;hC72V6rZ0nwbZNEL{tr325uXB-g-AZha4CD;I|3 zh~{{7GBM%e;sPogtSj)&)7fsRL@6a8D9HBlBiV<|&y!V=HTCssr0mBG0)oOZxJ^pw z%^B5n=D~VS>+P@Ykb9qQo*%oTJvhE1aQ@=zVmwd!YM@vr(828 zJluKpjmW5PTO`k}SXxNb8`p#~Tc=p1Tz$=!a4s$`FU^vKCx*RwDWkuiS$jXyYpgeh z9<|L&B#&^Up3PhHsRO=isve2Yz{p5BD80_Nt2$J`W-K52?8D}a-hRWnpDwNYDh??- zrsm}F>;d_jVSRHmx9d(izGMtfXxRSu(7VexR|51*EY=`&2WIY1zLR7%#MiK}fsQdZ zInq3r6*d*cRQV09<3rJ^-r6MFh0bE6{lcc6inEhbN?P=tJC7?PnI=ADzkQD%U;8E4 z{c&Y@;`rJt0|TYuDuI)fG~mpwfroyp6Kj)p}sL*)S~fyH_Hl8MQZ)lDi)(ChJ=+P#tsje?og zwQ`TPzFGvbY0hvtyGV$@;SR0W#oO=kHO;z&5M3#JBD7zRPf8PR++e@u7su@^JB6Z_ zP_MMosa4AD?CRv&KiFC55#Ri5LCE~|>sL&3<{5s0xUF;YZ}0pc-(*XBysHDO5p%@gQ-E-a6k2omf{gJZpHpT_xy!dJ9&a9_ zb&%G%+ub+}%*_?kWRN)oO}Z(}D~sxdMiK+5r#IqG!)U-d-)yUa>dF4(;95R|QX^srscm!|BOoH{%LJyIEruv2E^&V8s6Je?|1WHE+=;?Dg|H@ZY{-yNr#W9B{FNk=A@9 zq_^12KAE^A)4B7uYsKfnBD)4I&+OFtclD8xhptzq((HJgfa%>jPcIrSp0w>%m`}^m|n*$jSaubBlGzt{`_={?rUqZqUWckJRmyuuejdvHN5vwce zTHr*HY4}yM0VvK3mx=MXbAJ8(t-!!Btj;uBM}V1^(+m*T5=O82j()g2OtMAgHJb#h z27{`VJ;MZyZB2$)&g%X+cU0p{)|tC>8PD^Ji+#o)0G)zu9k3S79l@Y4=2q13JQy+$ zXOiKL0{q&^>h&sen^F`Hd|3(swqVjbK+OuE;XxgNw9NCE$^Osd>|)AISiBs~Xk|rE zyt~(0Oy|a}E6snE!QEi%_KL6RD$6kNSr}`tRE!8L6S42Dco=73#wk@%0jYZn_oS$G z$1$g&t0FcGU`-zh_EhJ+dY7=gFxk}q3_X|>R+J`|LI~1J0xXM=l6!9c{sW43@{l5- z-Wt9GCT@jAMLERkssQ<4mf8&$+1G%HIZ?wytIA_$1Wb~ChU&p#y>LspXcVBKTN{$A zm&SN24L9(dbvg0hXw9-uFD2EO@7pK;^eOv?wOp!gH}2hYCbjTSRxV6~%|XWjbc-?< zKeL{`UPXvoTb)ev@j2LTe$v&JpV;d%+{hY4Pap#)JpWKI*=O~IK}jz0#q5{IkAG-b zwq@Qlj-5Q_y(H|izpE`D?MFc!WFjgP@&_PvA-!T>&L`{>S;=5IU3uw zt*x!0m;}bYPrz^-P`(K8K$zy4T)lc#T3Q-A4R&xmC2vCxo)iUmHfRW7Cu}>HYCHoz zQ1q_q#S!K%j>R|r)O*^P7=3f~iZh-p~V=r@A4fxW;-ntM*y%C*3+I^ zk8%tv^>?&q>x!{t1A`z(d~GVF5PZ!t~0ZXKbzM-ohox_Z}OI8s2&Z` z*(%90*Yu|O3%`<#j&sj+0GXeqsOKo(yPP0>#pG0F>a4c?&B}`b6 zMw}HKukaLkcX4KUlB+R_H=Cii+bCW?uhSqlE)EQCtGB_x} z_-d$OaBuBb2j)2AF@Aa0{6x_o_dqTyC0%|o>ptEPFX3{8W2i=1pdH{yNg%;53!Xtu zR0hX`r4AlESbmKp2>21_PLiEH1?SAkVTHK96f-^Vj$vg;{7!n1O|6d9N||u6!q5+8 zVq2NB1tY&JRICXK0wtxBebs%1R+^-Bh~t5ZB%E+G2GO0mMkx_L=75kQ`mPq0l$2-~ z!8^o_-09H#7p3oJSa8^ZXYdCW9riy>PSmIxF;{fVk9zKVClTt~R~4aXB*@BBt@jZR zI^-RCO_2u>Y^JJteo0A;z>^ma$G0|YNmosHfwI^9{UiA^XX-n9uhrc0vY=VNe@R<+ zIJ*T%t|INw^}~Jp7C+SQm;Rf1abMO!C7$WA>^w+`b*n_heNx3IEICe2^i~?|;`t@$ zQWzynGYlONzWD7P&62sz`;LU%#1r)Uy)|>*y{oU+sJEZ`CRuxA*R#TcYfg@lHO#B5 zrza*x^e!|CRL@&ZwVK>XirmY#j`Rv%DkVi^+?T^?2dyfF^CXeeYE41Cm41JSoE9B0Gw`f&Dfq%i_8 z%q)BMVOZJSFFNY`X5D-2TU;OIVnmv8-*)S4+LG3$?mrwH?=E-l;P)?9>Zpp7oe-4DJ6^ za?^`R2CU!EM2_gqT361&7uUDj>! z-tb>oz@l%}I!0#ct|zLsi~TbHl-mx5=>5vmwEwwQk#tESwUUSXgqSpI>@l zOfFGSRTZ5u_8Rtoq+Wl6ST)em(LuHlQFT2gaqZhDh!+HIh6!uh0GoesK>RR~+GLcx z8s-qDFIu%ITP3RDv9_|v$7*GNa)-;VW}GN9!XaG+g*)*Vqq%oui32oq2eZEn#I!~J z!p&AwoAWYkuF_YVxC-wm@-WLe#3|BF{+|^6}yrgRZ?iE2Fy1 zroRijPo-oDWA~}%+$P0i8pC3GM06lnQ$(zSb>e?{<6!s;n5Kau67ik#vRCs6x_OxcG z5wWe(l%hcE_kaJs4BGbrt5h$>M~Y@ewP>vV^5B;1A?+tx=(@<%FM6_c{>*$+n9?Fk zv2B;eS}Daw&U?AI`za>4&Ck!boUIH3Y1dfdjR(d*2Ia1H(JA#h)K9jcf6XRTn_6#Z zChW6p6&KkqY%=&7e;+GJ^likuwXl)=LCjWzTaOI8kN2zi5pN?NI67=Ns^~WL!djkJpby=Ea93~8jY62myy5St4zhqX_5Ap&+N89I#T!G;1?k1 z+Sxj^jDj3Zf0n!c;OW~<9VbXLBv)#}))$RDPKT}p4Ofs8Y1-LJB*`~!#C+};_8(m5 zW7%{$M%l8#(r%1n+L}NTDv_4ypO{E|^r-VW4WPfM@~7zz0a`T9`-TFaIo6dbCH(xF zdBIOHqR#VriBN(@Za7mJr;S8!v||>U0;i|_`BqjWSWddGbQj$jkK-sVbsQW4>>l;D z%uPVUL&E8vfxV5zU24F?8Z-qZB?SdM8mV{2|2oB}XfgD_^Op*lAN~DoV(TjF5*jlY z!`+%4eNW^;!_M)5OyoJnKUK{aqcJ3!j5AV~eG!@@$iFx;7)(E5IPZA%Dq;O-y&((& z1al(S&yJ6egT7?FuA?W}QsMT_$@62&p2|=Pkl}ms)^_?6A+${7Y#Be#h$Ht=vlG*d z+?O8z67cYm0PsTvS&HzAGUeDUxOP4CP&~wzW#;s)VT==VSqY7GAUmxNc*!U|i z*BK@)rDf>%xo)bBPTh|EXM(EmznVk-lP_j${kESxSsr8f#LC`2(K`arWWXEW%Al4W zHf15y7M2MJEZNSfcg-K$4;~lW< zx^aUMBzEJpbohR+wMC1)JX-3ya&mHDNh1;ys}N8CAGJnbx#H^uOE!(GuKY#d`Ux~l z!Y3po^uO{@%SykDL9>vS+%a>_+elq&Z0H(jdz$3$*ZdxZ9#*KpcAJAbEdOU&xY_FJP=e~ zR`#RMw;!sMy30(Ww(f|Y1Yzqo%e9ql)@DW4iI7rZ&TwSG(C^@tG&4*X=CDFKIWZi~ z;2;AwOL(X7kjFCQ00|ds^o2n-!Pq|9tU>py9?R*axEGINV-d3026cRWef4q;7Bn9U znw_^0UJ;R6uh1jk0-rcL-Zg_GOv~IstoAt_GCwh)jWY}0&fMO<_l1kiT`Bk;9=0&P za>a3OOxxr+uA6b4{AH5w9=Iiy$ z-oz28oWiQzgySW>j|d8CM0<#x^jYyIe|zO0IQGOaFG<|F?#3@$Q!f?U>dlEA-2MlP zR=%-3q0pgE#$yN0M3~nV2#uf?U=_2kXoDQM$)nKW)8m`Q_4_4U$1P7afIGv9Vq~4z zo#aKHjwD3f&!&3{EB2Exa|bh`{baMFor;ZXak-tkY^K^{Ci<-?rX=0JcMqdJI*^>W zjlAo^n~fr9j=a3-*O;>ZxTL4O@mO1yHABApbcwxv6nu(I;{~#jtU6S6sr)$8&CGgh ziflbHw)E{X&GF;;um!tE$~S}%6Wk{>9xUQcZC{p~PaaW8x=}By!UzGB`Nu@|02r95 zd*&LHOkl@;j2G9$6~HL81#il*+NTnE*KL1)1~*7C?uLXwpf;g!We{DUBx=_NQYg08hO)=x zHV_Ie-s^LoyiNHdABm7va{gJ1Ujy8QfrX_C)m5wWg^%YhlRhO^@`>3k>0Mr3991cp zo%5b02f*=BNm3)<_{|}?-sp(nJ9lCwTpp435&|DGA2PTH*ZN_kK=}-6DB7?^0Pf0K8M%oSmI9AP5&z(N(gT=ty7A{f3AH9+@$WwJZvJPxWB>H~S^i50`hWJtNZzls zm=0spU6@6#z;gISL_};pTO#95&ZJq-Rd~PQdOK_e&J~Z3K1~A;7Gc}ZQz$dMM-$wf zY(7CLe*OCO!7-=|@U|liOq`u@P;Hc#M~BH9@AD^X{X%dccWVh zYnw}~mV|8~23IJUfig4OPa1>b&54cF`0m|!gqma)0{_{wb(mMrArfj{HYac&^5#JV z3dLd6>b^@Pt}IyI28V>iTpe0joDnzdhCZYH*1dbmxV2Q{PbBB+>*=-UUOov~dgawO zyA=;edYup#*He|=SQIMX#)aVRxLM|I-hy7B{+J{`zx`m{{cu6}U(rVFzz!tJb8T5&F4BJUuC$tR5;6mRqHefoZIKtMJ zM4b+^XvG&B%>q>NjRKeO#B2)fc!g`pAXJI9(4 z0!u^#0|Qkd+ik=(4wI&+16o^dVctAEl|q89)ZF@Y2ZgtNu*wQWEpP@3dMOch1=0nO zw|JkCWc(l=q!|k$V{=Oov@%+V-9ZO$**l1OHxCY*+Tu>8p#1{;%7ZyRny4t~c4Q6% z>TD>$k{1A0P}ijTlZC}FS$11%B`!!8@=(LXcKDFPtzGB*`Cuzu3z{cdS%>v%5##Qo z-Yymv6Z@F&3m0CiQ!k}+)G*w4CwhRNJ#&A@*_nAkE*izXP)GfoQeE=K$-VKmf%xev z+>G=9vzhiqMn-Bq(IOPB04uB4s9V=C*%;WwX|T7}j}CqfdaehA%!7r|D=d$)3;B5q z_5|93;m_-mZ5!ST*>mb8+<(x|C?WxCk#lTg{X^&}tVglkgOo z9G*M!Az>LxfqJ%e>`5H&r|QYKpan+52nI(6Czz6mje;HM8QAO*Q2BK`4Fj8p|Lik7 zHS~1ZM~7{3q+{Q1Q=+)taH%B<=kQ(l%RecJ2zpS3_ugJ!a|q7$Hv{A1TKiqdRObe^ z^o`xB0stpLS@w{g zUw0{Xb-DntK9^E3zXog@r(^C*G6_2@4~QW0Ls&!OH0@?7_l)j|M^z%7dtz<_EEv$RNkz} z;}uqE*Z3Q5xT3T(GHNx_RjH~|Dt5C{Ri{&n*mmi;?n_3z)wzv|?)*=IskX!Iv#KH)Y+ z0OK3c>E+Db|AHv{ck1jvzQae~pCw||l8RV2GP0gzkd~gFDDFIuFu##RK0fB5BjX3f zyJ`lyI$(>A({ijMuF0rvy!ooIa;0$bv!Ck`4G_0^t1Ps9pHoy+OurqwQ5bgF z^c=y!z#tx83tK=LOkk}+l1GH zx2qYRKZC zm2tCd+c}~bW@gi3J3S59xE^ra*51B5ax^6tg@$4I76SB9obYR$s`&zB{aUnMFQw(o!uw73Yn+<~8 z&n!z#K|x(jZOiXv7xJPPCZ*WoAj6AIr3pV#Z`D(IJU~!13@DIpsROfW>;^ z=Nr}YH##KB=}kk4fbvn-O~ZG4EWP%&?`cp!(LNGx6CCGgcI`srVR1qNq~!4p@E#%& z6^7w#I23t)q5H+${^1M4Q;$JP0Yjqx;}41?cvi^k=Uc!#1&V*)xVd$G4mdo&Va18J zGkNOCvPg>%$<3ZBs;Lo6Cb$TgRUvz}&025x+c$33dH^}Dc+r2dOJ?b}vy05JuFd+F zmoMKFc;V1jg-nLIfah*Fs$q!GFdgFo%j)}C83xKrlpY8RX?`5s^1Ur9r}I1QFVZcz+I;l}TOj1T^Js z6ux(&3@V&QiZTmYC}I8@kKr5Ixeg=eLSWL{H7m)@zh`&OJe)u9bUf zu_K3Dgs*-&-no2t77HpwTai=J$(k${$lM`ALkU%QNec>kix2V1iBnRa(bU6HIz99K zQlaGqfB^(3aVNT-mw37;hCca6MMChMv^?Rxbl%i7l`koE8rLhNokwfu5HZkBD1I_V zgJRkQ4$5P7$t6M6ZN@y6#1Cl^_~vlmKChV^eVBq>*Hbb)=8h50pS!!G37U1E*CxJR zZ>gpS1Q<8Gz-Ico9H}a>7~q524$sb=JEvTCJ(&zbt~vWIuK_pr^#m z(NTwml8J^Z!Idi?w+AJSyXl7OI?7jgQ46dm1~u{hDTn)0HSrYY6W5{O0>j;Y!fs>i zwfg5F0n5;QWab8mSB)#M3z30P)b?BpMxpCxxI$En?sH&8N7gZn zn7&qS9|dFaUUmfmFx-c${tyO>`_#=RC+|muDYxn5M`?*X@1ZMjq8j;oRpMw3b*y`x zt|Fyeh#{41r)MYo@*Mgkv2#%)E3?EvwCl+z&a3tqkdxCE^cUHqDGVf1>WE?@zeg{w z6F?jhUaUjx_wSVQJ5sKaR4JHYjrs3tCGl(ox|;rn75Qo^hYuW7u2`NZ4;rxG+s}8= zD444)W3MTd?;_c(fz1$wF@=zeV`9oYZQD~yf+{=0vL6;?V;u-sjrAR$`fZBDin2j} z<1Ck>Y)2PTZ@!6zy#`}{ znmyRE#T@?dH8pFYIse>?8wD}FAnp@2g*!3K zlR+IQ)3B*nn3(i`?+dg1v7LJdNXTM!(jMiv~EkAcIP|c&cdI8R(JOtRStZ#|3cw0KK^V%)JdBL`p`~e4>T2%n z6a#&Z>Ksb3P$mFyYLbg^8<&^;^)6kiArG%sCKMs{@Sm!ys{?qoKmj2A8auF*;8G1- z!~8zP9bwSpmSG4M7TFtH1^Z|4AZ@SJAI?u5BBNs~Te`bn zK>XsANnQ< zbV?$A%3a;N=P`&gM7hSQWoRY9nqnR;;;jxu9fK=dNa`XWH=f1 zfcU|wkAt_&<$E-V0CP;i4GVM3c#*LP6p8}WiFzz{pzK;qtbi0YlILf-b}Sh;%g zK=1tF}Pvg@r~;3#j0W6zyOl*0G<8CyYk<&**F}CGhYx_V)Icj*W~&uQuQ1 zYa`L%w%(w)sHCfI1*Ul}-+S>iV!y1*l5}N4xG#FbaG5|mn+353uCm0ddDMH3pznFO zR8erJB5g%wC7zDyz=2CoFCNdTK+-EKTLY`PHc_a#ARqtUn(+~70Jy=pwBNGA9?N(e z8$;I&T8&^>;T|pO^gF^2A{n9S5J#{&(RQO0ql)x8EfMylmA1f%F`lWF<8-sQ&}n@O zpZXrpCJ3gms z-+&@SIdN50$9JN{8jL%r1@&+H_cF=*eT+Y%+E4cmpS@h8${Zh3zFmv-U`um}i zn;XRb+8g2GW3tyfOZRhs3J<>gEoaqt9HwOp~rBVbv z1%3qbkBy4*TM_x6nY83Tw+=I7Z^{3SOXou15`jR#onruMstrS5e8BCXmw@i@3aSJR zfhh2W4B}2W-JB*SCa5AP3d2DeGaD=@M4d;=>t!t7#X16Q%Zw~IF?Cr3GwyBXIpa54*1Q>48z zbyLiuw#S5oB3@MshpNzVLL_F51QdBki!@IH;O-dyquUtAl+UZ9j8y!M0$(=Yuo^{< z88z*tuiuTj2OUm4B7)-!Gcysnzyy)NPcMDXQwCx0LqgGJ$6?8U1}(t9TJUb5Cuj_z zlO9Ur1YxW2)V+0nFUraqtm<_XX~9&Re~2dx^L1)N_eCv}`sMuAo%hx`L|Q`lIIGn+ z-X-^I^6`{coI)xMBs-d6Ar0p*X4pQP8Da#Dtejj*N{ZCa{;gew5KADG*S&tNQ1<`D zHKYMOs5xp$7BB={U4O~%%p&L)W^P`shoVCs4@7@ByqvPz-~Y#br#DZs8J5Y$w| zwt6)&@DjkzDG#UKJ^3*KIm3L*6B>2yc}Gh{wBkhpuvmpUHIQevMQ- z%~!-2V0(cnXUitTD2}bV$g6zqSy@rIFNV9KH#72SGA?Yi=|;Kxj@PfW%5(GkUG%sF{KsZ*dYb!Mm~?cq{IZ%PxILg?M6%J7&9X}77-<6R z7$;wSm58{w2i7cTpqaYi_tYaC9I3Zop}cAPisntc#G%Q_KKKY^QIcn5#ldfpXoa_> zAl`v*aMD5Qs_uKCMhlEJU|h~Op}8o_Vkp@vhLayZMHjyMbo0E}UK6y{S(8NWP~y<1 zL%cV|@$UV56=Gj(0HVS4`(00v6oasUEoZAadSSJ)G`i$|O+oV8TjtDFl2cG_waeJT zf@?Ghqc5b{G1mg4*EjO0e(_nc-)N;>Hx~vF>f4i>R?bx9v;S)z@xM@W{P%x?-Ozt8 y!hbKq#=-r6x)Wx6@jKVo>3Wnt;*0H+>~|}bl;JfS(#cJAgrdCanOs?;TmJ(bE+%LI literal 0 HcmV?d00001 diff --git a/Playground/scripts/build-runtime.mjs b/Playground/scripts/build-runtime.mjs new file mode 100644 index 0000000..9ac99fd --- /dev/null +++ b/Playground/scripts/build-runtime.mjs @@ -0,0 +1,34 @@ +// Publishes WebRuntime in Release and copies the resulting wwwroot/* into +// Playground/public/runtime/ so Vite can serve the runner from the same origin +// as the Playground page (workers require same-origin). + +import { execSync } from 'node:child_process'; +import { rm, mkdir, cp } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const playgroundDir = resolve(__dirname, '..'); +const runtimeProject = resolve(playgroundDir, '..', 'WebRuntime', 'WebRuntime.csproj'); +const publishOut = resolve(playgroundDir, '..', 'WebRuntime', 'bin', 'Release', 'net10.0', 'publish', 'wwwroot'); +const targetDir = resolve(playgroundDir, 'public', 'runtime'); + +console.log('[build:runtime] dotnet publish', runtimeProject); +execSync(`dotnet publish "${runtimeProject}" -c Release`, { + stdio: 'inherit', +}); + +if (!existsSync(publishOut)) { + console.error(`[build:runtime] expected publish output at ${publishOut} but it does not exist.`); + process.exit(1); +} + +console.log('[build:runtime] clearing', targetDir); +await rm(targetDir, { recursive: true, force: true }); +await mkdir(targetDir, { recursive: true }); + +console.log('[build:runtime] copying', publishOut, '→', targetDir); +await cp(publishOut, targetDir, { recursive: true }); + +console.log('[build:runtime] done.'); diff --git a/Playground/scripts/check-page.mjs b/Playground/scripts/check-page.mjs new file mode 100644 index 0000000..b4e303e --- /dev/null +++ b/Playground/scripts/check-page.mjs @@ -0,0 +1,379 @@ +// Headless check of the Playground page. Captures console messages, errors, +// and key DOM signals so we can iterate without bouncing through the human. +// +// Usage: node scripts/check-page.mjs [--run] [url] [timeoutMs] + +import { chromium } from 'playwright'; + +const positional = process.argv.slice(2).filter((a) => !a.startsWith('--')); +const url = positional[0] || 'http://localhost:5311/'; +const timeoutMs = Number(positional[1] || 45000); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext(); +const page = await context.newPage(); + +const messages = []; +const pageErrors = []; + +page.on('console', (msg) => { + messages.push({ type: msg.type(), text: msg.text() }); +}); +page.on('pageerror', (err) => { + pageErrors.push({ message: err.message, stack: err.stack }); +}); +page.on('console', (msg) => { + // Already captured above; surface deep stacks via printing args too +}); +page.on('requestfailed', (req) => { + messages.push({ + type: 'requestfailed', + text: `${req.method()} ${req.url()} ${req.failure()?.errorText ?? ''}`, + }); +}); + +try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); +} catch (e) { + console.error('navigation failed:', e.message); + await browser.close(); + process.exit(2); +} + +// Wait for bootstrap to finish (success or failure). +const settled = await page + .waitForFunction( + () => { + const w = window; + const status = document.getElementById('status')?.textContent ?? ''; + if (status.startsWith('Bootstrap failed:')) { + return { kind: 'bootstrap-failed', text: status }; + } + if (w.__fadeBootstrapDone) { + return { kind: 'done', status }; + } + return false; + }, + { timeout: timeoutMs }, + ) + .then((h) => h.jsonValue()) + .catch(() => null); + +const workbenchPresent = await page.evaluate( + () => document.querySelector('.monaco-workbench') != null, +); +const editorPresent = await page.evaluate( + () => document.querySelector('.monaco-editor') != null, +); +const activityBarPresent = await page.evaluate( + () => document.querySelector('.activitybar') != null, +); +const statusBarPresent = await page.evaluate( + () => document.querySelector('.statusbar') != null, +); + +const partsPresent = await page.evaluate(() => { + const parts = { + editorPart: !!document.querySelector('.part.editor'), + sidebarPart: !!document.querySelector('.part.sidebar'), + panelPart: !!document.querySelector('.part.panel'), + statusbarPart: !!document.querySelector('.part.statusbar'), + titlebarPart: !!document.querySelector('.part.titlebar'), + welcomePage: !!document.querySelector('.editor-instance .welcome-page, .gettingStartedContainer'), + }; + return parts; +}); + +// Wait for any post-bootstrap async work to complete (setTimeouts, etc.) +// BEFORE we snapshot console messages. +if (process.argv.includes('--shot') || process.argv.includes('--run')) { + await new Promise((r) => setTimeout(r, 5000)); +} + +const editorContent = await page.evaluate(() => { + // Try to read the active editor's view-lines text content + const lines = document.querySelectorAll('.monaco-editor .view-lines .view-line'); + return Array.from(lines).map((l) => l.textContent).slice(0, 4).join('\n'); +}); + +const editorBox = await page.evaluate(() => { + const editor = document.querySelector('.monaco-editor'); + if (!editor) return null; + const r = editor.getBoundingClientRect(); + const cs = getComputedStyle(editor); + return { + width: r.width, height: r.height, + visibility: cs.visibility, display: cs.display, opacity: cs.opacity, + viewLinesCount: editor.querySelectorAll('.view-line').length, + }; +}); + +// Inspect model state via monaco from the page +const modelState = await page.evaluate(() => { + const w = window; + const m = w.monaco; + if (!m) return null; + const models = m.editor.getModels(); + return models.map((mod) => ({ + uri: mod.uri.toString(), + languageId: mod.getLanguageId(), + lineCount: mod.getLineCount(), + })); +}); +console.log('models:', JSON.stringify(modelState)); + +// Probe the workbench parts dimensions too. Each "part" is `

` +const partsSizes = await page.evaluate(() => { + const partNames = ['editor', 'sidebar', 'panel', 'auxiliarybar', 'activitybar', 'statusbar', 'titlebar', 'banner']; + return Object.fromEntries(partNames.map((name) => { + const el = document.querySelector('.part.' + name); + if (!el) return [name, null]; + const r = el.getBoundingClientRect(); + return [name, { w: Math.round(r.width), h: Math.round(r.height) }]; + })); +}); + +// And the body / workbench root +const rootSize = await page.evaluate(() => { + const wb = document.querySelector('.monaco-workbench'); + if (!wb) return null; + const r = wb.getBoundingClientRect(); + return { w: Math.round(r.width), h: Math.round(r.height) }; +}); + +const tabLabels = await page.evaluate(() => { + return Array.from(document.querySelectorAll('.tabs-container .tab .tab-label')) + .map((t) => t.textContent) + .slice(0, 8); +}); + +console.log('---'); +console.log('settled:', settled ? JSON.stringify(settled) : '(timed out)'); +console.log('workbench mounted:', workbenchPresent); +console.log('editor present:', editorPresent); +console.log('activity bar present:', activityBarPresent); +console.log('status bar present:', statusBarPresent); +console.log('workbench parts:', JSON.stringify(partsPresent)); +console.log('editor box:', JSON.stringify(editorBox)); +console.log('workbench root:', JSON.stringify(rootSize)); +console.log('parts sizes:', JSON.stringify(partsSizes)); + +// What's inside the editor part? +const editorPartHtml = await page.evaluate(() => { + const el = document.querySelector('.part.editor'); + if (!el) return null; + const r = el.getBoundingClientRect(); + const parent = el.parentElement; + const pr = parent?.getBoundingClientRect(); + return { + outerHTML: el.outerHTML.slice(0, 500), + rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + parentRect: pr ? { x: Math.round(pr.x), y: Math.round(pr.y), w: Math.round(pr.width), h: Math.round(pr.height) } : null, + parentClass: parent?.className, + }; +}); +console.log('editor part:', JSON.stringify(editorPartHtml, null, 2)); + +// Find ALL .monaco-editor instances and where they live +const monacoEditors = await page.evaluate(() => { + return Array.from(document.querySelectorAll('.monaco-editor')).map((el) => { + const r = el.getBoundingClientRect(); + const ancestors = []; + let p = el.parentElement; + while (p && ancestors.length < 6) { + const cls = p.className?.toString().slice(0, 50) || ''; + const id = p.id ? '#' + p.id : ''; + ancestors.push(p.tagName + id + (cls ? '.' + cls : '')); + p = p.parentElement; + } + return { + w: Math.round(r.width), + h: Math.round(r.height), + viewLines: el.querySelectorAll('.view-line').length, + ancestors, + }; + }); +}); +console.log('all monaco-editors:', JSON.stringify(monacoEditors, null, 2)); + +// Walk up the .part.editor chain capturing inline styles +const editorChain = await page.evaluate(() => { + const out = []; + let el = document.querySelector('.part.editor'); + while (el && out.length < 10) { + const r = el.getBoundingClientRect(); + out.push({ + tag: el.tagName, + class: el.className?.toString().slice(0, 60), + inlineStyle: el.style?.cssText?.slice(0, 200), + w: Math.round(r.width), + h: Math.round(r.height), + }); + el = el.parentElement; + } + return out; +}); +console.log('editor part chain:', JSON.stringify(editorChain, null, 2)); +console.log('open tabs:', tabLabels); +console.log('editor first lines:'); +console.log(editorContent.split('\n').map((l) => ' ' + l).join('\n')); +console.log('---'); + +const errors = messages.filter((m) => m.type === 'error' || m.type === 'requestfailed'); +if (errors.length || pageErrors.length) { + console.log('Errors (' + (errors.length + pageErrors.length) + '):'); + for (const e of errors) console.log(' [' + e.type + '] ' + e.text); + for (const e of pageErrors) { + console.log(' [pageerror] ' + e.message); + if (e.stack) { + for (const line of e.stack.split('\n').slice(0, 6)) console.log(' ' + line); + } + } +} + +const interesting = messages.filter((m) => + m.text.includes('[fade]') || + m.text.includes('[fade-lsp]') || + m.text.includes('[runtime worker]') || + m.text.includes('[lsp worker]') +); +if (interesting.length) { + console.log('Log lines (' + interesting.length + '):'); + for (const l of interesting) console.log(' [' + l.type + '] ' + l.text); +} +console.log('All log messages (last 10):'); +for (const m of messages.filter((m) => m.type === 'log' || m.type === 'info' || m.type === 'warning' || m.type === 'error').slice(-10)) { + console.log(' [' + m.type + '] ' + m.text.slice(0, 200)); +} + +const warns = messages.filter((m) => m.type === 'warning'); +if (warns.length) { + console.log('Warnings (' + warns.length + '):'); + for (const w of warns.slice(0, 8)) console.log(' ' + w.text); + if (warns.length > 8) console.log(' ... and ' + (warns.length - 8) + ' more'); +} + +// If --lsp is given, introduce a syntax error and check diagnostics flow. +// To avoid HMR-induced double-bootstrap pollution in tests, do an explicit +// page reload once we know one bootstrap completed. After reload only ONE +// bootstrap will be in play. +if (settled?.kind === 'done') { + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window).__fadeBootstrapDone, { timeout: 30000 }).catch(() => null); + await new Promise((r) => setTimeout(r, 1500)); +} + +if (process.argv.includes('--hover') && settled?.kind === 'done') { + console.log('--- triggering hover ---'); + // Wait for the file to be loaded + LSP to have processed it + await new Promise((r) => setTimeout(r, 2000)); + // Move mouse over a known position (line 1 char 5 — middle of "print") + await page.evaluate(() => { + const editor = (window).monaco.editor.getEditors()[0]; + if (editor) { + // Trigger the editor hover at a known position + editor.trigger('test', 'editor.action.showHover', { + position: { lineNumber: 1, column: 5 }, + }); + } + }); + await new Promise((r) => setTimeout(r, 1500)); + const hoverWidget = await page.evaluate(() => { + return document.querySelector('.monaco-hover')?.textContent ?? null; + }); + console.log(' hover widget text:', hoverWidget); +} + +if (process.argv.includes('--lsp') && settled?.kind === 'done') { + console.log('--- introducing a syntax error ---'); + // Use the EDITOR captured at runtime — same path our polling uses now. + const setResult = await page.evaluate(() => { + const m = window.monaco; + const editors = m.editor.getEditors(); + if (!editors.length) return { error: 'no editor' }; + // Try to find the editor with a fade model + const ed = editors.find((e) => e.getModel()?.getLanguageId() === 'fade') ?? editors[0]; + const model = ed.getModel(); + if (!model) return { error: 'no model on editor' }; + model.applyEdits([{ range: model.getFullModelRange(), text: 'this is not valid fade %@$' }]); + return { uri: model.uri.toString(), value: model.getValue().slice(0, 50), editorCount: editors.length }; + }); + console.log(' setValue result:', JSON.stringify(setResult)); + // Wait for polling LSP push + diagnostics return + await new Promise((r) => setTimeout(r, 4000)); + + const problems = await page.evaluate(() => { + const items = document.querySelectorAll('#problems-list .problem-item'); + return Array.from(items).map((el) => el.textContent); + }); + const markerCount = await page.evaluate(() => { + const m = window.monaco; + return m.editor.getModelMarkers({}).length; + }); + console.log(' marker count:', markerCount); + console.log(' problems list items:', problems.length); + for (const p of problems.slice(0, 5)) console.log(' ', p); + + // Test hover at the error location + const hover = await page.evaluate(async () => { + const m = window.monaco; + const editors = m.editor.getEditors(); + if (!editors[0]) return null; + // Just trigger a hover; we can't read the widget easily + const model = editors[0].getModel(); + if (!model) return null; + return { uri: model.uri.toString(), value: model.getValue().slice(0, 50) }; + }); + console.log(' model after edit:', JSON.stringify(hover)); +} + +// If --run is given AND bootstrap finished AND no fatal errors, click Run +// and check the output. +const shouldRun = process.argv.includes('--run'); +if (shouldRun && settled?.kind === 'done') { + console.log('--- clicking Run ---'); + await page.click('#run'); + try { + await page.waitForFunction( + () => { + const t = document.getElementById('output')?.textContent ?? ''; + return t.length > 0 && t !== '(not yet run)' && !t.startsWith('Running'); + }, + { timeout: 15000 }, + ); + } catch { + console.error(' output did not populate within 15s'); + } + const out = await page.evaluate( + () => document.getElementById('output')?.textContent ?? '', + ); + console.log(' output (first 400 chars):'); + console.log(out.slice(0, 400).split('\n').map((l) => ' ' + l).join('\n')); +} + +// How many stylesheets are adopted? +const adoptedCount = await page.evaluate(() => document.adoptedStyleSheets?.length ?? 0); +console.log('adopted stylesheets:', adoptedCount); + +// Save a screenshot for visual debugging. +if (process.argv.includes('--shot')) { + // Wait an extra moment so any late settling shows up + await new Promise((r) => setTimeout(r, 3000)); + const lateEditor = await page.evaluate(() => { + const el = document.querySelector('.monaco-editor'); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { + w: Math.round(r.width), + h: Math.round(r.height), + viewLines: el.querySelectorAll('.view-line').length, + }; + }); + console.log('editor 3s later:', JSON.stringify(lateEditor)); + await page.screenshot({ path: 'page-shot.png', fullPage: false }); + console.log('--- screenshot saved to page-shot.png ---'); +} + +await browser.close(); + +process.exit(errors.length || pageErrors.length ? 1 : 0); diff --git a/Playground/scripts/test-dap.mjs b/Playground/scripts/test-dap.mjs new file mode 100644 index 0000000..561451d --- /dev/null +++ b/Playground/scripts/test-dap.mjs @@ -0,0 +1,150 @@ +// Headless integration tests for the debug session (DAP) features. +// +// Drives the FadeRunner exposed at `window.__fadeRunnerHelpers.debug` so we +// can validate the end-to-end debug loop without faking Monaco gutter clicks +// or breakpoint glyph rendering. +// +// Usage: node scripts/test-dap.mjs + +import { chromium } from 'playwright'; + +const url = 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 1400, height: 900 } }); +const page = await context.newPage(); + +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(e)); +page.on('console', (msg) => { + if (msg.type() === 'error') console.error('[browser-error]', msg.text()); +}); + +await page.goto(url, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +// One settling reload to avoid HMR's double-bootstrap pollution. +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +await new Promise((r) => setTimeout(r, 1500)); + +async function dbg(method, params = {}) { + return await page.evaluate(({ method, params }) => { + const r = window.__fadeRunnerHelpers?.debug; + if (!r) throw new Error('debug helpers not exposed'); + return r[method](params); + }, { method, params }); +} + +const tests = []; +function test(name, fn) { tests.push({ name, fn }); } + +test('debug: starts and reports statement lines', async () => { + const source = [ + 'x = 1', + 'y = 2', + 'z = x + y', + 'print z', + ].join('\n'); + const result = await dbg('start', { source }); + if (!result?.ok) throw new Error('start failed: ' + JSON.stringify(result)); + if (!Array.isArray(result.statementLines) || result.statementLines.length === 0) + throw new Error('no statement lines reported'); + return { lines: result.statementLines }; +}); + +test('debug: breakpoint hits and stack frame visible', async () => { + const source = [ + 'x = 1', + 'y = 2', + 'z = x + y', + 'print z', + ].join('\n'); + await dbg('terminate'); + // Clear any stale event so the wait below only sees a fresh one. + await page.evaluate(() => { window.__debugLastEvent = null; }); + const start = await dbg('start', { source }); + if (!start?.ok) throw new Error('start failed: ' + JSON.stringify(start)); + // Session starts paused — set breakpoint on line 3 (`z = x + y`) and resume. + await dbg('setBreakpoints', { breakpoints: [{ line: 2, column: 0 }] }); + await dbg('continue'); + // Wait for the breakpoint to fire. + const hit = await page.waitForFunction(() => { + return window.__debugLastEvent?.type === 'REV_REQUEST_BREAKPOINT'; + }, { timeout: 8000 }).catch(() => null); + if (!hit) throw new Error('breakpoint did not fire'); + const frames = await dbg('stackFrames'); + if (!Array.isArray(frames) || frames.length === 0) throw new Error('no frames at breakpoint'); + return { topFrame: frames[0] }; +}); + +test('debug: scopes contain locals', async () => { + const frames = await dbg('stackFrames'); + if (!Array.isArray(frames) || frames.length === 0) throw new Error('no frames available'); + // Frame id is its index in the list (DebugScopeRequest.frameIndex). + const scopes = await dbg('scopes', { frameId: 0 }); + if (!scopes?.scopes?.length) throw new Error('no scopes returned'); + const flatVars = scopes.scopes.flatMap((s) => s.variables ?? []); + const x = flatVars.find((v) => v.name?.toLowerCase() === 'x'); + if (!x) throw new Error('variable x not in scope: ' + flatVars.map((v) => v.name).join(',')); + if (x.value !== '1') throw new Error('expected x = 1, got ' + x.value); + return { x: x.value }; +}); + +test('debug: eval expression', async () => { + const result = await dbg('eval', { frameId: 0, expression: 'x + y' }); + if (!result || result.failed) throw new Error('eval failed: ' + JSON.stringify(result)); + if (result.value !== '3') throw new Error('expected 3, got ' + result.value); + return result; +}); + +test('debug: step over advances to next line', async () => { + const beforeFrames = await dbg('stackFrames'); + const beforeLine = beforeFrames[0]?.lineNumber; + // Reset the captured event so we can spot the new one. + await page.evaluate(() => { window.__debugLastEvent = null; }); + await dbg('step', { kind: 'over' }); + // The session signals a step landing by ACK'ing the original step request + // with a StepNextResponseMessage (type=PROTO_ACK, status=1). That's how + // the native DAP adapter knows to fire a Stopped event for VSCode; we + // do the same recognition on the page. + await page.waitForFunction(() => { + const ev = window.__debugLastEvent; + if (!ev || ev.type !== 'PROTO_ACK' || !ev.json) return false; + try { + const parsed = JSON.parse(ev.json); + return parsed?.status === 1; + } catch { return false; } + }, { timeout: 8000 }); + const afterFrames = await dbg('stackFrames'); + if (!afterFrames.length) throw new Error('no frames after step'); + if (afterFrames[0].lineNumber === beforeLine) + throw new Error('expected line change, still on ' + beforeLine); + return { from: beforeLine, to: afterFrames[0].lineNumber }; +}); + +test('debug: terminate', async () => { + await dbg('terminate'); + return true; +}); + +let passed = 0, failed = 0; +for (const t of tests) { + process.stdout.write(`• ${t.name} ... `); + try { + const result = await t.fn(); + console.log('OK', result ? JSON.stringify(result) : ''); + passed++; + } catch (e) { + console.log('FAIL'); + console.log(' ', e.message); + failed++; + } +} + +if (pageErrors.length) { + console.log('\nPage errors during run:'); + for (const e of pageErrors) console.log(' ' + e.message); +} + +await browser.close(); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/Playground/scripts/test-lsp.mjs b/Playground/scripts/test-lsp.mjs new file mode 100644 index 0000000..4fe0921 --- /dev/null +++ b/Playground/scripts/test-lsp.mjs @@ -0,0 +1,357 @@ +// Headless integration tests for the Playground LSP features. +// +// Drives Monaco directly inside the page rather than simulating keystrokes so +// the tests are deterministic. Each test seeds a known source string, calls +// monaco's command for the feature being tested, then inspects the resulting +// widget DOM. +// +// Usage: node scripts/test-lsp.mjs [--only=name1,name2] [--url URL] + +import { chromium } from 'playwright'; + +const args = process.argv.slice(2); +const urlArg = (args.find((a) => a.startsWith('--url=')) ?? '').slice('--url='.length); +const url = urlArg || 'http://localhost:5311/'; +const onlyArg = (args.find((a) => a.startsWith('--only=')) ?? '').slice('--only='.length); +const only = onlyArg ? new Set(onlyArg.split(',').map((s) => s.trim())) : null; + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 1400, height: 900 } }); +const page = await context.newPage(); + +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(e)); +page.on('console', (msg) => { + if (msg.type() === 'error') console.error('[browser-error]', msg.text()); +}); + +await page.goto(url, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +// One settling reload to avoid HMR's double-bootstrap pollution. +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +// Let the runtime worker finish booting + the model push the initial doc. +await new Promise((r) => setTimeout(r, 2000)); + +// ─── Helpers in page context ───────────────────────────────────────────── +async function seedSource(source) { + await page.evaluate(({ source }) => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + const model = ed.getModel(); + model.applyEdits([{ range: model.getFullModelRange(), text: source }]); + }, { source }); + // Polling pushes the new doc to the LSP every 250ms; allow time for the + // lex/parse + diagnostic round-trip. + await new Promise((r) => setTimeout(r, 800)); +} + +async function setCursor(line, column) { + await page.evaluate(({ line, column }) => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + ed.setPosition({ lineNumber: line, column }); + ed.focus(); + }, { line, column }); +} + +// Dismiss any open suggest / hover / signature widgets between tests so the +// next test isn't reading state left behind by the previous one. +async function dismissWidgets() { + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + await new Promise((r) => setTimeout(r, 150)); + // Belt-and-braces: hide the hover explicitly via Monaco's API. + await page.evaluate(() => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + ed?.getContribution?.('editor.contrib.hover')?.hideContentHover?.(); + }); +} + +async function triggerCommand(command) { + await page.evaluate(({ command }) => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + ed.trigger('test', command, null); + }, { command }); +} + +// Pull a snapshot of the completion suggest widget after triggering it. +async function getCompletions() { + await triggerCommand('editor.action.triggerSuggest'); + // Suggest widget builds asynchronously; poll for visible items. + for (let i = 0; i < 30; i++) { + const items = await page.evaluate(() => { + const rows = document.querySelectorAll('.monaco-editor .suggest-widget .monaco-list-row'); + return Array.from(rows).map((r) => { + const label = r.querySelector('.label-name .monaco-icon-label-container .monaco-icon-name-container .label-name')?.textContent + ?? r.querySelector('.label-name')?.textContent + ?? r.textContent; + return label?.trim(); + }).filter(Boolean); + }); + if (items.length > 0) return items; + await new Promise((r) => setTimeout(r, 100)); + } + return []; +} + +async function getHoverText() { + await triggerCommand('editor.action.showHover'); + for (let i = 0; i < 30; i++) { + const text = await page.evaluate(() => { + const hover = document.querySelector('.monaco-editor .monaco-hover'); + return hover?.textContent?.trim() ?? null; + }); + if (text) return text; + await new Promise((r) => setTimeout(r, 100)); + } + return null; +} + +async function getSignatureHelp() { + await triggerCommand('editor.action.triggerParameterHints'); + for (let i = 0; i < 30; i++) { + const sig = await page.evaluate(() => { + const w = document.querySelector('.monaco-editor .parameter-hints-widget'); + if (!w || w.classList.contains('hidden') || !w.classList.contains('visible')) return null; + const sigLine = w.querySelector('.signature')?.textContent?.trim(); + const activeParam = w.querySelector('.signature .parameter.active')?.textContent?.trim(); + const docs = w.querySelector('.docs')?.textContent?.trim(); + return { signature: sigLine, activeParam, docs }; + }); + if (sig?.signature) return sig; + await new Promise((r) => setTimeout(r, 100)); + } + return null; +} + +// Direct LSP probes via the runtime worker — bypass the editor UI entirely so +// we can validate the Core handlers even before they're wired through Monaco +// providers. Uses globally-exposed helpers from main.ts (set later). +async function lspProbe(method, params) { + return await page.evaluate(({ method, params }) => { + const probe = window.__fadeLspProbe; + if (!probe) throw new Error('__fadeLspProbe not exposed'); + return probe(method, params); + }, { method, params }); +} + +async function workerCall(method, params = {}) { + return await page.evaluate(({ method, params }) => { + const helpers = window.__fadeRunnerHelpers; + if (!helpers) throw new Error('__fadeRunnerHelpers not exposed'); + return helpers[method](params); + }, { method, params }); +} + +// ─── Tests ─────────────────────────────────────────────────────────────── +const tests = []; +function test(name, fn) { tests.push({ name, fn }); } + +test('lsp-direct: completion at start of fresh statement', async () => { + // Completion in LSPUtil fires when the cursor sits at a statement-start + // position. Newline + space ensures leftToken is EndStatement so the + // GetStatementCompletions branch fires. + await seedSource('print "hi"\n '); + const items = await lspProbe('completion', { line: 1, character: 1 }); + if (!items.length) throw new Error('no completions'); + const hasPrint = items.some((i) => i.label?.toLowerCase().startsWith('print')); + if (!hasPrint) throw new Error('no print completion: ' + items.slice(0, 5).map(i => i.label).join(',')); + return { count: items.length, sample: items.slice(0, 3).map((i) => i.label) }; +}); + +test('ui: completion widget surfaces at fresh statement', async () => { + await seedSource('print "hi"\n '); + await dismissWidgets(); + await setCursor(2, 2); // 1-based line 2, column 2 = after the space + const items = await getCompletions(); + if (!items.length) throw new Error('no completions returned'); + const hasPrint = items.some((i) => i.toLowerCase().startsWith('print')); + if (!hasPrint) throw new Error('expected a "print*" completion, got: ' + items.slice(0, 10).join(', ')); + return { items: items.slice(0, 5) }; +}); + +test('ui: hover token info for "print"', async () => { + await seedSource('print "hi"\n'); + await dismissWidgets(); + await setCursor(1, 3); + const text = await getHoverText(); + if (!text) throw new Error('no hover widget surfaced'); + return { text: text.slice(0, 80) }; +}); + +test('ui: hover error message on bad token', async () => { + await seedSource('this is not valid fade %@$\n'); + await dismissWidgets(); + await setCursor(1, 24); // inside %@$ + const text = await getHoverText(); + if (!text) throw new Error('no hover widget for error'); + if (!/error/i.test(text)) throw new Error('hover did not mention "error": ' + text.slice(0, 100)); + return { text: text.slice(0, 120) }; +}); + +test('ui: signature-help built-in command', async () => { + await seedSource('print upper$("hi")\n'); + await dismissWidgets(); + // column after `upper$(` — name has 7 chars from col 7-12, paren at 13, cursor at 14 + await setCursor(1, 14); + const sig = await getSignatureHelp(); + if (!sig) throw new Error('no signature widget'); + return sig; +}); + +test('lsp-direct: signature help', async () => { + await seedSource('print upper$("hi")\n'); + const r = await lspProbe('signature-help', { line: 0, character: 13 }); + if (!r) throw new Error('no signature returned from worker'); + if (!r.signatures || r.signatures.length === 0) throw new Error('no signatures: ' + JSON.stringify(r)); + return { label: r.signatures[0].label }; +}); + +test('lsp-direct: references on use site', async () => { + await seedSource('x = 1\ny = x + 2\nprint x\n'); + // Click on the `x` on line 1 (the use, not the declaration). + const r = await lspProbe('references', { line: 1, character: 4 }); + if (!r || !Array.isArray(r)) throw new Error('expected array, got ' + JSON.stringify(r)); + if (r.length < 2) throw new Error('expected >= 2 references, got ' + r.length + ': ' + JSON.stringify(r)); + return { count: r.length }; +}); + +test('lsp-direct: references on def site', async () => { + await seedSource('x = 1\ny = x + 2\nprint x\n'); + const r = await lspProbe('references', { line: 0, character: 0 }); + if (!r || !Array.isArray(r)) throw new Error('expected array, got ' + JSON.stringify(r)); + if (r.length < 2) throw new Error('expected >= 2 references from def site, got ' + r.length + ': ' + JSON.stringify(r)); + return { count: r.length }; +}); + +test('lsp-direct: goto-def for variable', async () => { + await seedSource('x = 1\nprint x\n'); + const r = await lspProbe('goto-def', { line: 1, character: 6 }); + if (!r) throw new Error('no definition returned'); + if (r.range?.start?.line !== 0) throw new Error('expected definition on line 0, got ' + JSON.stringify(r)); + return r; +}); + +test('lsp-direct: hover shows rich docs for built-in command', async () => { + await seedSource('print "hello"\n'); + // Hover anywhere inside the `print` command — line 0, char 2. + const r = await lspProbe('hover', { line: 0, character: 2 }); + if (!r) throw new Error('no hover returned'); + if (!/### print/.test(r.contents)) throw new Error('missing command header: ' + r.contents.slice(0, 120)); + // Built-in `print` has at least one parameter — the docs path should + // include a Parameters section. + if (!/Parameters/.test(r.contents)) throw new Error('missing Parameters section: ' + r.contents.slice(0, 200)); + return { contents: r.contents.slice(0, 80) }; +}); + +test('lsp-direct: hover renders trivia for function as markdown', async () => { + // Trivia is the contiguous block of comment lines immediately before + // a function/declaration. Hover over the call-site name to verify. + await seedSource([ + "` Greets the user politely.", + "` Returns a friendly greeting.", + "function greet(name as string)", + " exitfunction \"hi \" + name", + "endfunction \"\"", + "", + "msg$ = greet(\"world\")", + ].join('\n')); + // Hover on `greet` of the call (last line, 0-indexed line 6, the `g` at col 7) + const r = await lspProbe('hover', { line: 6, character: 7 }); + if (!r) throw new Error('no hover returned'); + if (!/greet/.test(r.contents)) throw new Error('hover missing function header: ' + r.contents); + if (!/Greets the user/i.test(r.contents)) throw new Error('hover missing trivia: ' + r.contents); + return { contents: r.contents.slice(0, 120) }; +}); + +test('lsp-direct: document symbols lists function + label', async () => { + await seedSource([ + "function foo()", + "endfunction \"\"", + ":mylabel", + "print 1", + ].join('\n')); + const r = await lspProbe('document-symbols', {}); + if (!Array.isArray(r) || r.length === 0) throw new Error('no symbols: ' + JSON.stringify(r)); + const names = r.map((s) => s.name); + if (!names.includes('foo')) throw new Error('missing fn symbol: ' + names.join(',')); + return { names }; +}); + +test('lsp-direct: folding ranges for if-block', async () => { + // Block-form `if ... endif` requires NO `then` after the condition. + // The `then` form is one-liner-only per ParseIfStatement. + await seedSource([ + "if 1 = 1", + " print \"a\"", + " print \"b\"", + "endif", + ].join('\n')); + const r = await lspProbe('folding-ranges', {}); + if (!Array.isArray(r) || r.length === 0) throw new Error('no folds returned'); + const span = r.find((x) => x.endLine - x.startLine >= 2); + if (!span) throw new Error('no multi-line fold: ' + JSON.stringify(r)); + return { count: r.length, first: r[0] }; +}); + +test('lsp-direct: format emits TokenFormatter edits', async () => { + // Messy indent should produce at least one whitespace edit. + await seedSource("if 1 = 1 then\n print \"a\"\nendif\n"); + const r = await lspProbe('format', { options: { tabSize: 2, insertSpaces: true, casing: 0 } }); + if (!Array.isArray(r)) throw new Error('format did not return array: ' + JSON.stringify(r)); + return { count: r.length, sample: r[0] }; +}); + +test('lsp-direct: rename produces a workspace edit', async () => { + await seedSource('foo = 1\nbar = foo + 2\n'); + const r = await lspProbe('rename', { line: 0, character: 0, newName: 'baz' }); + if (!r?.changes) throw new Error('no rename edit returned: ' + JSON.stringify(r)); + const allEdits = Object.values(r.changes).flat(); + if (allEdits.length < 2) throw new Error('expected >= 2 edits, got ' + allEdits.length); + if (!allEdits.every((e) => e.newText === 'baz')) throw new Error('edit text wrong: ' + JSON.stringify(allEdits)); + return { edits: allEdits.length }; +}); + +test('tests: list+run integration', async () => { + // Fade test syntax uses bare names: `test foo` / `endtest`. + const source = [ + "test addsone", + " assert 1 + 1 = 2", + "endtest", + "test failsonpurpose", + " assert 1 = 0", + "endtest", + ].join('\n'); + const list = await workerCall('listTests', { source }); + if (!Array.isArray(list) || list.length !== 2) throw new Error('expected 2 tests, got ' + JSON.stringify(list)); + const run = await workerCall('runTests', { source }); + if (run.passed !== 1 || run.failed !== 1) { + throw new Error('expected 1 pass / 1 fail, got ' + JSON.stringify({ p: run.passed, f: run.failed, err: run.error })); + } + return { passed: run.passed, failed: run.failed, names: list.map((t) => t.name) }; +}); + +// ─── Run ───────────────────────────────────────────────────────────────── +let passed = 0; +let failed = 0; +for (const t of tests) { + if (only && !only.has(t.name)) continue; + process.stdout.write(`• ${t.name} ... `); + try { + const result = await t.fn(); + console.log('OK', result ? JSON.stringify(result) : ''); + passed++; + } catch (e) { + console.log('FAIL'); + console.log(' ', e.message); + failed++; + } +} + +if (pageErrors.length) { + console.log('\nPage errors during run:'); + for (const e of pageErrors) console.log(' ' + e.message); +} + +await browser.close(); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/Playground/scripts/test-tests-panel.mjs b/Playground/scripts/test-tests-panel.mjs new file mode 100644 index 0000000..fa8a4dd --- /dev/null +++ b/Playground/scripts/test-tests-panel.mjs @@ -0,0 +1,192 @@ +// Headless tests for the Tests-panel improvements: +// - search filter narrows the visible list +// - failureFrames flow through the bridge and onto the run result +// - inline test-log writes failure rows +// - editor "Run Test at Cursor" / "Debug Test at Cursor" actions resolve +// the surrounding test from cursor position +// +// Usage: node scripts/test-tests-panel.mjs + +import { chromium } from 'playwright'; + +const url = process.argv[2] || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 1400, height: 900 } }); +const page = await context.newPage(); + +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(e)); + +await page.goto(url, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +// Settling reload to avoid HMR double-bootstrap noise. +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// The Tests panel is hidden inside dockview when another tab is active. +// Activate it explicitly so Playwright sees the inputs as visible. +async function activateTestsPanel() { + await page.evaluate(() => { + const api = window.__fadeDockview; + const p = api?.getPanel?.('tests'); + if (p) p.api.setActive(); + }); + await page.waitForSelector('#tests-search', { state: 'visible', timeout: 5000 }); +} +await activateTestsPanel(); + +const TEST_SOURCE = [ + 'test addsone', + ' assert 1 + 1 = 2', + 'endtest', + 'test failsonpurpose', + ' assert 1 = 0', + 'endtest', + 'test anotherpass', + ' assert 2 + 2 = 4', + 'endtest', +].join('\n'); + +async function seedSource(source) { + await page.evaluate(({ source }) => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + const m = ed.getModel(); + m.applyEdits([{ range: m.getFullModelRange(), text: source }]); + }, { source }); + // Wait for the 400ms refreshDebounce + a margin. + await new Promise((r) => setTimeout(r, 1200)); +} + +const tests = []; +function test(name, fn) { tests.push({ name, fn }); } + +test('bridge: failureFrames present on failing test result', async () => { + const run = await page.evaluate(({ source }) => { + return window.__fadeRunnerHelpers.runTests({ source }); + }, { source: TEST_SOURCE }); + if (run.failed !== 1) throw new Error('expected 1 failure, got ' + run.failed); + const fail = run.results.find((r) => !r.passed); + if (!fail) throw new Error('no failing result returned'); + if (!Array.isArray(fail.failureFrames)) throw new Error('failureFrames missing on failing result'); + if (fail.failureFrames.length === 0) throw new Error('failureFrames is empty'); + const f = fail.failureFrames[0]; + if (typeof f.lineNumber !== 'number') throw new Error('frame.lineNumber missing'); + return { frames: fail.failureFrames.length, line0: f.lineNumber }; +}); + +test('ui: search filter narrows the visible test list', async () => { + await seedSource(TEST_SOURCE); + // Discover happens on doc-push; wait for the list to populate. + await page.waitForFunction(() => document.querySelectorAll('#tests-list .test-item').length >= 3, { timeout: 8000 }); + const beforeCount = await page.locator('#tests-list .test-item').count(); + await page.fill('#tests-search', 'fail'); + await new Promise((r) => setTimeout(r, 100)); + const afterCount = await page.locator('#tests-list .test-item').count(); + const afterName = (await page.locator('#tests-list .test-name').first().textContent()) || ''; + await page.fill('#tests-search', ''); + if (beforeCount < 3) throw new Error('expected >=3 tests before filter, got ' + beforeCount); + if (afterCount !== 1) throw new Error('expected 1 test after filter, got ' + afterCount); + if (!/fail/i.test(afterName)) throw new Error('filtered name should mention "fail": ' + afterName); + return { beforeCount, afterCount }; +}); + +test('ui: running a test writes a row into the inline test log', async () => { + await seedSource(TEST_SOURCE); + await page.waitForFunction(() => document.querySelectorAll('#tests-list .test-item').length >= 3, { timeout: 8000 }); + // Click the Run button on the failing test row. + const items = page.locator('#tests-list .test-item'); + const count = await items.count(); + let clickedFail = false; + for (let i = 0; i < count; i++) { + const row = items.nth(i); + const name = (await row.locator('.test-name').textContent()) || ''; + if (/failsonpurpose/i.test(name)) { + await row.locator('vscode-button', { hasText: 'Run' }).click(); + clickedFail = true; + break; + } + } + if (!clickedFail) throw new Error('failing row not found'); + await page.waitForFunction( + () => document.querySelectorAll('#tests-log .tests-log-line.fail').length > 0, + { timeout: 8000 }, + ); + const lines = await page.evaluate(() => + Array.from(document.querySelectorAll('#tests-log .tests-log-line')).map((l) => l.textContent), + ); + const hasFailLine = lines.some((l) => /failsonpurpose/i.test(l)); + if (!hasFailLine) throw new Error('inline log missing fail row: ' + JSON.stringify(lines)); + return { lines: lines.length }; +}); + +test('ui: failure-frame link in inline log jumps editor', async () => { + // The previous test populated the log; rerun all to be safe. + await page.evaluate(() => document.getElementById('tests-log-clear').click()); + await page.locator('#tests-run-all').click(); + await page.waitForFunction( + () => document.querySelectorAll('#tests-log .test-failure-frame').length > 0, + { timeout: 12000 }, + ); + // Move cursor far away first so we can verify the click moves it. + await page.evaluate(() => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + ed.setPosition({ lineNumber: 1, column: 1 }); + }); + const frameEl = page.locator('#tests-log .test-failure-frame').first(); + await frameEl.click(); + const newLine = await page.evaluate(() => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + return ed.getPosition().lineNumber; + }); + // Failing assert is on line 5 (1-based) of TEST_SOURCE. + if (newLine === 1) throw new Error('editor cursor did not move (still at line 1)'); + return { newLine }; +}); + +test('editor action: "Run Test at Cursor" runs the surrounding test', async () => { + await seedSource(TEST_SOURCE); + await page.waitForFunction(() => document.querySelectorAll('#tests-list .test-item').length >= 3, { timeout: 8000 }); + // Cursor inside `anotherpass` (line 8). + await page.evaluate(() => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + ed.setPosition({ lineNumber: 8, column: 1 }); + ed.focus(); + }); + // Clear the log to make the result observable. + await page.evaluate(() => document.getElementById('tests-log-clear').click()); + // Trigger via Monaco action API (no need to actually right-click). + await page.evaluate(() => { + const ed = window.monaco.editor.getEditors().find((e) => e.getModel()?.getLanguageId() === 'fade'); + return ed.getAction('fade.runTestAtCursor').run(); + }); + await page.waitForFunction( + () => Array.from(document.querySelectorAll('#tests-log .tests-log-line.pass')) + .some((l) => /anotherpass/i.test(l.textContent)), + { timeout: 12000 }, + ); + return { ok: true }; +}); + +let passed = 0, failed = 0; +for (const t of tests) { + process.stdout.write(`• ${t.name} ... `); + try { + const r = await t.fn(); + console.log('OK', r ? JSON.stringify(r) : ''); + passed++; + } catch (e) { + console.log('FAIL'); + console.log(' ', e.message); + failed++; + } +} + +if (pageErrors.length) { + console.log('\nPage errors during run:'); + for (const e of pageErrors) console.log(' ' + e.message); +} + +await browser.close(); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/Playground/src/main.ts b/Playground/src/main.ts new file mode 100644 index 0000000..87f2abf --- /dev/null +++ b/Playground/src/main.ts @@ -0,0 +1,3060 @@ +// Playground: Monaco editor + custom shell with OPFS-backed file list and tabs. +// +// Layout: +// ┌─ header (title, status, heartbeat, Run) ────────────────────┐ +// ├─ sidebar ┬─ tabs ──────────────────────────────────────────┤ +// │ files │ main.fbasic [x] other.fbasic [x] │ +// │ ├─────────────────────────────────────────────────┤ +// │ │ Monaco editor │ +// │ │ │ +// ├─ Output ─┴─────────────────────────────────────────────────┤ +// │ ... program output ... │ +// └────────────────────────────────────────────────────────────┘ + +import * as monaco from 'monaco-editor'; +import 'vscode/localExtensionHost'; +import { initialize as initServices } from '@codingame/monaco-vscode-api'; + +// vscode-look web components used in the bottom panel + header. +import '@vscode-elements/elements/dist/vscode-badge'; +import '@vscode-elements/elements/dist/vscode-button'; +import '@vscode-elements/elements/dist/vscode-icon'; + +// Dockview: dockable / resizable panel layout. We use the vanilla-JS +// flavor (dockview-core). The page itself acts as the framework +// adapter via `createComponent`, returning
s pulled out of the +// hidden #panel-cells pool. +import { createDockview } from 'dockview-core'; +import type { DockviewApi, SerializedDockview } from 'dockview-core'; +import 'dockview-core/dist/styles/dockview.css'; +// Codicons stylesheet URL — required for glyphs. The +// vscode-elements library specifically looks for a element with +// id="vscode-codicon-stylesheet"; a Vite-injected @@ -844,6 +1200,8 @@

Fade Playground

+ + Loading… @@ -899,7 +1257,19 @@

Fade Playground

-
(not yet run)
+
+
+ Output + + +
+
+
(not yet run)
+
+
@@ -1008,6 +1378,27 @@

Fade Playground

+ + + + diff --git a/Playground/package-lock.json b/Playground/package-lock.json index de1160b..e98a923 100644 --- a/Playground/package-lock.json +++ b/Playground/package-lock.json @@ -36,6 +36,7 @@ "@codingame/monaco-vscode-working-copy-service-override": "^32.0.2", "@vscode-elements/elements": "^2.5.1", "dockview-core": "^6.3.0", + "marked": "^14.1.4", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@^32.0.2", "vscode": "npm:@codingame/monaco-vscode-extension-api@^32.0.2" }, @@ -65,6 +66,18 @@ "marked": "14.0.0" } }, + "node_modules/@codingame/monaco-vscode-api/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@codingame/monaco-vscode-base-service-override": { "version": "32.0.2", "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-base-service-override/-/monaco-vscode-base-service-override-32.0.2.tgz", @@ -1360,9 +1373,9 @@ } }, "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", + "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==", "license": "MIT", "bin": { "marked": "bin/marked.js" diff --git a/Playground/package.json b/Playground/package.json index 04b4b53..0e51a8f 100644 --- a/Playground/package.json +++ b/Playground/package.json @@ -39,6 +39,7 @@ "@codingame/monaco-vscode-working-copy-service-override": "^32.0.2", "@vscode-elements/elements": "^2.5.1", "dockview-core": "^6.3.0", + "marked": "^14.1.4", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@^32.0.2", "vscode": "npm:@codingame/monaco-vscode-extension-api@^32.0.2" }, diff --git a/Playground/public/fade.schema.json b/Playground/public/fade.schema.json new file mode 100644 index 0000000..b32891a --- /dev/null +++ b/Playground/public/fade.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fade.dev/schema/fade.json", + "title": "Fade Project (fade.json)", + "description": "Project manifest for a Fade Playground workspace. Replaces csproj inside the Playground. The file is required at the root of every project folder and may be edited, but cannot be deleted or renamed by the UI.", + "type": "object", + "required": ["name", "type", "sources"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Display name for the project.", + "minLength": 1 + }, + "author": { + "type": "string", + "description": "Optional author / owner string." + }, + "type": { + "type": "string", + "description": "Project flavor. Only 'web' is supported today.", + "enum": ["web"] + }, + "commandDlls": { + "type": "array", + "description": "Names of .NET command assemblies the project wants loaded. Currently accepted but not enforced; the WebRuntime cannot dynamically load DLLs.", + "items": { "type": "string" }, + "default": [] + }, + "sources": { + "type": "array", + "description": "Ordered list of .fbasic source files belonging to this project. ORDER MATTERS — sources are concatenated in this order before compilation, matching the native Fade SDK build path. Each entry is a workspace-relative filename.", + "items": { + "type": "string", + "pattern": "^[\\w.\\-/]+\\.(fbasic|fb)$" + }, + "minItems": 1 + } + } +} diff --git a/Playground/scripts/test-project.mjs b/Playground/scripts/test-project.mjs new file mode 100644 index 0000000..6c34bfa --- /dev/null +++ b/Playground/scripts/test-project.mjs @@ -0,0 +1,647 @@ +// Phase 2 integration probes: fade.json validation, project source concat, +// locked manifest semantics, header label, and Problems integration. +// +// Usage: node scripts/test-project.mjs + +import { chromium } from 'playwright'; + +const url = process.argv[2] || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 1500, height: 950 } }); +const page = await context.newPage(); + +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(e)); + +// Reset OPFS between runs so flake from previous tests can't leak. +await page.goto(url, { waitUntil: 'domcontentloaded' }); +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry('workspace', { recursive: true }); } catch { /* ignore */ } +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +// Settling reload to avoid HMR double-bootstrap pollution. +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +await new Promise((r) => setTimeout(r, 1500)); + +async function readFadeJson() { + return await page.evaluate(async () => { + const m = window.monaco.editor.getModels().find((m) => m.uri.toString().endsWith('/fade.json')); + return m ? m.getValue() : null; + }); +} +async function writeFadeJson(json) { + await page.evaluate(({ json }) => { + const m = window.monaco.editor.getModels().find((m) => m.uri.toString().endsWith('/fade.json')); + if (m) m.applyEdits([{ range: m.getFullModelRange(), text: json }]); + }, { json }); + await new Promise((r) => setTimeout(r, 1200)); +} + +const tests = []; +function test(name, fn) { tests.push({ name, fn }); } + +test('boot: fade.json synthesized in default project folder', async () => { + const text = await readFadeJson(); + if (!text) throw new Error('no fade.json model present'); + const obj = JSON.parse(text); + if (obj.type !== 'web') throw new Error('type should be "web", got ' + obj.type); + if (!Array.isArray(obj.sources) || obj.sources.length === 0) { + throw new Error('sources should be a non-empty array'); + } + return { name: obj.name, sources: obj.sources }; +}); + +test('header shows the active project name', async () => { + const label = (await page.locator('#project-name').textContent()) || ''; + if (!label.trim()) throw new Error('project-name label is empty'); + return { label: label.trim() }; +}); + +test('default project lists main.fbasic + fade.json in file list', async () => { + const names = await page.locator('#file-list li').evaluateAll((els) => + els.map((e) => (e.dataset.name || e.textContent || '').trim().split('\n')[0]), + ); + if (!names.some((n) => /fade\.json/.test(n))) throw new Error('fade.json missing from file list: ' + names.join(',')); + if (!names.some((n) => /main\.fbasic/.test(n))) throw new Error('main.fbasic missing: ' + names.join(',')); + return { names }; +}); + +test('fade.json shows a lock badge in the file list', async () => { + const hasLock = await page.locator('#file-list li.manifest .file-lock').count(); + if (hasLock === 0) throw new Error('lock indicator missing on manifest row'); + return { hasLock }; +}); + +test('schema error in fade.json surfaces in Problems', async () => { + await writeFadeJson('{ "name": "demo", "type": "native", "sources": ["main.fbasic"] }'); + // Activate Problems so the items are visible. + await page.evaluate(() => window.__fadeDockview?.getPanel?.('problems')?.api?.setActive?.()); + await new Promise((r) => setTimeout(r, 400)); + const probTexts = await page.locator('#problems-list .problem-item').evaluateAll((els) => + els.map((e) => e.textContent || ''), + ); + const hasTypeIssue = probTexts.some((t) => /type/.test(t) && /web/.test(t)); + if (!hasTypeIssue) throw new Error('Expected a "type" enum error in Problems: ' + JSON.stringify(probTexts)); + return { count: probTexts.length }; +}); + +test('valid fade.json clears the schema problems', async () => { + await writeFadeJson('{ "name": "demo", "type": "web", "sources": ["main.fbasic"] }'); + await new Promise((r) => setTimeout(r, 400)); + const probTexts = await page.locator('#problems-list .problem-item').evaluateAll((els) => + els.map((e) => e.textContent || ''), + ); + const stillBroken = probTexts.some((t) => /fade\.json/.test(t)); + if (stillBroken) throw new Error('Schema errors should be gone: ' + JSON.stringify(probTexts)); + return { ok: true }; +}); + +test('multi-file source: getProjectSource concats in fade.json order', async () => { + // Seed main.fbasic via Monaco model edit; create util.fbasic through + // the new dropdown → inline-create flow so it lands in OPFS + tabs. + await page.evaluate(() => { + const main = window.monaco.editor.getModels().find((m) => m.uri.toString().endsWith('/main.fbasic')); + main.applyEdits([{ range: main.getFullModelRange(), text: 'print "A"\n' }]); + }); + await createFileViaDropdown('util.fbasic'); + await page.evaluate(() => { + const m = window.monaco.editor.getModels().find((m) => m.uri.toString().endsWith('/util.fbasic')); + m.applyEdits([{ range: m.getFullModelRange(), text: 'print "B"\n' }]); + }); + await new Promise((r) => setTimeout(r, 800)); // let save timers flush + + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic","util.fbasic"] }'); + const forward = await page.evaluate(() => window.__fadeRunnerHelpers.project.getSource()); + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["util.fbasic","main.fbasic"] }'); + const reverse = await page.evaluate(() => window.__fadeRunnerHelpers.project.getSource()); + + const idxA_fwd = forward.indexOf('"A"'); + const idxB_fwd = forward.indexOf('"B"'); + const idxA_rev = reverse.indexOf('"A"'); + const idxB_rev = reverse.indexOf('"B"'); + if (idxA_fwd === -1 || idxB_fwd === -1 || idxA_rev === -1 || idxB_rev === -1) { + throw new Error('Both prints should appear in both concats. forward=' + JSON.stringify(forward) + ' reverse=' + JSON.stringify(reverse)); + } + if (!(idxA_fwd < idxB_fwd)) throw new Error('Forward order should be A then B: ' + JSON.stringify(forward)); + if (!(idxB_rev < idxA_rev)) throw new Error('Reverse order should be B then A: ' + JSON.stringify(reverse)); + return { forward: forward.replace(/\s+/g, ' ').slice(0, 60), reverse: reverse.replace(/\s+/g, ' ').slice(0, 60) }; +}); + +test('schema errors push Monaco markers on fade.json (squiggles)', async () => { + await writeFadeJson('{ "name":"demo", "type":"native", "sources":[] }'); + await new Promise((r) => setTimeout(r, 600)); + const markers = await page.evaluate(() => { + const uri = window.monaco.Uri.file('/workspace/fade.json'); + const model = window.monaco.editor.getModel(uri); + if (!model) return null; + return window.monaco.editor.getModelMarkers({ resource: uri }).map((m) => ({ + owner: m.owner, severity: m.severity, message: m.message, + line: m.startLineNumber, col: m.startColumn, + })); + }); + if (!Array.isArray(markers) || markers.length === 0) { + throw new Error('expected Monaco markers on fade.json, got: ' + JSON.stringify(markers)); + } + const fromConfig = markers.filter((m) => m.owner === 'fade-config'); + if (fromConfig.length === 0) throw new Error('expected fade-config markers'); + const hasTypeMarker = fromConfig.some((m) => /type/.test(m.message)); + if (!hasTypeMarker) throw new Error('expected a "type" enum marker: ' + JSON.stringify(fromConfig)); + return { count: fromConfig.length }; +}); + +test('valid fade.json clears error-severity markers', async () => { + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic"] }'); + await new Promise((r) => setTimeout(r, 600)); + // Filter to error-severity only — orphan-source warnings may still + // legitimately exist if other .fbasic files were left behind by an + // earlier test in this run. + const errors = await page.evaluate(() => { + const uri = window.monaco.Uri.file('/workspace/fade.json'); + return window.monaco.editor.getModelMarkers({ resource: uri }) + .filter((m) => m.owner === 'fade-config' && m.severity === window.monaco.MarkerSeverity.Error); + }); + if (errors.length !== 0) throw new Error('error markers should be empty: ' + JSON.stringify(errors)); + return { ok: true }; +}); + +test('cross-check: missing source file produces an error', async () => { + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic","does_not_exist.fbasic"] }'); + await new Promise((r) => setTimeout(r, 700)); + const errors = await page.evaluate(() => { + const uri = window.monaco.Uri.file('/workspace/fade.json'); + return window.monaco.editor.getModelMarkers({ resource: uri }) + .filter((m) => m.owner === 'fade-config' && m.severity === window.monaco.MarkerSeverity.Error) + .map((m) => m.message); + }); + if (!errors.some((m) => /does_not_exist/.test(m))) { + throw new Error('expected an error mentioning the missing file: ' + JSON.stringify(errors)); + } + return { count: errors.length }; +}); + +test('cross-check: orphan .fbasic produces NO diagnostic', async () => { + // Only main is listed; util exists in OPFS. We intentionally do NOT + // warn — unlisted source files are a normal iteration pattern. The + // dash badge in the file list carries that signal already. + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic"] }'); + await new Promise((r) => setTimeout(r, 700)); + const markers = await page.evaluate(() => { + const uri = window.monaco.Uri.file('/workspace/fade.json'); + return window.monaco.editor.getModelMarkers({ resource: uri }) + .filter((m) => m.owner === 'fade-config') + .map((m) => ({ sev: m.severity, msg: m.message })); + }); + if (markers.some((m) => /not listed in sources/.test(m.msg))) { + throw new Error('orphan warning should NOT exist: ' + JSON.stringify(markers)); + } + return { markerCount: markers.length }; +}); + +test('print output does not duplicate after run completes', async () => { + await page.evaluate(() => { + const main = window.monaco.editor.getModels().find((m) => m.uri.toString().endsWith('/main.fbasic')); + main.applyEdits([{ range: main.getFullModelRange(), text: 'print "alpha"\nprint "beta"\n' }]); + }); + await new Promise((r) => setTimeout(r, 800)); + await page.evaluate(() => document.getElementById('output-clear')?.click()); + await page.evaluate(() => window.__fadeDockview?.getPanel?.('output')?.api?.setActive?.()); + await new Promise((r) => setTimeout(r, 200)); + await page.evaluate(() => { + const btns = Array.from(document.querySelectorAll('vscode-button, button')); + const run = btns.find((b) => /Run \(/.test(b.textContent || '')); + if (run) run.click(); + }); + await page.waitForFunction( + () => Array.from(document.querySelectorAll('#output .output-line')) + .some((l) => /beta/.test(l.textContent)), + { timeout: 10000 }, + ); + // Wait a beat to allow any (incorrect) duplicate emit to land. + await new Promise((r) => setTimeout(r, 800)); + const lines = await page.evaluate(() => + Array.from(document.querySelectorAll('#output .output-line')) + .map((l) => (l.textContent || '').trim()) + .filter(Boolean), + ); + const alphaCount = lines.filter((l) => /alpha/.test(l)).length; + const betaCount = lines.filter((l) => /beta/.test(l)).length; + if (alphaCount !== 1 || betaCount !== 1) { + throw new Error('Expected one "alpha" and one "beta" line, got: ' + JSON.stringify(lines)); + } + return { lines }; +}); + +test('file list shows numeric source badge for listed .fbasic files', async () => { + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic","util.fbasic"] }'); + await new Promise((r) => setTimeout(r, 600)); + const badges = await page.locator('#file-list li[data-name$=".fbasic"] .source-badge') + .evaluateAll((els) => els.map((e) => ({ + file: e.parentElement?.dataset.name, + text: (e.textContent || '').trim(), + listed: e.classList.contains('listed'), + orphan: e.classList.contains('orphan'), + }))); + const main = badges.find((b) => b.file === 'main.fbasic'); + const util = badges.find((b) => b.file === 'util.fbasic'); + if (!main || main.text !== '1' || !main.listed) throw new Error('main badge wrong: ' + JSON.stringify(main)); + if (!util || util.text !== '2' || !util.listed) throw new Error('util badge wrong: ' + JSON.stringify(util)); + return { badges }; +}); + +test('orphan .fbasic shows dash badge', async () => { + // Drop util from sources; util.fbasic still exists in OPFS → orphan. + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic"] }'); + await new Promise((r) => setTimeout(r, 600)); + const badge = await page.locator('#file-list li[data-name="util.fbasic"] .source-badge').first(); + const cls = await badge.getAttribute('class'); + const text = (await badge.textContent() || '').trim(); + if (!/orphan/.test(cls || '')) throw new Error('expected orphan class: ' + cls); + if (text !== '–' && text !== '-') throw new Error('expected dash, got: ' + text); + return { cls }; +}); + +// Helper: open the file-row right-click menu and click the matching item. +async function clickFileContextItem(fileName, itemPattern) { + const row = page.locator(`#file-list li[data-name="${fileName}"]`); + await row.dispatchEvent('contextmenu', {}); + await page.waitForSelector('.source-badge-menu[data-menu="file-context"]', { timeout: 3000 }); + await page.evaluate((rx) => { + const re = new RegExp(rx); + const item = Array.from(document.querySelectorAll('.source-badge-menu .source-badge-item')) + .find((el) => re.test(el.textContent || '')); + item?.click(); + }, itemPattern); +} + +test('right-click → "Add to sources (end)" appends to fade.json', async () => { + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic"] }'); + await new Promise((r) => setTimeout(r, 600)); + await clickFileContextItem('util.fbasic', 'Add to sources \\(end\\)'); + await page.waitForFunction(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/fade.json')); + return /"util\.fbasic"/.test(m?.getValue() || ''); + }, { timeout: 5000 }); + await new Promise((r) => setTimeout(r, 500)); + const text = await page.locator('#file-list li[data-name="util.fbasic"] .source-badge').textContent(); + if ((text || '').trim() !== '2') throw new Error('util badge should be "2", got: ' + text); + return { ok: true }; +}); + +test('right-click → "Remove from sources" rewrites fade.json', async () => { + await clickFileContextItem('util.fbasic', 'Remove from sources'); + await page.waitForFunction(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/fade.json')); + try { + const obj = JSON.parse(m?.getValue() || ''); + return Array.isArray(obj.sources) && !obj.sources.includes('util.fbasic'); + } catch { return false; } + }, { timeout: 5000 }); + await new Promise((r) => setTimeout(r, 500)); + const cls = await page.locator('#file-list li[data-name="util.fbasic"] .source-badge').getAttribute('class'); + if (!/orphan/.test(cls || '')) throw new Error('expected orphan after removal, got: ' + cls); + return { cls }; +}); + +test('right-click → "Go to fade.json" reveals the source line', async () => { + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic","util.fbasic"] }'); + await new Promise((r) => setTimeout(r, 600)); + await clickFileContextItem('util.fbasic', 'Go to fade\\.json'); + await new Promise((r) => setTimeout(r, 500)); + const position = await page.evaluate(() => { + const ed = window.monaco.editor.getEditors().find((e) => /fade\.json/.test(e.getModel()?.uri.toString() || '')); + if (!ed) return null; + const p = ed.getPosition(); + const line = ed.getModel()?.getLineContent(p.lineNumber) || ''; + return { line, lineNumber: p.lineNumber, column: p.column }; + }); + if (!position) throw new Error('editor not on fade.json'); + if (!/util\.fbasic/.test(position.line)) { + throw new Error('expected cursor on util.fbasic source entry: ' + JSON.stringify(position)); + } + return position; +}); + +test('right-click → Rename moves the file + rewrites sources', async () => { + // util.fbasic exists + is listed; rename to helper.fbasic. + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic","util.fbasic"] }'); + await new Promise((r) => setTimeout(r, 600)); + page.once('dialog', (d) => d.accept('helper.fbasic')); + await clickFileContextItem('util.fbasic', 'Rename'); + await page.waitForFunction(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/helper.fbasic')); + return !!m; + }, { timeout: 5000 }); + // Wait for the file list re-render (it happens after the model swap). + await page.waitForFunction( + () => Array.from(document.querySelectorAll('#file-list li')).some((l) => l.dataset.name === 'helper.fbasic'), + { timeout: 5000 }, + ); + await new Promise((r) => setTimeout(r, 400)); + // Old name gone, new name present. + const names = await page.locator('#file-list li').evaluateAll((els) => + els.map((e) => e.dataset.name).filter(Boolean), + ); + if (names.includes('util.fbasic')) throw new Error('util.fbasic should be gone after rename'); + if (!names.includes('helper.fbasic')) throw new Error('helper.fbasic should be present'); + // fade.json sources updated. + const sources = await page.evaluate(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/fade.json')); + try { return JSON.parse(m?.getValue() || '').sources; } catch { return []; } + }); + if (!sources.includes('helper.fbasic') || sources.includes('util.fbasic')) { + throw new Error('sources should reflect rename: ' + JSON.stringify(sources)); + } + return { names, sources }; +}); + +test('right-click → Delete removes file + entry from sources', async () => { + // helper.fbasic from previous test; delete it. + page.once('dialog', (d) => d.accept()); // confirm + await clickFileContextItem('helper.fbasic', 'Delete'); + await page.waitForFunction(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/helper.fbasic')); + return !m; + }, { timeout: 5000 }); + const names = await page.locator('#file-list li').evaluateAll((els) => + els.map((e) => e.dataset.name).filter(Boolean), + ); + if (names.includes('helper.fbasic')) throw new Error('helper.fbasic should be gone'); + const sources = await page.evaluate(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/fade.json')); + try { return JSON.parse(m?.getValue() || '').sources; } catch { return []; } + }); + if (sources.includes('helper.fbasic')) { + throw new Error('sources should not list deleted file: ' + JSON.stringify(sources)); + } + return { names, sources }; +}); + +test('right-click on fade.json shows no menu (locked)', async () => { + const row = page.locator('#file-list li[data-name="fade.json"]'); + await row.dispatchEvent('contextmenu', {}); + await new Promise((r) => setTimeout(r, 300)); + const count = await page.locator('.source-badge-menu[data-menu="file-context"]').count(); + if (count !== 0) throw new Error('fade.json should not open a file-context menu'); + return { ok: true }; +}); + +// Drive the new dropdown + inline-create flow. Picks the .fbasic entry, +// types the requested name, and presses Enter. +async function createFileViaDropdown(name) { + await page.locator('#new-file').click(); + await page.waitForSelector('.source-badge-menu[data-menu="file-context"]', { timeout: 3000 }); + await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('.source-badge-menu .source-badge-item')); + const ext = name => /(\.fbasic|\.fb)$/i; + // Always pick the first item that matches the requested extension. + // Fall back to first item if no match. + const item = items[0]; + item?.click(); + }); + await page.waitForSelector('#file-list li.file-edit-row input', { timeout: 3000 }); + await page.fill('#file-list li.file-edit-row input', name); + await page.keyboard.press('Enter'); + await page.waitForFunction( + (n) => Array.from(document.querySelectorAll('#file-list li')) + .some((l) => l.dataset.name === n), + name, + { timeout: 5000 }, + ); +} + +test('new .fbasic file auto-appends to fade.json sources (via dropdown)', async () => { + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic"] }'); + await new Promise((r) => setTimeout(r, 600)); + await createFileViaDropdown('autoadded.fbasic'); + await page.waitForFunction(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/fade.json')); + return /"autoadded\.fbasic"/.test(m?.getValue() || ''); + }, { timeout: 5000 }); + const sources = await page.evaluate(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/fade.json')); + return JSON.parse(m.getValue()).sources; + }); + if (sources[sources.length - 1] !== 'autoadded.fbasic') { + throw new Error('new fbasic should land at end of sources: ' + JSON.stringify(sources)); + } + return { sources }; +}); + +test('new-file dropdown: invalid name silently discards the row', async () => { + await page.locator('#new-file').click(); + await page.waitForSelector('.source-badge-menu[data-menu="file-context"]', { timeout: 3000 }); + await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('.source-badge-menu .source-badge-item')); + items[3]?.click(); // .txt + }); + await page.waitForSelector('#file-list li.file-edit-row input', { timeout: 3000 }); + // Invalid name (contains a space) — silent discard expected. + await page.fill('#file-list li.file-edit-row input', 'bad name.txt'); + await page.keyboard.press('Enter'); + await new Promise((r) => setTimeout(r, 400)); + const rowGone = await page.locator('#file-list li.file-edit-row').count(); + if (rowGone !== 0) throw new Error('edit row should be removed after invalid Enter'); + const persisted = await page.locator('#file-list li[data-name="bad name.txt"]').count(); + if (persisted !== 0) throw new Error('invalid name should not be saved'); + return { ok: true }; +}); + +test('new-file dropdown: Escape cancels without writing anything', async () => { + const beforeCount = await page.locator('#file-list li').count(); + await page.locator('#new-file').click(); + await page.waitForSelector('.source-badge-menu[data-menu="file-context"]', { timeout: 3000 }); + await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('.source-badge-menu .source-badge-item')); + items[0]?.click(); // .fbasic + }); + await page.waitForSelector('#file-list li.file-edit-row input', { timeout: 3000 }); + await page.keyboard.press('Escape'); + await new Promise((r) => setTimeout(r, 300)); + const afterCount = await page.locator('#file-list li').count(); + if (afterCount !== beforeCount) { + throw new Error('Escape should not add a row, before=' + beforeCount + ' after=' + afterCount); + } + return { beforeCount, afterCount }; +}); + +test('right-click workspace empty area opens new-file dropdown', async () => { + // Trigger contextmenu on the workspace pane host (outside any file row). + await page.evaluate(() => { + const host = document.querySelector('.sidebar-host'); + const rect = host.getBoundingClientRect(); + const ev = new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.bottom - 10, + }); + host.dispatchEvent(ev); + }); + const menuOpen = await page.locator('.source-badge-menu[data-menu="file-context"]').count(); + if (menuOpen !== 1) throw new Error('workspace empty-area right-click should open the dropdown'); + // Close + leave clean state. + await page.keyboard.press('Escape'); + await new Promise((r) => setTimeout(r, 200)); + return { ok: true }; +}); + +test('reload respects fade.json source order for default-opened file', async () => { + // Create a second .fbasic via the dropdown, then promote it to be + // the only listed source. After reload, the editor should land on + // it (not on the alphabetically-first file). + const alphaExists = await page.locator('#file-list li[data-name="alpha.fbasic"]').count(); + if (!alphaExists) { + await createFileViaDropdown('alpha.fbasic'); + } + await page.evaluate(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/alpha.fbasic')); + m.applyEdits([{ range: m.getFullModelRange(), text: 'print "alpha is special"\n' }]); + }); + await new Promise((r) => setTimeout(r, 800)); + // Edit fade.json via the open flow so the save listener fires. + await page.locator('#file-list li[data-name="fade.json"]').click(); + await new Promise((r) => setTimeout(r, 200)); + await page.evaluate(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/fade.json')); + m.applyEdits([{ + range: m.getFullModelRange(), + text: '{ "name":"demo", "type":"web", "sources":["alpha.fbasic"] }', + }]); + }); + await new Promise((r) => setTimeout(r, 1200)); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); + await new Promise((r) => setTimeout(r, 1500)); + const active = await page.evaluate(() => { + const eds = window.monaco.editor.getEditors(); + return eds[0]?.getModel()?.uri.toString(); + }); + if (!/alpha\.fbasic/.test(active || '')) throw new Error('expected alpha.fbasic as default, got: ' + active); + // And it should be tokenized as fade. + const hasFadeTokens = await page.evaluate(() => + Array.from(document.querySelectorAll('.monaco-editor .view-lines .view-line span')) + .some((s) => Array.from(s.classList).some((c) => c.startsWith('fade-token-'))), + ); + if (!hasFadeTokens) throw new Error('alpha.fbasic should have fade syntax highlighting'); + return { active }; +}); + +test('switching to a not-yet-opened source tab shows highlighting immediately', async () => { + // Make sure util.fbasic exists (earlier tests may have renamed/deleted it). + const utilExists = await page.locator('#file-list li[data-name="util.fbasic"]').count(); + if (!utilExists) { + await createFileViaDropdown('util.fbasic'); + } + // Create util.fbasic with content that contains tokens we can recognize + // (keyword `print`, string literal). Then make sure main.fbasic stays + // active. The first activation of util.fbasic must already carry the + // semantic-token decorations — no edit required. + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic","util.fbasic"] }'); + await new Promise((r) => setTimeout(r, 600)); + await page.evaluate(() => { + const m = window.monaco.editor.getModels().find((mm) => mm.uri.toString().endsWith('/util.fbasic')); + m.applyEdits([{ range: m.getFullModelRange(), text: 'print "highlight me"\n' }]); + }); + // Switch the active tab back to main.fbasic. + await page.locator('#file-list li[data-name="main.fbasic"]').click(); + await new Promise((r) => setTimeout(r, 500)); + // Now click util.fbasic — this is the "first time activated" scenario. + await page.locator('#file-list li[data-name="util.fbasic"]').click(); + // Brief settle so applySemanticTokens has a chance to run. + await new Promise((r) => setTimeout(r, 600)); + const tokenInfo = await page.evaluate(() => { + // Look at the visible view-lines: every line should have at least + // one fade-token-* class somewhere if semantic tokens applied. + const spans = Array.from(document.querySelectorAll('.monaco-editor .view-lines .view-line span')); + const classes = new Set(); + for (const s of spans) for (const c of s.classList) classes.add(c); + const fadeClasses = Array.from(classes).filter((c) => c.startsWith('fade-token-')); + return { fadeClasses, activeUri: window.monaco.editor.getEditors()[0]?.getModel()?.uri.toString() }; + }); + if (!/util\.fbasic/.test(tokenInfo.activeUri || '')) { + throw new Error('expected util.fbasic active, got: ' + tokenInfo.activeUri); + } + if (tokenInfo.fadeClasses.length === 0) { + throw new Error('util.fbasic should have semantic-token decorations on first open, got: ' + JSON.stringify(tokenInfo)); + } + return tokenInfo; +}); + +test('closing then reopening a fbasic tab does not throw', async () => { + const utilExists = await page.locator('#file-list li[data-name="util.fbasic"]').count(); + if (!utilExists) { + await createFileViaDropdown('util.fbasic'); + } + // Make sure util.fbasic exists and is openable. + await writeFadeJson('{ "name":"demo", "type":"web", "sources":["main.fbasic","util.fbasic"] }'); + await new Promise((r) => setTimeout(r, 400)); + // Open util.fbasic from the file list, then close its tab. + await page.locator('#file-list li[data-name="util.fbasic"]').click(); + await new Promise((r) => setTimeout(r, 200)); + await page.evaluate(() => { + const tab = Array.from(document.querySelectorAll('.tab')).find((t) => /util\.fbasic/.test(t.textContent || '')); + tab?.querySelector('.close')?.click(); + }); + await new Promise((r) => setTimeout(r, 200)); + // Capture page errors during the next open attempt. + const errs = []; + const handler = (e) => errs.push(e.message); + page.on('pageerror', handler); + await page.locator('#file-list li[data-name="util.fbasic"]').click(); + await new Promise((r) => setTimeout(r, 400)); + page.off('pageerror', handler); + const dupErr = errs.find((m) => /already exists/.test(m)); + if (dupErr) throw new Error('reopen still crashes: ' + dupErr); + // util.fbasic should be active again. + const activeTab = await page.evaluate(() => { + const active = document.querySelector('.tab.active'); + return active?.textContent || ''; + }); + if (!/util\.fbasic/.test(activeTab)) throw new Error('reopened tab should be active: ' + activeTab); + return { activeTab }; +}); + +test('lock: typing "fade.json" into the inline-create row silently discards', async () => { + await page.locator('#new-file').click(); + await page.waitForSelector('.source-badge-menu[data-menu="file-context"]', { timeout: 3000 }); + await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('.source-badge-menu .source-badge-item')); + items[2]?.click(); // .json + }); + await page.waitForSelector('#file-list li.file-edit-row input', { timeout: 3000 }); + await page.fill('#file-list li.file-edit-row input', 'fade.json'); + await page.keyboard.press('Enter'); + await new Promise((r) => setTimeout(r, 400)); + // No second fade.json should be written. Count fade.json rows: still + // exactly the original one (the manifest), edit row gone. + const count = await page.locator('#file-list li[data-name="fade.json"]').count(); + if (count !== 1) throw new Error('fade.json count should stay at 1, got ' + count); + const rowGone = await page.locator('#file-list li.file-edit-row').count(); + if (rowGone !== 0) throw new Error('edit row should be removed'); + return { count }; +}); + +let passed = 0, failed = 0; +for (const t of tests) { + process.stdout.write(`• ${t.name} ... `); + try { + const r = await t.fn(); + console.log('OK', r ? JSON.stringify(r) : ''); + passed++; + } catch (e) { + console.log('FAIL'); + console.log(' ', e.message); + failed++; + } +} + +if (pageErrors.length) { + console.log('\nPage errors during run:'); + for (const e of pageErrors) console.log(' ' + e.message); +} + +await browser.close(); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/Playground/scripts/test-projects-overlay.mjs b/Playground/scripts/test-projects-overlay.mjs new file mode 100644 index 0000000..533286f --- /dev/null +++ b/Playground/scripts/test-projects-overlay.mjs @@ -0,0 +1,144 @@ +// Phase 3 probes: project viewer overlay + project switching. +// Verifies create / switch flows + that fade.json + main.fbasic +// land in fresh projects with the expected content. +// +// Usage: node scripts/test-projects-overlay.mjs + +import { chromium } from 'playwright'; + +const url = process.argv[2] || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ viewport: { width: 1500, height: 950 } }); +const page = await ctx.newPage(); + +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(e)); + +// Wipe OPFS so probes start clean. +await page.goto(url, { waitUntil: 'domcontentloaded' }); +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry('workspace', { recursive: true }); } catch { /* ignore */ } + localStorage.removeItem('fade.activeProject'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 60000 }); +await new Promise((r) => setTimeout(r, 1500)); + +const tests = []; +function test(name, fn) { tests.push({ name, fn }); } + +test('header button opens overlay', async () => { + await page.locator('#open-projects').click(); + await page.waitForSelector('#project-overlay:not([hidden])', { timeout: 3000 }); + const rows = await page.locator('#project-list .project-row').count(); + if (rows < 1) throw new Error('overlay should list at least one project'); + return { rows }; +}); + +test('default project is marked active', async () => { + const active = await page.locator('#project-list .project-row.active').textContent(); + if (!/default/.test(active || '')) throw new Error('default project should be active: ' + active); + return { active: (active || '').trim().slice(0, 30) }; +}); + +test('Esc closes the overlay', async () => { + await page.keyboard.press('Escape'); + await page.waitForFunction(() => document.getElementById('project-overlay')?.hidden === true, { timeout: 3000 }); + return { ok: true }; +}); + +test('⌘P/Ctrl+P opens the overlay', async () => { + await page.keyboard.press('ControlOrMeta+p'); + await page.waitForSelector('#project-overlay:not([hidden])', { timeout: 3000 }); + return { ok: true }; +}); + +test('creating a new project seeds fade.json + main.fbasic and switches', async () => { + await page.locator('#project-new-input').fill('demoproj'); + await page.locator('#project-new-input').press('Enter'); + // switchToProject reloads — wait for bootstrap to finish on the new + // project, then verify state. + await page.waitForFunction(() => window.__fadeBootstrapDone === true && /demoproj/.test(document.getElementById('project-name')?.textContent || ''), { timeout: 30000 }); + await new Promise((r) => setTimeout(r, 1000)); + const label = (await page.locator('#project-name').textContent() || '').trim(); + if (!/demoproj/.test(label)) throw new Error('header should show demoproj, got: ' + label); + // File list must contain fade.json + main.fbasic. + const names = await page.locator('#file-list li').evaluateAll((els) => + els.map((e) => (e.dataset.name || e.textContent || '').trim().split('\n')[0]), + ); + if (!names.some((n) => /fade\.json/.test(n))) throw new Error('fade.json missing in new project: ' + names.join(',')); + if (!names.some((n) => /main\.fbasic/.test(n))) throw new Error('main.fbasic missing in new project: ' + names.join(',')); + return { label, names }; +}); + +test('overlay lists both projects after the new one was created', async () => { + await page.locator('#open-projects').click(); + await page.waitForSelector('#project-overlay:not([hidden])', { timeout: 3000 }); + // Wait for the lazy meta resolution. + await new Promise((r) => setTimeout(r, 600)); + const rows = await page.locator('#project-list .project-row').evaluateAll((els) => + els.map((e) => (e.querySelector('.project-row-name')?.textContent || '').trim()), + ); + if (!rows.some((n) => /default/.test(n))) throw new Error('default missing in overlay: ' + rows.join(',')); + if (!rows.some((n) => /demoproj/.test(n))) throw new Error('demoproj missing in overlay: ' + rows.join(',')); + return { rows }; +}); + +test('switching back to default project works', async () => { + // Click the default row. + await page.evaluate(() => { + const rows = Array.from(document.querySelectorAll('#project-list .project-row')); + const row = rows.find((r) => /default/.test(r.querySelector('.project-row-name')?.textContent || '')); + row?.click(); + }); + await page.waitForFunction(() => window.__fadeBootstrapDone === true && /default/.test(document.getElementById('project-name')?.textContent || ''), { timeout: 30000 }); + const label = (await page.locator('#project-name').textContent() || '').trim(); + if (!/default/.test(label)) throw new Error('header should show default, got: ' + label); + return { label }; +}); + +test('duplicate project name is rejected', async () => { + await page.locator('#open-projects').click(); + await page.waitForSelector('#project-overlay:not([hidden])', { timeout: 3000 }); + await page.locator('#project-new-input').fill('demoproj'); + await page.locator('#project-new-input').press('Enter'); + await new Promise((r) => setTimeout(r, 300)); + const errVisible = await page.locator('#project-new-error:not([hidden])').count(); + if (!errVisible) throw new Error('expected duplicate-name error to be visible'); + const errText = (await page.locator('#project-new-error').textContent() || '').trim(); + await page.keyboard.press('Escape'); + if (!/already exists/i.test(errText)) throw new Error('error wording should mention "already exists": ' + errText); + return { errText }; +}); + +test('forceHardReset() is exposed on window', async () => { + const isFn = await page.evaluate(() => typeof window.forceHardReset === 'function'); + if (!isFn) throw new Error('window.forceHardReset should be a function'); + return { ok: true }; +}); + +let passed = 0, failed = 0; +for (const t of tests) { + process.stdout.write(`• ${t.name} ... `); + try { + const r = await t.fn(); + console.log('OK', r ? JSON.stringify(r) : ''); + passed++; + } catch (e) { + console.log('FAIL'); + console.log(' ', e.message); + failed++; + } +} + +if (pageErrors.length) { + console.log('\nPage errors during run:'); + for (const e of pageErrors) console.log(' ' + e.message); +} + +await browser.close(); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/Playground/src/fade-config.ts b/Playground/src/fade-config.ts new file mode 100644 index 0000000..facb975 --- /dev/null +++ b/Playground/src/fade-config.ts @@ -0,0 +1,346 @@ +// Schema-mirroring TypeScript types for the playground's fade.json manifest. +// The canonical schema is shipped at public/fade.schema.json so external +// editors can reference it; this file keeps the in-page validator in sync. +// +// Keep these mirrored: if you add a field to fade.schema.json, add it here +// (and to the validator) — the playground doesn't use a JSON Schema runtime +// to keep the bundle small. + +export const FADE_JSON_NAME = 'fade.json'; + +export type FadeProjectType = 'web'; + +export interface FadeProject { + name: string; + author?: string; + type: FadeProjectType; + commandDlls?: string[]; + sources: string[]; +} + +// Validation outcome — either a parsed/typed config or a list of errors +// pinned to JSON pointer paths so the Problems panel can attach decorations +// to specific lines once we add deeper editor integration. +export interface FadeConfigError { + path: string; // JSON-pointer-ish, e.g. "sources[2]" or "type" + message: string; + severity: 'error' | 'warning'; + // Optional source range, filled in after the locator runs over the + // text. Drives Monaco squiggles and click-to-jump in Problems. + range?: { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + }; +} + +export interface FadeConfigParseResult { + ok: boolean; + project?: FadeProject; + errors: FadeConfigError[]; +} + +const SOURCE_NAME_RE = /^[\w.\-/]+\.(fbasic|fb)$/; +const ALLOWED_TYPES = new Set(['web']); + +// Validate a *parsed* JSON value against the fade.json shape. Returns a +// fully-typed FadeProject only when no `error`-severity issues were found. +export function validateFadeProject(raw: unknown): FadeConfigParseResult { + const errors: FadeConfigError[] = []; + const err = (path: string, message: string) => + errors.push({ path, message, severity: 'error' }); + const warn = (path: string, message: string) => + errors.push({ path, message, severity: 'warning' }); + + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + err('', 'fade.json root must be a JSON object.'); + return { ok: false, errors }; + } + const root = raw as Record; + + // Reject unknown keys so typos surface instead of being silently dropped. + // `$schema` is permitted (and emitted by stringifyFadeProject) so editors + // can attach the JSON Schema for inline validation. + const known = new Set(['$schema', 'name', 'author', 'type', 'commandDlls', 'sources']); + for (const k of Object.keys(root)) { + if (!known.has(k)) warn(k, `Unknown property "${k}".`); + } + + // name (required, non-empty string) + if (typeof root.name !== 'string' || root.name.length === 0) { + err('name', 'Property "name" must be a non-empty string.'); + } + + // author (optional string) + if (root.author !== undefined && typeof root.author !== 'string') { + err('author', 'Property "author" must be a string.'); + } + + // type (required, enum) + if (typeof root.type !== 'string') { + err('type', 'Property "type" must be a string.'); + } else if (!ALLOWED_TYPES.has(root.type as FadeProjectType)) { + err('type', `Property "type" must be one of: ${[...ALLOWED_TYPES].map((t) => `"${t}"`).join(', ')}.`); + } + + // commandDlls (optional array of strings) + if (root.commandDlls !== undefined) { + if (!Array.isArray(root.commandDlls)) { + err('commandDlls', 'Property "commandDlls" must be an array of strings.'); + } else { + (root.commandDlls as unknown[]).forEach((v, i) => { + if (typeof v !== 'string') err(`commandDlls[${i}]`, 'Each entry must be a string.'); + }); + } + } + + // sources (required, non-empty array of fbasic-looking strings) + if (!Array.isArray(root.sources) || root.sources.length === 0) { + err('sources', 'Property "sources" must be a non-empty array of .fbasic file names.'); + } else { + const seen = new Set(); + (root.sources as unknown[]).forEach((v, i) => { + if (typeof v !== 'string') { + err(`sources[${i}]`, 'Each source must be a string.'); + return; + } + if (!SOURCE_NAME_RE.test(v)) { + err(`sources[${i}]`, `"${v}" does not look like a .fbasic file name.`); + } + if (seen.has(v)) warn(`sources[${i}]`, `Duplicate source "${v}" is listed earlier.`); + seen.add(v); + }); + } + + const hasErrors = errors.some((e) => e.severity === 'error'); + if (hasErrors) return { ok: false, errors }; + return { + ok: true, + project: { + name: root.name as string, + author: typeof root.author === 'string' ? root.author : undefined, + type: root.type as FadeProjectType, + commandDlls: Array.isArray(root.commandDlls) + ? (root.commandDlls as string[]) + : [], + sources: root.sources as string[], + }, + errors, // may still contain warnings + }; +} + +// Parse-and-validate from a raw JSON string. Convenience wrapper that +// surfaces JSON syntax errors as schema errors at path "". +export function parseFadeProject(jsonText: string): FadeConfigParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch (e: any) { + return { + ok: false, + errors: [{ path: '', message: 'fade.json is not valid JSON: ' + (e?.message ?? e), severity: 'error' }], + }; + } + return validateFadeProject(parsed); +} + +// Build a default fade.json for a fresh project. Used when migrating legacy +// flat OPFS files into the first project folder, and when (later) creating +// a new project from the project viewer. +export function defaultFadeProject(projectName: string, sources: string[]): FadeProject { + return { + name: projectName, + type: 'web', + commandDlls: [], + sources: sources.length > 0 ? sources : ['main.fbasic'], + }; +} + +// Map JSON-pointer-ish paths ("sources[2]", "type", "$schema") to character +// ranges in the source text. Used to turn validator errors into Monaco +// markers so the user sees a red squiggle on the offending key/value. +// +// The locator is a small hand-rolled tokenizer + state machine — not a full +// JSON parser. It's deliberately tolerant of malformed input: every token it +// can recognize gets recorded, and unknown bytes are skipped. The output is +// a Map keyed by the same path strings the validator emits. + +export interface JsonRange { start: number; end: number; } + +export function locateJsonPaths(text: string): Map { + const ranges = new Map(); + let i = 0; + const n = text.length; + const stack: Array<{ kind: 'obj' | 'arr'; key?: string; index?: number; parentPath: string }> = []; + + function pathFor(extra?: string): string { + const top = stack[stack.length - 1]; + if (!top) return extra ?? ''; + const base = top.parentPath; + if (top.kind === 'obj') { + const k = extra ?? top.key ?? ''; + if (!k) return base; + return base ? `${base}.${k}` : k; + } + // array + const idx = top.index ?? 0; + const seg = `[${idx}]`; + return base + seg; + } + + function skipWs() { + while (i < n) { + const c = text.charCodeAt(i); + if (c === 32 || c === 9 || c === 10 || c === 13) { i++; continue; } + if (c === 47 /* / */ && i + 1 < n) { + if (text.charCodeAt(i + 1) === 47) { + // line comment + i += 2; + while (i < n && text.charCodeAt(i) !== 10) i++; + continue; + } + if (text.charCodeAt(i + 1) === 42) { + // block comment + i += 2; + while (i < n - 1 && !(text.charCodeAt(i) === 42 && text.charCodeAt(i + 1) === 47)) i++; + i += 2; + continue; + } + } + break; + } + } + + function readString(): { value: string; range: JsonRange } | null { + if (text.charCodeAt(i) !== 34 /* " */) return null; + const start = i; + i++; + let value = ''; + while (i < n) { + const c = text.charCodeAt(i); + if (c === 92 /* \ */ && i + 1 < n) { + value += text[i + 1]; i += 2; continue; + } + if (c === 34) { i++; return { value, range: { start, end: i } }; } + value += text[i]; i++; + } + return { value, range: { start, end: i } }; + } + + function readLiteralRange(): JsonRange { + const start = i; + while (i < n) { + const c = text.charCodeAt(i); + // stop on JSON structural chars or whitespace + if (c === 44 || c === 125 || c === 93 || c === 32 || c === 9 || c === 10 || c === 13) break; + i++; + } + return { start, end: i }; + } + + // Path-segment math we redo after pushing/popping so paths reflect the + // current container. + function topParentPath(): string { + // Path of the enclosing container — the new frame's parentPath. + return pathFor(); + } + + while (i < n) { + skipWs(); + if (i >= n) break; + const c = text.charCodeAt(i); + // Container opens + if (c === 123 /* { */) { + const parentPath = topParentPath(); + stack.push({ kind: 'obj', parentPath }); + i++; + continue; + } + if (c === 91 /* [ */) { + const parentPath = topParentPath(); + stack.push({ kind: 'arr', index: 0, parentPath }); + i++; + continue; + } + if (c === 125 /* } */ || c === 93 /* ] */) { + stack.pop(); + i++; + // After closing a value, parent array (if any) advances its index. + const top = stack[stack.length - 1]; + if (top?.kind === 'arr') top.index = (top.index ?? 0) + 1; + else if (top?.kind === 'obj') top.key = undefined; + continue; + } + if (c === 44 /* , */) { + const top = stack[stack.length - 1]; + if (top?.kind === 'arr') top.index = (top.index ?? 0) + 1; + else if (top?.kind === 'obj') top.key = undefined; + i++; + continue; + } + if (c === 58 /* : */) { + // Colon separates a recorded key from its value; no-op. + i++; + continue; + } + if (c === 34 /* " */) { + const s = readString(); + if (!s) break; + const top = stack[stack.length - 1]; + // In an object, alternating: first string is a key. + if (top?.kind === 'obj' && top.key === undefined) { + top.key = s.value; + // Record the key range first; the value will overwrite if + // present (validators usually target the value). + ranges.set(pathFor(), s.range); + continue; + } + // Otherwise it's a value (in array or as a value-of-key). + const valuePath = pathFor(); + if (valuePath) ranges.set(valuePath, s.range); + continue; + } + // Literals: number, true, false, null + const range = readLiteralRange(); + if (range.end > range.start) { + const valuePath = pathFor(); + if (valuePath) ranges.set(valuePath, range); + } else { + i++; // safety: don't hang on unknown char + } + } + return ranges; +} + +// Convert a (start,end) byte range into a Monaco (line,col) range. Single +// pass over the text; returns 1-based line/col tuples ready for setModelMarkers. +export function offsetsToLineCol(text: string, start: number, end: number): { + startLineNumber: number; startColumn: number; + endLineNumber: number; endColumn: number; +} { + let line = 1, col = 1; + let startLine = 1, startCol = 1, endLine = 1, endCol = 1; + const target = Math.max(0, Math.min(text.length, end)); + for (let i = 0; i <= text.length; i++) { + if (i === start) { startLine = line; startCol = col; } + if (i === target) { endLine = line; endCol = col; } + if (i >= target) break; + if (text.charCodeAt(i) === 10) { line++; col = 1; } else { col++; } + } + return { startLineNumber: startLine, startColumn: startCol, endLineNumber: endLine, endColumn: endCol }; +} + +// Stable pretty-printer so saving fade.json from a form keeps a consistent +// shape on disk (no key drift, no whitespace churn). +export function stringifyFadeProject(p: FadeProject): string { + const ordered: Record = { + $schema: '/fade.schema.json', + name: p.name, + }; + if (p.author) ordered.author = p.author; + ordered.type = p.type; + ordered.commandDlls = p.commandDlls ?? []; + ordered.sources = p.sources; + return JSON.stringify(ordered, null, 2) + '\n'; +} diff --git a/Playground/src/languages.ts b/Playground/src/languages.ts new file mode 100644 index 0000000..4c45c22 --- /dev/null +++ b/Playground/src/languages.ts @@ -0,0 +1,254 @@ +// Minimal Monarch tokenizers + theme contributions for the supporting file +// types the playground hosts alongside .fbasic. Kept small on purpose — +// users edit fade source, the other types are mostly read-only project +// metadata (fade.json), shader stubs (.fx), or notes (.md/.yaml). When a +// language outgrows this, swap in the full TextMate grammar via the +// @codingame default-extension package + textmate-service-override. + +import * as monaco from 'monaco-editor'; + +type ThemeRule = { token: string; foreground?: string; fontStyle?: string }; + +// Per-language theme contributions, merged into the base fade-dark rules. +// Tokens that overlap with fade's existing rules (comment, keyword, string, +// number, etc.) inherit fade's colors; only language-unique tokens listed. +const themeContributions: ThemeRule[] = [ + // markdown + { token: 'heading.md', foreground: '569CD6', fontStyle: 'bold' }, + { token: 'strong.md', foreground: 'D4D4D4', fontStyle: 'bold' }, + { token: 'emphasis.md', foreground: 'D4D4D4', fontStyle: 'italic' }, + { token: 'code.md', foreground: 'CE9178' }, + { token: 'link.md', foreground: '569CD6', fontStyle: 'underline' }, + { token: 'list.md', foreground: 'C586C0' }, + { token: 'quote.md', foreground: '6A9955' }, + { token: 'hr.md', foreground: '6A9955' }, + // json + { token: 'key.json', foreground: '9CDCFE' }, + // yaml + { token: 'key.yaml', foreground: '9CDCFE' }, + { token: 'anchor.yaml', foreground: 'DCDCAA' }, + // hlsl shares fade's keyword/type/number/string tokens — no extras. +]; + +// ─── markdown ─────────────────────────────────────────────────────────── +const markdownLang: monaco.languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.md', + tokenizer: { + root: [ + // fenced code blocks (```lang … ```) + [/^\s*```\s*([a-zA-Z0-9_-]*)\s*$/, { token: 'code.md', next: '@codeblock' }], + // ATX headers + [/^#{1,6}\s.*$/, 'heading.md'], + // Setext header underline + [/^[=-]{3,}\s*$/, 'heading.md'], + // Block quote + [/^\s*>.*$/, 'quote.md'], + // Horizontal rule + [/^\s*[-*_]{3,}\s*$/, 'hr.md'], + // Unordered list marker + [/^\s*[-*+]\s+/, 'list.md'], + // Ordered list marker + [/^\s*\d+\.\s+/, 'list.md'], + // Inline code + [/`[^`]+`/, 'code.md'], + // Bold (** … **) and italics (* … *) + [/\*\*[^*]+\*\*/, 'strong.md'], + [/\*[^*]+\*/, 'emphasis.md'], + [/__[^_]+__/, 'strong.md'], + [/_[^_]+_/, 'emphasis.md'], + // Image / link + [/!?\[[^\]]*\]\([^)]+\)/, 'link.md'], + // Auto-link + [/]+>/, 'link.md'], + ], + codeblock: [ + [/^\s*```\s*$/, { token: 'code.md', next: '@pop' }], + [/.*/, 'code.md'], + ], + }, +}; + +// ─── json ─────────────────────────────────────────────────────────────── +const jsonLang: monaco.languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.json', + keywords: ['true', 'false', 'null'], + tokenizer: { + root: [ + [/"(?:[^"\\]|\\.)*"\s*(?=:)/, 'key.json'], + [/"(?:[^"\\]|\\.)*"/, 'string'], + [/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/, 'number'], + [/\b(?:true|false|null)\b/, 'keyword'], + [/[{}\[\],:]/, 'operator'], + [/\/\/.*$/, 'comment'], + [/\/\*/, { token: 'comment', next: '@blockcomment' }], + ], + blockcomment: [ + [/[^*]+/, 'comment'], + [/\*\//, { token: 'comment', next: '@pop' }], + [/./, 'comment'], + ], + }, +}; + +// ─── yaml ─────────────────────────────────────────────────────────────── +const yamlLang: monaco.languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.yaml', + keywords: ['true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'], + tokenizer: { + root: [ + // Document markers + [/^---/, 'operator'], + [/^\.\.\./, 'operator'], + // Comments + [/#.*$/, 'comment'], + // Anchors / aliases + [/&[A-Za-z0-9_-]+/, 'anchor.yaml'], + [/\*[A-Za-z0-9_-]+/, 'anchor.yaml'], + // Keys (word + optional spaces + ':' at end of word) + [/^\s*-?\s*[A-Za-z_][\w.-]*(?=\s*:)/, 'key.yaml'], + [/^\s*"(?:[^"\\]|\\.)*"(?=\s*:)/, 'key.yaml'], + // Block scalar indicators + [/[|>][+-]?\d*\s*$/, 'operator'], + // Strings + [/"(?:[^"\\]|\\.)*"/, 'string'], + [/'(?:[^'\\]|\\.)*'/, 'string'], + // Booleans / null / numbers + [/\b(?:true|false|null|yes|no|on|off)\b/i, 'keyword'], + [/\b-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/, 'number'], + // List bullet + [/^\s*-\s+/, 'list.md'], + // Flow indicators + [/[\[\]{},]/, 'operator'], + ], + }, +}; + +// ─── hlsl (.fx) ───────────────────────────────────────────────────────── +const hlslLang: monaco.languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.hlsl', + keywords: [ + 'asm', 'asm_fragment', 'break', 'case', 'cbuffer', 'centroid', 'class', + 'column_major', 'compile', 'const', 'continue', 'default', 'discard', + 'do', 'else', 'export', 'extern', 'for', 'fxgroup', 'globallycoherent', + 'goto', 'groupshared', 'if', 'in', 'inline', 'inout', 'interface', + 'line', 'lineadj', 'linear', 'namespace', 'nointerpolation', 'noperspective', + 'NULL', 'out', 'packoffset', 'pass', 'pixelfragment', 'point', + 'precise', 'register', 'return', 'row_major', 'sample', 'sampler', + 'shared', 'snorm', 'stateblock', 'stateblock_state', 'static', 'string', + 'struct', 'switch', 'tbuffer', 'technique', 'technique10', 'technique11', + 'texture', 'triangle', 'triangleadj', 'typedef', 'uniform', 'unorm', + 'unsigned', 'vertexfragment', 'void', 'volatile', 'while', + ], + typeKeywords: [ + 'bool', 'int', 'uint', 'half', 'float', 'double', 'min10float', + 'min16float', 'min12int', 'min16int', 'min16uint', 'matrix', 'vector', + 'bool1', 'bool2', 'bool3', 'bool4', + 'int1', 'int2', 'int3', 'int4', + 'uint1', 'uint2', 'uint3', 'uint4', + 'float1', 'float2', 'float3', 'float4', + 'float1x1', 'float2x2', 'float3x3', 'float4x4', + 'Texture1D', 'Texture2D', 'Texture3D', 'TextureCube', + 'Texture2DArray', 'TextureCubeArray', 'SamplerState', 'SamplerComparisonState', + 'StructuredBuffer', 'RWStructuredBuffer', 'Buffer', 'RWBuffer', + 'ByteAddressBuffer', 'RWByteAddressBuffer', + ], + operators: [ + '=', '+', '-', '*', '/', '%', '!', '~', '&', '|', '^', '<<', '>>', + '==', '!=', '<', '>', '<=', '>=', '&&', '||', '++', '--', '+=', '-=', + '*=', '/=', '%=', '&=', '|=', '^=', '<<=', '>>=', '?', ':', + ], + symbols: /[=>(); +export function registerExtraLanguages() { + for (const s of SPECS) { + if (registered.has(s.id)) continue; + registered.add(s.id); + monaco.languages.register({ id: s.id, extensions: s.extensions, aliases: s.aliases }); + monaco.languages.setMonarchTokensProvider(s.id, s.monarch); + } +} + +// Theme rules to merge into the playground's fade-dark theme. Callers +// merge before defineTheme so all rules apply in one pass. +export function extraThemeRules(): ThemeRule[] { + return themeContributions; +} diff --git a/Playground/src/main.ts b/Playground/src/main.ts index 87f2abf..b07b4a2 100644 --- a/Playground/src/main.ts +++ b/Playground/src/main.ts @@ -60,8 +60,24 @@ import { // file into this when it's opened so the editor service can resolve it. const virtualFs = new RegisteredFileSystemProvider(false); registerFileSystemOverlay(1, virtualFs); +// virtualFs.registerFile() throws if called twice for the same URI. We +// track registered URIs so reopening a previously-closed tab no-ops the +// registration step instead of crashing with "file already exists". +const registeredVirtualFsUris = new Set(); import EditorWorker from '@codingame/monaco-vscode-api/workers/editor.worker?worker'; +import { languageForExtra, registerExtraLanguages, extraThemeRules } from './languages'; +import { createMarkdownPreview, previewPanelIdFor } from './markdown-preview'; +import { + FADE_JSON_NAME, + defaultFadeProject, + stringifyFadeProject, + parseFadeProject, + locateJsonPaths, + offsetsToLineCol, + type FadeProject, + type FadeConfigError, +} from './fade-config'; (self as any).MonacoEnvironment = { getWorker: () => new EditorWorker(), }; @@ -92,19 +108,130 @@ const editorContainer = document.getElementById('editor')!; const editorPlaceholder = document.getElementById('editor-placeholder')!; const outputEl = document.getElementById('output')!; // ─── OPFS workspace ───────────────────────────────────────────────────────── +// Project-aware (folder-per-project) layout: +// +// workspace/ +// / +// fade.json ← required manifest, locked from create/rename/delete +// main.fbasic +// util.fbasic +// … +// +// On first init we migrate any legacy flat files at workspace/ into +// workspace/default/ and synthesize a starter fade.json listing the +// .fbasic files we found. Active project lives in localStorage so reloads +// land in the same place. + +const ACTIVE_PROJECT_KEY = 'fade.activeProject'; +const DEFAULT_PROJECT_NAME = 'default'; + class OpfsWorkspace { - private dir!: FileSystemDirectoryHandle; + private root!: FileSystemDirectoryHandle; // workspace/ + private dir!: FileSystemDirectoryHandle; // workspace// + private activeProject: string = DEFAULT_PROJECT_NAME; async init() { - const root = await navigator.storage.getDirectory(); - this.dir = await root.getDirectoryHandle('workspace', { create: true }); - // Seed default file if workspace is empty - const names = await this.list(); - if (names.length === 0) { - await this.write('main.fbasic', DEFAULT_SOURCE); + const opfsRoot = await navigator.storage.getDirectory(); + this.root = await opfsRoot.getDirectoryHandle('workspace', { create: true }); + + // Migrate any legacy flat files (workspace/) into a default + // project folder so the new layout invariant holds. + await this.migrateLegacyFlatLayout(); + + // Determine which project to open. Validated against the actual + // folders on disk; if the stored name is gone, fall back to the + // first project we find (creating one if none exist). + let target = localStorage.getItem(ACTIVE_PROJECT_KEY) || DEFAULT_PROJECT_NAME; + const projects = await this.listProjects(); + if (!projects.includes(target)) target = projects[0] ?? DEFAULT_PROJECT_NAME; + await this.setActiveProject(target, /*seedIfEmpty*/ true); + } + + // Promote any leaf files at the workspace root into a project folder. + // Idempotent: if there are no flat files, this does nothing. + private async migrateLegacyFlatLayout(): Promise { + const flat: string[] = []; + for await (const entry of (this.root as any).values()) { + if (entry.kind === 'file') flat.push(entry.name); + } + if (flat.length === 0) return; + const dest = await this.root.getDirectoryHandle(DEFAULT_PROJECT_NAME, { create: true }); + const movedSources: string[] = []; + for (const name of flat) { + const srcFh = await this.root.getFileHandle(name); + const srcText = await (await srcFh.getFile()).text(); + const dstFh = await dest.getFileHandle(name, { create: true }); + const w = await dstFh.createWritable(); + await w.write(srcText); + await w.close(); + await this.root.removeEntry(name); + if (/\.(fbasic|fb)$/i.test(name)) movedSources.push(name); + } + // Synthesize fade.json if it wasn't part of the legacy set. + let alreadyHasManifest = false; + for await (const entry of (dest as any).values()) { + if (entry.kind === 'file' && entry.name === FADE_JSON_NAME) { + alreadyHasManifest = true; + break; + } + } + if (!alreadyHasManifest) { + const proj = defaultFadeProject(DEFAULT_PROJECT_NAME, movedSources); + const mh = await dest.getFileHandle(FADE_JSON_NAME, { create: true }); + const mw = await mh.createWritable(); + await mw.write(stringifyFadeProject(proj)); + await mw.close(); + } + } + + // Public API for project-level operations. + async listProjects(): Promise { + const names: string[] = []; + for await (const entry of (this.root as any).values()) { + if (entry.kind === 'directory') names.push(entry.name); } + names.sort(); + return names; + } + + currentProject(): string { return this.activeProject; } + + async setActiveProject(name: string, seedIfEmpty: boolean = false): Promise { + this.activeProject = name; + this.dir = await this.root.getDirectoryHandle(name, { create: true }); + localStorage.setItem(ACTIVE_PROJECT_KEY, name); + if (seedIfEmpty) await this.seedIfEmpty(); } + // If the active project has no files at all, drop in a default main.fbasic + // + fade.json so the user lands on something they can run. + private async seedIfEmpty(): Promise { + const names = await this.list(); + if (names.length > 0) return; + await this.write('main.fbasic', DEFAULT_SOURCE); + const proj = defaultFadeProject(this.activeProject, ['main.fbasic']); + await this.write(FADE_JSON_NAME, stringifyFadeProject(proj)); + } + + // Create a fresh project folder with a starter main.fbasic + fade.json. + async createProject(name: string): Promise { + const dir = await this.root.getDirectoryHandle(name, { create: true }); + // Avoid clobbering an existing project. + let hasAny = false; + for await (const _ of (dir as any).values()) { hasAny = true; break; } + if (hasAny) return; + const mainFh = await dir.getFileHandle('main.fbasic', { create: true }); + const mainW = await mainFh.createWritable(); + await mainW.write(DEFAULT_SOURCE); + await mainW.close(); + const proj = defaultFadeProject(name, ['main.fbasic']); + const manifestFh = await dir.getFileHandle(FADE_JSON_NAME, { create: true }); + const manifestW = await manifestFh.createWritable(); + await manifestW.write(stringifyFadeProject(proj)); + await manifestW.close(); + } + + // File-level operations, scoped to the active project. async list(): Promise { const names: string[] = []; for await (const entry of (this.dir as any).values()) { @@ -128,8 +255,40 @@ class OpfsWorkspace { } async delete(name: string): Promise { + if (name === FADE_JSON_NAME) { + throw new Error('fade.json is required and cannot be deleted.'); + } await this.dir.removeEntry(name); } + + // OPFS has no atomic rename. Read → write under the new name → remove + // the old. If write fails partway, the old file is preserved (we only + // remove after the new file lands successfully). + async rename(oldName: string, newName: string): Promise { + if (oldName === FADE_JSON_NAME || newName === FADE_JSON_NAME) { + throw new Error('fade.json is required and cannot be renamed.'); + } + if (oldName === newName) return; + if (!/^[\w.\-]+$/.test(newName)) { + throw new Error('Invalid name. Letters, digits, dot, dash, underscore only.'); + } + // Collision check. + let collision = false; + try { + await this.dir.getFileHandle(newName); + collision = true; + } catch { /* NotFoundError → free to proceed */ } + if (collision) throw new Error(`A file named "${newName}" already exists.`); + const content = await this.read(oldName); + await this.write(newName, content); + try { await this.dir.removeEntry(oldName); } + catch (e) { + // Best-effort cleanup. The new file already landed, so the + // rename is effectively complete even if we couldn't remove + // the source. + console.warn('[fade] rename: failed to remove old file', oldName, e); + } + } } // ─── Tabs + model management ──────────────────────────────────────────────── @@ -146,7 +305,8 @@ let editor: monaco.editor.IStandaloneCodeEditor | null = null; function languageFor(name: string): string { if (name.endsWith('.fbasic') || name.endsWith('.fb')) return 'fade'; - return 'plaintext'; + const extra = languageForExtra(name); + return extra ?? 'plaintext'; } async function openFile(workspace: OpfsWorkspace, name: string) { @@ -158,7 +318,13 @@ async function openFile(workspace: OpfsWorkspace, name: string) { const uri = monaco.Uri.file(`/workspace/${name}`); // Push this file into the virtual FS overlay so any vscode-side // editor open won't fail with "Unable to resolve nonexistent file". - virtualFs.registerFile(new RegisteredMemoryFile(uri, text)); + // The provider throws on duplicate URIs, so skip the registration + // if we've already registered this file in a previous open. + const uriKey = uri.toString(); + if (!registeredVirtualFsUris.has(uriKey)) { + virtualFs.registerFile(new RegisteredMemoryFile(uri, text)); + registeredVirtualFsUris.add(uriKey); + } let model = monaco.editor.getModel(uri); if (!model) { model = monaco.editor.createModel(text, languageFor(name), uri); @@ -192,6 +358,13 @@ async function openFile(workspace: OpfsWorkspace, name: string) { editorPlaceholder.style.display = 'none'; renderTabs(); renderFileList(workspace); + // Force a semantic-token refresh for fade models. The diagnostics + // handler also applies tokens, but it only runs after an LSP push; + // for preloaded-but-untouched files, that may not have happened yet + // by the time the user clicks the tab. + if (tab.model.getLanguageId() === 'fade') { + (window as any).__fadeRefreshSemanticTokens?.(tab.model); + } } function closeTab(name: string) { @@ -230,6 +403,22 @@ function renderTabs() { renderTabs(); renderFileListSelection(); }; + el.append(label); + + // Markdown files get a preview-toggle button next to the label. + // Clicking activates an existing preview panel or creates one. + if (/\.(md|markdown)$/i.test(name)) { + const previewBtn = document.createElement('span'); + previewBtn.className = 'tab-action'; + previewBtn.title = 'Open Markdown Preview'; + previewBtn.innerHTML = ''; + previewBtn.onclick = (e) => { + e.stopPropagation(); + openMarkdownPreview(name); + }; + el.append(previewBtn); + } + const close = document.createElement('span'); close.className = 'close'; close.textContent = '×'; @@ -237,26 +426,168 @@ function renderTabs() { e.stopPropagation(); closeTab(name); }; - el.append(label, close); + el.append(close); tabsEl.append(el); } } +// Open (or activate, if already present) a markdown preview panel for the +// given filename. Resolves the dockview API off the window since renderTabs +// runs at module scope, before the bootstrap closure that owns dockApi. +function openMarkdownPreview(filename: string) { + const api = (window as any).__fadeDockview; + if (!api) return; + const id = previewPanelIdFor(filename); + const existing = api.getPanel?.(id); + if (existing) { existing.api.setActive(); return; } + api.addPanel({ + id, + component: 'markdown-preview', + title: 'Preview: ' + filename, + position: { referencePanel: 'editor', direction: 'right' }, + }); +} + +// ─── Project state surface for module-scope renderers ─────────────────── +// renderFileList runs at module scope (used at boot before bootstrap()'s +// closure exists), but needs to know which sources fade.json lists today +// and how to mutate them. Bootstrap fills these in; everything reads via +// the getters so timing doesn't matter. +let currentProjectRef: FadeProject | null = null; +interface ProjectOps { + addSourceAt(name: string, position: 'start' | 'end'): Promise; + removeSource(name: string): Promise; + revealSourceInManifest(name: string): Promise; + renameFile(name: string): Promise; + deleteFile(name: string): Promise; +} +let projectOps: ProjectOps | null = null; + async function renderFileList(workspace: OpfsWorkspace) { const names = await workspace.list(); fileListEl.innerHTML = ''; + const sources = currentProjectRef?.sources ?? []; for (const name of names) { const li = document.createElement('li'); - li.textContent = name; + li.dataset.name = name; + const label = document.createElement('span'); + label.textContent = name; + li.append(label); + if (name === FADE_JSON_NAME) { + // Visible cue that the manifest is locked from delete/rename + // (creation is blocked at the New-File prompt). + const lock = document.createElement('span'); + lock.className = 'file-lock codicon codicon-lock-small'; + lock.title = 'Project manifest — required, cannot be deleted or renamed.'; + li.append(lock); + li.classList.add('manifest'); + } else { + // Right-click → rename / delete. fade.json is locked above + // so it gets no menu and falls through to the browser default. + li.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showFileContextMenu(e.clientX, e.clientY, name); + }); + } + // Source-membership badge for .fbasic files: numeric index when + // listed in fade.json:sources, dash when orphaned. Informational + // only — the matching add / remove / jump actions live on the + // file row's right-click menu (handled below). + if (/\.(fbasic|fb)$/i.test(name)) { + const sourceIdx = sources.indexOf(name); + const badge = document.createElement('span'); + if (sourceIdx >= 0) { + badge.className = 'source-badge listed'; + badge.textContent = String(sourceIdx + 1); + badge.title = `Source #${sourceIdx + 1} in fade.json`; + } else { + badge.className = 'source-badge orphan'; + badge.textContent = '–'; + badge.title = 'Not listed in fade.json:sources'; + } + li.append(badge); + } if (name === activeName) li.classList.add('active'); li.onclick = () => openFile(workspace, name); fileListEl.append(li); } } +// File-list right-click context menu. Source-membership actions (add / +// remove / reveal-in-manifest) live here alongside Rename + Delete so +// every file operation is reachable from one consistent gesture. +function showFileContextMenu(x: number, y: number, fileName: string) { + closeAnyFileMenu(); + if (!projectOps) return; + const menu = document.createElement('div'); + menu.className = 'source-badge-menu'; + menu.dataset.menu = 'file-context'; + const addItem = (label: string, handler: () => void) => { + const item = document.createElement('button'); + item.className = 'source-badge-item'; + item.type = 'button'; + item.textContent = label; + item.onclick = (e) => { + e.stopPropagation(); + closeAnyFileMenu(); + handler(); + }; + menu.append(item); + }; + const addSeparator = () => { + const sep = document.createElement('div'); + sep.className = 'source-badge-sep'; + menu.append(sep); + }; + + const ops = projectOps; + // Source-membership actions for .fbasic files. Mirrors what used to + // live on the badge click, now bundled with rename/delete. + if (/\.(fbasic|fb)$/i.test(fileName)) { + const sources = currentProjectRef?.sources ?? []; + const sourceIdx = sources.indexOf(fileName); + if (sourceIdx >= 0) { + addItem(`Go to fade.json (source #${sourceIdx + 1})`, + () => ops.revealSourceInManifest(fileName)); + addItem('Remove from sources', () => ops.removeSource(fileName)); + } else { + addItem('Add to sources (end)', () => ops.addSourceAt(fileName, 'end')); + addItem('Add to sources (start)', () => ops.addSourceAt(fileName, 'start')); + } + addSeparator(); + } + addItem(`Rename "${fileName}"…`, () => ops.renameFile(fileName)); + addItem(`Delete "${fileName}"`, () => ops.deleteFile(fileName)); + document.body.append(menu); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + // Flip into viewport if we'd overflow. + const r = menu.getBoundingClientRect(); + if (r.right > window.innerWidth) menu.style.left = `${window.innerWidth - r.width - 4}px`; + if (r.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - r.height - 4}px`; + setTimeout(() => { + const onClick = (e: MouseEvent) => { + if (!(e.target as HTMLElement).closest('.source-badge-menu')) closeAnyFileMenu(); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeAnyFileMenu(); }; + document.addEventListener('mousedown', onClick, true); + document.addEventListener('keydown', onKey, true); + (menu as any).__cleanup = () => { + document.removeEventListener('mousedown', onClick, true); + document.removeEventListener('keydown', onKey, true); + }; + }, 0); +} +function closeAnyFileMenu() { + for (const m of document.querySelectorAll('[data-menu="file-context"]')) { + (m as any).__cleanup?.(); + m.remove(); + } +} + function renderFileListSelection() { for (const li of Array.from(fileListEl.children) as HTMLElement[]) { - li.classList.toggle('active', li.textContent === activeName); + li.classList.toggle('active', li.dataset.name === activeName); } } @@ -931,6 +1262,7 @@ async function bootstrap() { ]; // Fade theme — based on vs-dark with colors for the semantic token types. + registerExtraLanguages(); monaco.editor.defineTheme('fade-dark', { base: 'vs-dark', inherit: true, @@ -946,6 +1278,7 @@ async function bootstrap() { { token: 'operator', foreground: 'D4D4D4' }, { token: 'number', foreground: 'B5CEA8' }, { token: 'string', foreground: 'CE9178' }, + ...extraThemeRules(), ], colors: {}, }); @@ -953,10 +1286,7 @@ async function bootstrap() { statusEl.textContent = 'Booting Fade runtime worker…'; const runner = new FadeRunner({ - onPrint: (line) => { - outputEl.textContent += line + '\n'; - outputEl.scrollTop = outputEl.scrollHeight; - }, + onPrint: (line) => appendOutputLine(line), onAlert: (msg) => window.alert(msg), }); await runner.ready; @@ -975,6 +1305,14 @@ async function bootstrap() { // in index.html colors each .fade-token- class. const decorationsByUri = new Map(); + // openFile (module scope) calls into this on every tab activation so + // preloaded-but-never-displayed models get tokenized before they're + // first shown. Fire-and-forget — applySemanticTokens deduplicates via + // deltaDecorations so duplicate calls are cheap. + (window as any).__fadeRefreshSemanticTokens = (model: monaco.editor.ITextModel) => { + void applySemanticTokens(model); + }; + async function applySemanticTokens(model: monaco.editor.ITextModel) { const uri = model.uri.toString(); const tokens = await lsp.getTokens(uri); @@ -1305,9 +1643,12 @@ async function bootstrap() { // all of them so the live editor's model is always covered. const allModels = monaco.editor.getModels().filter((m) => m.uri.toString() === uri); if (!allModels.length) return; - const activeModel = editor?.getModel(); - if (activeModel && activeModel.uri.toString() === uri) { - void applySemanticTokens(activeModel); + // Refresh semantic-token decorations on EVERY model with this URI, + // not just the editor's active one. Decorations are stored on the + // model itself, so doing this for inactive tabs means switching to + // them later shows them already highlighted (no edit needed). + for (const m of allModels) { + void applySemanticTokens(m); } const markers: monaco.editor.IMarkerData[] = diagnostics.map((d) => ({ severity: d.severity === 1 ? monaco.MarkerSeverity.Error @@ -1351,6 +1692,45 @@ async function bootstrap() { function renderProblems() { problemsList.innerHTML = ''; let total = 0; + + // fade.json schema problems get their own header row so the user + // recognizes them as project-level (not source-file) issues. + for (const e of currentProjectErrors) { + total++; + const li = document.createElement('li'); + li.className = 'problem-item'; + const icon = document.createElement('vscode-icon'); + icon.setAttribute('name', e.severity); + icon.className = e.severity; + const msg = document.createElement('span'); + msg.className = 'problem-message'; + msg.textContent = e.message; + if (e.path) { + const code = document.createElement('span'); + code.className = 'code'; + code.textContent = e.path; + msg.append(code); + } + const loc = document.createElement('span'); + loc.className = 'problem-location'; + const where = e.range + ? `${FADE_JSON_NAME}:${e.range.startLineNumber}:${e.range.startColumn}` + : FADE_JSON_NAME; + loc.textContent = where; + li.append(icon, msg, loc); + li.onclick = async () => { + try { + await openFile(workspace, FADE_JSON_NAME); + if (e.range && editor) { + editor.revealLineInCenter(e.range.startLineNumber, monaco.editor.ScrollType.Smooth); + editor.setPosition({ lineNumber: e.range.startLineNumber, column: e.range.startColumn }); + editor.focus(); + } + } catch { /* ignore */ } + }; + problemsList.append(li); + } + for (const [uri, diags] of diagnosticsByUri) { for (const d of diags) { total++; @@ -1479,8 +1859,316 @@ async function bootstrap() { return activeTab?.model.getValue() ?? ''; } + const projectNameEl = document.getElementById('project-name')!; + function setProjectStatus(label: string) { + projectNameEl.textContent = label; + projectNameEl.hidden = !label; + } + + // ─── Project (fade.json) state ─────────────────────────────────────── + // Re-read whenever fade.json's OPFS contents OR its open model changes. + // The compile/run/test/debug paths read from `currentProject` to build + // their source string instead of just the active tab; ordering follows + // fade.json sources[] exactly, matching the native SDK behavior. + let currentProject: FadeProject | null = null; + let currentProjectErrors: FadeConfigError[] = []; + + // Live-content lookup: prefer Monaco's model (so dirty edits compile + // even before the save timer fires), fall back to the OPFS-persisted + // copy. Models exist for every workspace file because bootstrap + // preloads them; we still tolerate a missing model gracefully. + async function readFile(name: string): Promise { + const uri = monaco.Uri.file(`/workspace/${name}`); + const model = monaco.editor.getModel(uri); + if (model) return model.getValue(); + const tab = tabs.get(name); + if (tab) return tab.model.getValue(); + try { return await workspace.read(name); } + catch { return ''; } + } + + async function refreshFadeProject(): Promise { + let text = ''; + try { + text = await readFile(FADE_JSON_NAME); + } catch { + currentProject = null; + currentProjectErrors = [{ + path: '', severity: 'error', + message: 'fade.json is missing from this project.', + }]; + applyFadeJsonMarkers(text, currentProjectErrors); + renderProblems(); + return; + } + const r = parseFadeProject(text); + currentProject = r.ok ? (r.project ?? null) : null; + + // Attach source ranges to schema errors so the renderer + Monaco + // markers can highlight the offending key/value precisely. + const paths = locateJsonPaths(text); + const decorate = (e: FadeConfigError): FadeConfigError => { + if (!e.path) return e; + const rng = paths.get(e.path); + if (!rng) return e; + return { ...e, range: offsetsToLineCol(text, rng.start, rng.end) }; + }; + const errors: FadeConfigError[] = r.errors.map(decorate); + + // Cross-check against the actual workspace: every entry in + // `sources` must point at a real file. We intentionally do NOT + // warn about unlisted .fbasic files in the workspace — keeping + // extra source files lying around (linked/unlinked as you iterate) + // is a normal workflow, not a misconfiguration. The dash badge + // in the file list already surfaces the "not part of the build" + // signal without an inline diagnostic. + // Sub-folder paths (containing "/") are skipped — OpfsWorkspace + // is flat for now, so a "/" path is by definition unresolvable + // and would always false-positive. + if (currentProject) { + try { + const workspaceFiles = await workspace.list(); + const fileSet = new Set(workspaceFiles); + currentProject.sources.forEach((src, idx) => { + if (src.includes('/')) return; + if (!fileSet.has(src)) { + errors.push(decorate({ + path: `sources[${idx}]`, + severity: 'error', + message: `Source "${src}" not found in this project. Create the file or remove the entry from fade.json.`, + })); + } + }); + } catch (e) { + console.warn('[fade] sources cross-check failed', e); + } + } + + currentProjectErrors = errors; + applyFadeJsonMarkers(text, currentProjectErrors); + renderProblems(); + // Republish for module-scope renderers (file list badges) and + // re-render so the source-order indicators update immediately. + currentProjectRef = currentProject; + renderFileList(workspace).catch(() => { /* ignore */ }); + // Title bar reflects the resolved project name. + if (currentProject?.name) { + const hasErrors = currentProjectErrors.some((e) => e.severity === 'error'); + setProjectStatus(hasErrors ? `${currentProject.name} (fade.json invalid)` : currentProject.name); + } else { + setProjectStatus(workspace.currentProject() + ' (fade.json invalid)'); + } + } + + // Mutations triggered from the file-list source badges. They edit + // the live Monaco model for fade.json (rather than the OPFS copy + // directly) so the polling loop's refreshFadeProject + LSP push + // chain reacts naturally; the model's saveTimer persists to OPFS. + async function mutateManifest( + mutate: (project: FadeProject) => FadeProject | null, + ): Promise { + const uri = monaco.Uri.file(`/workspace/${FADE_JSON_NAME}`); + const model = monaco.editor.getModel(uri); + if (!model) return; + const current = parseFadeProject(model.getValue()); + if (!current.ok || !current.project) { + // We don't try to fix invalid manifests; the user is best + // positioned to resolve schema problems first. + return; + } + const next = mutate(current.project); + if (!next) return; + const newText = stringifyFadeProject(next); + if (newText === model.getValue()) return; + model.applyEdits([{ range: model.getFullModelRange(), text: newText }]); + // Immediate state refresh so the file list redraws without waiting + // on the 250ms polling loop. + await refreshFadeProject(); + } + + projectOps = { + addSourceAt: async (name, position) => { + await mutateManifest((p) => { + if (p.sources.includes(name)) return null; // already listed + const updated = position === 'start' + ? [name, ...p.sources] + : [...p.sources, name]; + return { ...p, sources: updated }; + }); + }, + removeSource: async (name) => { + await mutateManifest((p) => { + if (!p.sources.includes(name)) return null; + return { ...p, sources: p.sources.filter((s) => s !== name) }; + }); + }, + revealSourceInManifest: async (name) => { + try { await openFile(workspace, FADE_JSON_NAME); } catch { /* ignore */ } + const uri = monaco.Uri.file(`/workspace/${FADE_JSON_NAME}`); + const model = monaco.editor.getModel(uri); + if (!model || !editor) return; + const text = model.getValue(); + const ranges = locateJsonPaths(text); + // Walk indices until we find the entry that contains the + // file name (string compare against the located value). + const proj = currentProject; + if (!proj) return; + const idx = proj.sources.indexOf(name); + if (idx < 0) return; + const r = ranges.get(`sources[${idx}]`); + if (!r) return; + const lc = offsetsToLineCol(text, r.start, r.end); + editor.revealLineInCenter(lc.startLineNumber, monaco.editor.ScrollType.Smooth); + editor.setPosition({ lineNumber: lc.startLineNumber, column: lc.startColumn }); + editor.focus(); + }, + renameFile: async (oldName) => { + if (oldName === FADE_JSON_NAME) { + alert('fade.json is the project manifest and cannot be renamed.'); + return; + } + const newName = prompt(`Rename "${oldName}" to:`, oldName); + if (!newName || newName === oldName) return; + try { + await workspace.rename(oldName, newName); + } catch (e: any) { + alert('Rename failed: ' + (e?.message ?? e)); + return; + } + // Monaco models are immutable on URI — swap by disposing the + // old model and creating a new one with the new URI. Any + // decorations + markers reattach via the polling loop's next + // LSP push. + const oldUri = monaco.Uri.file(`/workspace/${oldName}`); + const newUri = monaco.Uri.file(`/workspace/${newName}`); + const oldModel = monaco.editor.getModel(oldUri); + const text = oldModel ? oldModel.getValue() : await workspace.read(newName); + const wasActive = activeName === oldName; + const wasInEditor = editor?.getModel() === oldModel; + // Drop old model + virtualFs registration. + if (oldModel) oldModel.dispose(); + registeredVirtualFsUris.delete(oldUri.toString()); + // Recreate at new URI. + const newModel = monaco.editor.createModel(text, languageFor(newName), newUri); + // Move tab entry if open. + const oldTab = tabs.get(oldName); + if (oldTab) { + tabs.delete(oldName); + const newTab: Tab = { name: newName, model: newModel, dirty: false }; + newTab.model.onDidChangeContent(() => { + newTab.dirty = true; + clearTimeout(newTab.saveTimer); + newTab.saveTimer = window.setTimeout(async () => { + try { + await workspace.write(newTab.name, newTab.model.getValue()); + newTab.dirty = false; + renderTabs(); + } catch (e) { + console.error('[fade] save failed for', newTab.name, e); + } + }, 600); + renderTabs(); + }); + tabs.set(newName, newTab); + if (wasActive) activeName = newName; + if (wasInEditor && editor) editor.setModel(newTab.model); + } + // If the renamed file was listed in fade.json:sources, rewrite + // the manifest so the build keeps working. Preserves position. + await mutateManifest((p) => { + const idx = p.sources.indexOf(oldName); + if (idx < 0) return null; + const updated = [...p.sources]; + updated[idx] = newName; + return { ...p, sources: updated }; + }); + await refreshFadeProject(); + renderTabs(); + await renderFileList(workspace); + }, + deleteFile: async (name) => { + if (name === FADE_JSON_NAME) { + alert('fade.json is the project manifest and cannot be deleted.'); + return; + } + if (!confirm(`Delete "${name}"? This cannot be undone.`)) return; + // Close any open tab for this file first. + if (tabs.has(name)) closeTab(name); + // Dispose the Monaco model so it doesn't linger after the file + // is gone — otherwise the polling loop keeps pushing stale + // content to LSP under a now-orphan URI. + const uri = monaco.Uri.file(`/workspace/${name}`); + const model = monaco.editor.getModel(uri); + if (model) model.dispose(); + registeredVirtualFsUris.delete(uri.toString()); + try { + await workspace.delete(name); + } catch (e: any) { + alert('Delete failed: ' + (e?.message ?? e)); + return; + } + // If the deleted file was a listed source, remove it from + // fade.json so we don't trip the missing-source error. + await mutateManifest((p) => { + if (!p.sources.includes(name)) return null; + return { ...p, sources: p.sources.filter((s) => s !== name) }; + }); + await refreshFadeProject(); + renderTabs(); + await renderFileList(workspace); + }, + }; + + // Push schema errors as Monaco markers on the fade.json model so the + // user sees red/yellow squiggles inline. Owner string scopes them so + // they don't fight with LSP-emitted markers on other models. + function applyFadeJsonMarkers(text: string, errors: FadeConfigError[]) { + const uri = monaco.Uri.file(`/workspace/${FADE_JSON_NAME}`); + const model = monaco.editor.getModel(uri); + if (!model) return; + // If we have no useful text, clear and bail. + if (!text) { + monaco.editor.setModelMarkers(model, 'fade-config', []); + return; + } + const markers: monaco.editor.IMarkerData[] = []; + for (const e of errors) { + const r = e.range ?? { + // Whole-document fallback for root-level errors (bad JSON). + startLineNumber: 1, startColumn: 1, + endLineNumber: Math.max(1, model.getLineCount()), + endColumn: Math.max(1, model.getLineMaxColumn(model.getLineCount())), + }; + markers.push({ + severity: e.severity === 'warning' + ? monaco.MarkerSeverity.Warning + : monaco.MarkerSeverity.Error, + message: e.message, + source: 'fade.json', + startLineNumber: r.startLineNumber, + startColumn: r.startColumn, + endLineNumber: r.endLineNumber, + endColumn: r.endColumn, + }); + } + monaco.editor.setModelMarkers(model, 'fade-config', markers); + } + + // Concatenate the project's .fbasic sources in fade.json order. Falls + // back to the active tab's contents when fade.json is missing/invalid + // so the playground still runs *something* for new users mid-edit. + async function getProjectSource(): Promise { + if (!currentProject) return getActiveSource(); + const parts: string[] = []; + for (const name of currentProject.sources) { + const text = await readFile(name); + parts.push(text); + } + return parts.join('\n'); + } + async function refreshTests() { - const source = getActiveSource(); + const source = await getProjectSource(); if (!source) { testEntries = []; renderTests(); @@ -1585,13 +2273,36 @@ async function bootstrap() { } } - function appendOutput(text: string) { - if (!text) return; - // Trailing newline strip — drainPrintBuffer adds them. - const norm = text.endsWith('\n') ? text : text + '\n'; - outputEl.textContent += norm; + type OutputKind = 'plain' | 'dim' | 'error' | 'warning' | 'info' | 'pass'; + + // First write clears the "(not yet run)" placeholder. + let outputPrimed = false; + function primeOutput() { + if (outputPrimed) return; + outputEl.innerHTML = ''; + outputPrimed = true; + } + function clearOutput() { + outputEl.innerHTML = ''; + outputPrimed = true; + } + function appendOutputLine(text: string, kind: OutputKind = 'plain') { + if (text == null) return; + primeOutput(); + // Split on newlines so each rendered
is one logical line — keeps + // colored lines distinct and lets long text wrap inside the block. + const lines = String(text).split('\n'); + if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); + for (const line of lines) { + const div = document.createElement('div'); + div.className = 'output-line' + (kind !== 'plain' ? ' ' + kind : ''); + div.textContent = line; + outputEl.append(div); + } outputEl.scrollTop = outputEl.scrollHeight; } + const outputClearBtn = document.getElementById('output-clear'); + outputClearBtn?.addEventListener('click', clearOutput); function setTestsBusy(busy: boolean) { testsRunAllBtn.disabled = busy; @@ -1599,7 +2310,7 @@ async function bootstrap() { } async function runSingleTest(name: string) { - const source = getActiveSource(); + const source = await getProjectSource(); if (!source) return; const idx = testEntries.findIndex((t) => t.name === name); if (idx < 0) return; @@ -1625,7 +2336,7 @@ async function bootstrap() { } async function runAllTests() { - const source = getActiveSource(); + const source = await getProjectSource(); if (!source) return; for (const t of testEntries) { if (!t.isAbstract) { @@ -1649,7 +2360,7 @@ async function bootstrap() { function applyResult(r: TestRunResult) { if (r.error) { - appendOutput(r.error); + appendOutputLine(r.error, 'error'); appendTestLog(r.error, 'fail'); for (const t of testEntries) if (t.status === 'running') t.status = 'idle'; testsStatusEl.textContent = r.error; @@ -1665,15 +2376,11 @@ async function bootstrap() { e.failure = res.passed ? null : (res.failureMessage || res.failureReason || 'Failed'); e.failureFrames = res.passed ? undefined : (res.failureFrames || []); } - // Headline + per-test rollup go both to Output and the inline log. + // Captured stdout still belongs in the Output panel; the headline + + // per-test ✓/✗ rollup live in the inline test log only (no more + // "--- Tests: ... ---" section header in Output). const headline = `Tests: ${r.passed} passed, ${r.failed} failed (${Math.round(r.duration)} ms)`; - appendOutput(`\n--- ${headline} ---`); - if (r.printed) appendOutput(r.printed.trimEnd()); - for (const res of r.results) { - if (!res.passed) { - appendOutput(` ✗ ${res.name}: ${res.failureMessage || res.failureReason || 'failed'}`); - } - } + if (r.printed) appendOutputLine(r.printed.replace(/\n$/, '')); // Inline log: include printed stdout, then one block per result. Failure // frames render as click-to-jump links. @@ -1758,7 +2465,22 @@ async function bootstrap() { // playground's styling (vs-dark Monaco theme, vscode-elements). theme: { name: 'vs', className: 'dockview-theme-vs' }, disableFloatingGroups: false, - createComponent: ({ name }) => { + createComponent: ({ name, id }) => { + // Dynamic components (one element per panel instance) — + // resolved before the static `panel-cell` pool. Each name + // here builds its own DOM on demand and owns its lifecycle. + // dockview's createComponent contract gives us only id+name, + // not panel params — so we encode the filename into the + // panel id (e.g. `md-preview:doc.md`) and parse it back here. + if (name === 'markdown-preview') { + const filename = id.startsWith('md-preview:') ? id.slice('md-preview:'.length) : ''; + if (!filename) { + const err = document.createElement('div'); + err.textContent = 'markdown-preview missing filename in panel id'; + return { element: err, init() {}, dispose() {} }; + } + return createMarkdownPreview(filename); + } const cell = panelCells.querySelector( `.panel-cell[data-panel="${name}"]`, ); @@ -1824,6 +2546,8 @@ async function bootstrap() { const KNOWN_COMPONENTS = new Set([ 'workspace', 'editor', 'debug', 'output', 'problems', 'tests', 'debug-console', + // Dynamic — created on demand by the markdown preview button. + 'markdown-preview', ]); function healLayout(dock: DockviewApi) { @@ -2017,6 +2741,187 @@ async function bootstrap() { location.reload(); }); + // Console-only escape hatch — no UI button, intentionally. Wipes + // workspace/ from OPFS and clears Playground state in localStorage, + // then reloads. Useful when an existing workspace lacks fade.json (so + // the manifest lock keeps you from regenerating it) or when migrations + // leave things in a confused state. + (window as any).forceHardReset = async () => { + if (!confirm('forceHardReset(): wipe OPFS workspace + reset state? This deletes every file in every project.')) return; + try { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry('workspace', { recursive: true }); } catch { /* ignore */ } + } catch (e) { + console.error('[fade] OPFS wipe failed', e); + } + try { + localStorage.removeItem(LAYOUT_STORAGE_KEY); + localStorage.removeItem(ACTIVE_PROJECT_KEY); + } catch { /* ignore */ } + console.warn('[fade] forceHardReset complete — reloading'); + location.reload(); + }; + + // ─── Project viewer overlay ────────────────────────────────────────── + // Modal dialog that lists OPFS projects and offers a "new project" + // input. Switching reloads the page — simplest way to ensure all + // dock panels, Monaco models, and the polling loop pick up the new + // project cleanly (the dockview layout is global and persists + // across switches). + const projectOverlay = document.getElementById('project-overlay')!; + const projectListEl = document.getElementById('project-list')!; + const projectOverlayCloseBtn = document.getElementById('project-overlay-close')!; + const projectNewInput = document.getElementById('project-new-input') as HTMLInputElement; + const projectNewError = document.getElementById('project-new-error')!; + const openProjectsBtn = document.getElementById('open-projects')!; + + function showProjectError(msg: string) { + projectNewError.textContent = msg; + projectNewError.hidden = false; + } + function clearProjectError() { + projectNewError.textContent = ''; + projectNewError.hidden = true; + } + + // Pull a short summary line from a project's fade.json so the list + // shows something useful (name + source count or an invalid marker). + async function summarizeProject(name: string): Promise<{ label: string; meta: string; invalid: boolean }> { + try { + const dir = await (await navigator.storage.getDirectory()) + .getDirectoryHandle('workspace') + .then((w) => w.getDirectoryHandle(name)); + let manifestText: string | null = null; + try { + const fh = await dir.getFileHandle(FADE_JSON_NAME); + manifestText = await (await fh.getFile()).text(); + } catch { /* no manifest */ } + if (!manifestText) return { label: name, meta: 'no fade.json', invalid: true }; + const r = parseFadeProject(manifestText); + if (!r.ok || !r.project) return { label: name, meta: 'fade.json invalid', invalid: true }; + const count = r.project.sources.length; + const label = r.project.name && r.project.name !== name + ? `${r.project.name} (${name})` + : name; + return { label, meta: `${count} source${count === 1 ? '' : 's'}`, invalid: false }; + } catch { + return { label: name, meta: 'unreadable', invalid: true }; + } + } + + async function renderProjectList() { + projectListEl.innerHTML = ''; + const projects = await workspace.listProjects(); + const active = workspace.currentProject(); + for (const name of projects) { + const li = document.createElement('li'); + li.className = 'project-row' + (name === active ? ' active' : ''); + li.dataset.name = name; + + const icon = document.createElement('span'); + icon.className = 'codicon ' + (name === active ? 'codicon-folder-opened' : 'codicon-folder'); + li.append(icon); + + const label = document.createElement('span'); + label.className = 'project-row-name'; + label.textContent = name; + li.append(label); + + const meta = document.createElement('span'); + meta.className = 'project-row-meta'; + meta.textContent = name === active ? 'active' : '…'; + li.append(meta); + + li.onclick = () => { + if (name === active) { closeProjectOverlay(); return; } + switchToProject(name); + }; + projectListEl.append(li); + + // Resolve the meta line lazily so the list renders fast even + // with many projects. + summarizeProject(name).then((s) => { + if (name !== active) meta.textContent = s.meta; + if (s.invalid) li.classList.add('invalid'); + label.textContent = s.label; + }); + } + } + + async function switchToProject(name: string) { + try { + await workspace.setActiveProject(name); + } catch (e: any) { + showProjectError('Failed to switch: ' + (e?.message ?? e)); + return; + } + // Reload so all in-memory state (models, tabs, dockview) rebinds + // to the new project. Layout in localStorage survives. + location.reload(); + } + + async function createNewProject(rawName: string) { + const name = rawName.trim(); + if (!name) return; + if (!/^[\w.-]+$/.test(name)) { + showProjectError('Invalid name. Letters, digits, dot, dash, underscore only.'); + return; + } + const existing = await workspace.listProjects(); + if (existing.includes(name)) { + showProjectError(`A project named "${name}" already exists.`); + return; + } + try { + await workspace.createProject(name); + } catch (e: any) { + showProjectError('Create failed: ' + (e?.message ?? e)); + return; + } + clearProjectError(); + await switchToProject(name); + } + + function openProjectOverlay() { + clearProjectError(); + projectNewInput.value = ''; + projectOverlay.hidden = false; + renderProjectList().catch((e) => console.error('[fade] project list render failed', e)); + // Focus the new-project input on a microtask so screen readers + the + // browser's focus ring land correctly after the show animation. + setTimeout(() => projectNewInput.focus(), 0); + } + function closeProjectOverlay() { projectOverlay.hidden = true; } + + openProjectsBtn.addEventListener('click', openProjectOverlay); + projectNameEl.addEventListener('click', openProjectOverlay); + projectOverlayCloseBtn.addEventListener('click', closeProjectOverlay); + projectOverlay.addEventListener('click', (e) => { + if (e.target === projectOverlay) closeProjectOverlay(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !projectOverlay.hidden) { + e.preventDefault(); + closeProjectOverlay(); + } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p' && !e.shiftKey) { + // ⌘P / Ctrl+P opens the project switcher. Override the browser + // print shortcut since users are editing code, not paper docs. + e.preventDefault(); + openProjectOverlay(); + } + }); + projectNewInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + createNewProject(projectNewInput.value); + } + if (e.key === 'Escape') { + e.stopPropagation(); + closeProjectOverlay(); + } + }); + projectNewInput.addEventListener('input', clearProjectError); + statusEl.textContent = 'Mounting editor…'; editor = monaco.editor.create(editorContainer, { value: '', @@ -2032,29 +2937,63 @@ async function bootstrap() { // Watch ALL fade models in the registry and push when any change. Picks // up changes regardless of which model object the live editor uses (we // can have duplicates with the same URI under codingame's services). + // Also watches fade.json so the project manifest stays in sync with + // the live editor (drives source-list concat + Problems entries). const lastPushedByUri = new Map(); setInterval(() => { let anyChanged = false; + let manifestChanged = false; for (const m of monaco.editor.getModels()) { - if (m.getLanguageId() !== 'fade') continue; + const lang = m.getLanguageId(); const uri = m.uri.toString(); const value = m.getValue(); if (lastPushedByUri.get(uri) === value) continue; lastPushedByUri.set(uri, value); - lsp.setDocument(uri, value); - anyChanged = true; + if (lang === 'fade') { + lsp.setDocument(uri, value); + anyChanged = true; + } else if (uri.endsWith('/' + FADE_JSON_NAME)) { + manifestChanged = true; + } } // Re-discover tests in the background whenever the active file moves. if (anyChanged) refreshDebounce(); + if (manifestChanged) { + // fade.json edit landed — re-validate + refresh derived state. + refreshFadeProject().then(() => refreshDebounce()); + } }, 250); - // Initial test scan once a file is open. - setTimeout(refreshTests, 800); - - // Open the first file in the workspace. + // Preload every workspace file's Monaco model first so refreshFadeProject + // (and the project source concat) can read live content without an OPFS + // round-trip per file. Tabs aren't opened for these — they just sit in + // monaco.editor.getModels() until the user clicks them. const names = await workspace.list(); + for (const name of names) { + const uri = monaco.Uri.file(`/workspace/${name}`); + if (!monaco.editor.getModel(uri)) { + const text = await workspace.read(name); + monaco.editor.createModel(text, languageFor(name), uri); + } + } + + // Now that the fade.json model exists, resolve currentProject. We + // *must* await this before picking the default-opened file — otherwise + // currentProject is null and the preferred logic falls back to + // alphabetical, opening the wrong file when fade.json reorders sources. + await refreshFadeProject(); + setTimeout(refreshTests, 200); + + // Open a sensible default. Prefer the first .fbasic listed in fade.json + // (so the user lands in source code, not the manifest); fall back to any + // .fbasic in the workspace; fall back to whatever's there. if (names.length > 0) { - await openFile(workspace, names[0]); + const manifestSources: string[] = (currentProject as FadeProject | null)?.sources ?? []; + const preferred = + manifestSources.find((s) => names.includes(s)) + ?? names.find((n) => /\.(fbasic|fb)$/i.test(n)) + ?? names[0]; + await openFile(workspace, preferred); } else { editorContainer.style.display = 'none'; editorPlaceholder.style.display = 'flex'; @@ -2066,18 +3005,36 @@ async function bootstrap() { debugBtn.disabled = false; const runOnce = async () => { - const activeTab = activeName ? tabs.get(activeName) : null; - if (!activeTab) { - outputEl.textContent = 'No file open.'; + const source = await getProjectSource(); + if (!source) { + clearOutput(); + appendOutputLine('No file open.', 'dim'); return; } runBtn.disabled = true; - outputEl.textContent = ''; + clearOutput(); try { - const result = await runner.run(activeTab.model.getValue()); - outputEl.textContent = result; + const result = await runner.run(source); + // CompileAndRun returns a JSON envelope { compileError, runtimeError, printed }. + // Tolerate the legacy plain-string form too — useful if an older + // worker is still in cache mid-deploy. + let env: { compileError?: string | null; runtimeError?: string | null; printed?: string } | null = null; + try { env = JSON.parse(result); } catch { env = null; } + if (env && (typeof env.compileError !== 'undefined' || typeof env.runtimeError !== 'undefined')) { + // `env.printed` would duplicate what already streamed via the + // worker's per-line `print` messages (onPrint → appendOutputLine). + // We intentionally ignore it here and only render errors + the + // empty-state hint. + if (env.compileError) appendOutputLine(env.compileError, 'error'); + if (env.runtimeError) appendOutputLine(env.runtimeError, 'error'); + if (!env.compileError && !env.runtimeError && !env.printed) { + appendOutputLine('(no output)', 'dim'); + } + } else { + appendOutputLine(result); + } } catch (e: any) { - outputEl.textContent = 'Error: ' + (e?.message ?? e); + appendOutputLine(e?.message ?? String(e), 'error'); } finally { runBtn.disabled = false; } @@ -2801,19 +3758,20 @@ async function bootstrap() { }; const startDebug = async () => { - const activeTab = activeName ? tabs.get(activeName) : null; - if (!activeTab) { - outputEl.textContent = 'No file open.'; + const source = await getProjectSource(); + if (!source) { + clearOutput(); + appendOutputLine('No file open.', 'dim'); return; } - await beginDebugSession(() => runner.debugStart(activeTab.model.getValue())); + await beginDebugSession(() => runner.debugStart(source)); }; // Shared session-start machinery, factored so both Debug-button and // per-test Debug share the same "prep UI → start → sync bps → continue" // sequence. async function beginDebugSession(starter: () => Promise): Promise { - outputEl.textContent = ''; + clearOutput(); debugReplOutput.textContent = ''; setDebugStatus('starting', 'paused'); debugBtn.disabled = true; @@ -2839,13 +3797,14 @@ async function bootstrap() { // Per-test debug entry. Compiles the current file, starts a VM at the // chosen test's entry point, then proceeds like a normal debug session. async function debugSingleTest(name: string) { - const activeTab = activeName ? tabs.get(activeName) : null; - if (!activeTab) { - outputEl.textContent = 'No file open.'; + const source = await getProjectSource(); + if (!source) { + clearOutput(); + appendOutputLine('No file open.', 'dim'); return; } appendReplLine(`▶ debug test "${name}"`, 'in'); - await beginDebugSession(() => runner.debugStartTest(activeTab.model.getValue(), name)); + await beginDebugSession(() => runner.debugStartTest(source, name)); } debugBtn.addEventListener('click', startDebug); @@ -2964,27 +3923,130 @@ async function bootstrap() { // Editor option: glyph margin must be on to show breakpoint glyphs. editor.updateOptions({ glyphMargin: true }); - // New-file button - newFileBtn.addEventListener('click', async () => { - const name = prompt('File name (e.g. helper.fbasic)'); - if (!name) return; - if (!/^[\w.-]+$/.test(name)) { - alert('Invalid name. Letters, digits, dot, dash, underscore only.'); - return; + // New-file flow: click the +-button OR right-click in the workspace + // pane's empty area → small dropdown of allowed extensions → an + // inline edit row appears in the file list, pre-filled with a + // suggested name (base portion selected). Enter saves; Escape / + // blur / invalid name silently discards. + const NEW_FILE_EXTENSIONS: Array<{ label: string; ext: string }> = [ + { label: 'Fade source (.fbasic)', ext: 'fbasic' }, + { label: 'Shader (.fx)', ext: 'fx' }, + { label: 'JSON (.json)', ext: 'json' }, + { label: 'Text (.txt)', ext: 'txt' }, + ]; + + function showNewFileMenu(x: number, y: number) { + closeAnyFileMenu(); + const menu = document.createElement('div'); + menu.className = 'source-badge-menu'; + menu.dataset.menu = 'file-context'; + for (const { label, ext } of NEW_FILE_EXTENSIONS) { + const item = document.createElement('button'); + item.className = 'source-badge-item'; + item.type = 'button'; + item.textContent = label; + item.onclick = (e) => { + e.stopPropagation(); + closeAnyFileMenu(); + startInlineCreate(ext); + }; + menu.append(item); } - try { - // Check if already exists + document.body.append(menu); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + const r = menu.getBoundingClientRect(); + if (r.right > window.innerWidth) menu.style.left = `${window.innerWidth - r.width - 4}px`; + if (r.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - r.height - 4}px`; + setTimeout(() => { + const onClick = (e: MouseEvent) => { + if (!(e.target as HTMLElement).closest('.source-badge-menu')) closeAnyFileMenu(); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeAnyFileMenu(); }; + document.addEventListener('mousedown', onClick, true); + document.addEventListener('keydown', onKey, true); + (menu as any).__cleanup = () => { + document.removeEventListener('mousedown', onClick, true); + document.removeEventListener('keydown', onKey, true); + }; + }, 0); + } + + // Insert an inline-edit row at the top of the file list and focus + // its input. Commit on Enter; cancel on Escape/blur/invalid. + let inlineCreateRow: HTMLLIElement | null = null; + async function startInlineCreate(ext: string) { + // If a previous row is hanging, kill it first. + inlineCreateRow?.remove(); + inlineCreateRow = null; + + // Find a name that doesn't collide. + const existing = new Set(await workspace.list()); + let base = 'untitled'; + let candidate = `${base}.${ext}`; + let n = 1; + while (existing.has(candidate)) candidate = `${base}${++n}.${ext}`; + + const li = document.createElement('li'); + li.className = 'file-edit-row'; + const input = document.createElement('input'); + input.type = 'text'; + input.spellcheck = false; + input.autocomplete = 'off'; + input.value = candidate; + li.append(input); + // Insert at the top of the list so it's obvious. + fileListEl.prepend(li); + inlineCreateRow = li; + // Focus + select the base name so users can type-replace it. + input.focus(); + const dot = candidate.lastIndexOf('.'); + input.setSelectionRange(0, dot >= 0 ? dot : candidate.length); + + let settled = false; + const finish = async (commit: boolean) => { + if (settled) return; + settled = true; + const name = input.value.trim(); + li.remove(); + inlineCreateRow = null; + if (!commit) return; + // Silently discard on invalid name — by design, no alerts. + if (!name) return; + if (!/^[\w.-]+$/.test(name)) return; + if (name === FADE_JSON_NAME) return; const names = await workspace.list(); - if (names.includes(name)) { - alert('File already exists.'); - return; + if (names.includes(name)) return; + try { + await workspace.write(name, ''); + await openFile(workspace, name); + if (/\.(fbasic|fb)$/i.test(name)) { + await projectOps?.addSourceAt(name, 'end'); + } + } catch (e) { + console.error('[fade] new-file failed:', e); } - await workspace.write(name, ''); - await openFile(workspace, name); - } catch (e) { - console.error('[fade] new-file failed:', e); - alert('Failed to create file: ' + e); - } + }; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); void finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); void finish(false); } + }); + input.addEventListener('blur', () => { void finish(true); }); + } + + newFileBtn.addEventListener('click', (e) => { + const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + showNewFileMenu(r.left, r.bottom + 2); + }); + // Right-click on the workspace pane's empty area (file rows handle + // their own contextmenu and stop propagation via preventDefault + + // showFileContextMenu). + fileListEl.parentElement?.addEventListener('contextmenu', (e) => { + // Skip if the right-click landed inside a file row — those have + // their own context menu. + if ((e.target as HTMLElement).closest('#file-list li')) return; + e.preventDefault(); + showNewFileMenu(e.clientX, e.clientY); }); // Test probe — bypasses Monaco UI and goes straight to the worker. Used by @@ -3013,6 +4075,23 @@ async function bootstrap() { (window as any).__fadeRunnerHelpers = { listTests: ({ source }: { source: string }) => runner.listTests(source), runTests: ({ source, name }: { source: string; name?: string }) => runner.runTests(source, name), + project: { + // Refresh fade.json state synchronously and report the resolved + // source concat. Used by playwright probes to validate ordering + // without racing the 250ms polling loop. + getSource: async () => { + await refreshFadeProject(); + return await getProjectSource(); + }, + getProject: async () => { + await refreshFadeProject(); + return currentProject; + }, + getErrors: async () => { + await refreshFadeProject(); + return currentProjectErrors; + }, + }, debug: { start: ({ source }: { source: string }) => runner.debugStart(source), startTest: ({ source, name }: { source: string; name: string }) => diff --git a/Playground/src/markdown-preview.ts b/Playground/src/markdown-preview.ts new file mode 100644 index 0000000..1fe2fb9 --- /dev/null +++ b/Playground/src/markdown-preview.ts @@ -0,0 +1,127 @@ +// Markdown preview panel host. One instance per opened .md file; lives as +// a dockview panel of component "markdown-preview". Subscribes to the +// matching Monaco model and re-renders on every content change. + +import * as monaco from 'monaco-editor'; +import { marked } from 'marked'; + +// Resolves the model URI for a given workspace file name. Mirrors +// `monaco.Uri.file('/workspace/${name}')` from main.ts. +function uriFor(name: string): monaco.Uri { + return monaco.Uri.file(`/workspace/${name}`); +} + +function findModel(name: string): monaco.editor.ITextModel | null { + const uri = uriFor(name); + return monaco.editor.getModel(uri); +} + +// Minimal sanitization: strip script/style tags + on* attributes. marked +// gives us HTML; we control the input (user-authored docs in their own +// workspace) but still don't want a stray + + + + + + + + + + + + + + + + + + + + diff --git a/WebRuntime/FadeBridge.cs b/WebRuntime/FadeBridge.cs index 936c4d1..7a1581b 100644 --- a/WebRuntime/FadeBridge.cs +++ b/WebRuntime/FadeBridge.cs @@ -10,6 +10,10 @@ using FadeBasic.Launch; using FadeBasic.Lib.Standard; using FadeBasic.Sdk; +// FadeBasic.Sdk.Fade collides with the Fade.* MonoGame namespaces that +// arrive transitively via the Fade.MonoGame.Lib ProjectReference. Alias +// so `FadeSdk.TryCreateFromString(...)` is unambiguous. +using FadeSdk = FadeBasic.Sdk.Fade; using FadeBasic.LSP.Core; using FadeBasic.LSP.Core.Handlers; @@ -22,18 +26,111 @@ namespace WebRuntime; [SupportedOSPlatform("browser")] public static partial class FadeBridge { - private static readonly FadeWorkspace _workspace = CreateWorkspace(); + // Active workspace — rebuilt by SetProjectType when the editor switches + // between fade.json types. Each project type has a different CommandCollection + // so the LSP knows which commands exist (sprite/texture/etc. for monogame, + // location/alert/etc. for web). + private static FadeWorkspace _workspace = CreateWorkspace("web"); + private static string _activeProjectType = "web"; + + private static FadeWorkspace CreateWorkspace(string projectType) + { + // Swap the standard `wait ms` to the interruptible JS path so + // pause/stop during a long wait isn't blocked by Thread.Sleep on + // the VM worker. This MUST run before any user code does — that's + // why it's pinned here in the static initializer, alongside the + // workspace itself. + StandardCommands.WaitImpl = ms => + { + if (ms <= 0) return; + int kind; + try { kind = WebInterop.WaitMsInterruptible(ms); } + catch (System.Exception) + { + System.Threading.Thread.Sleep(ms); + return; + } + // 0 = wait completed normally — nothing to do. + // 1 = page wants the VM to pause. Enqueue REQUEST_PAUSE + // synchronously so the very next instruction check + // pauses, instead of waiting up to a full DebugTick + // budget for the worker's debug-pause postMessage to + // drain into the session. + // 2 = page wants the VM to terminate. Throw cancellation so + // ctx.Run unwinds without running any more ops; the + // surrounding DebugTick swallows the exception and + // reports complete. + if (kind == 1) + { + EnqueueBasic(DebugMessageType.REQUEST_PAUSE); + // Also surface a synthetic stop event so the page's debug + // adapter flips into the paused UI state immediately. + _debugSession?.EmitStop(); + } + else if (kind == 2) + { + // Terminate. Throw OperationCanceledException to unwind + // mid-instruction so the very next instruction doesn't + // run. The VM's Execute3 wrapper catches it and surfaces + // a runtime-error message (the "angry JSON" the user + // sees in the console); the page's debug-terminate + // message then nulls _debugSession on the next worker + // tick and the session ends. Imperfect but at least the + // VM stops at the right place. + throw new System.OperationCanceledException("wait ms interrupted by terminate"); + } + else if (kind == 3) + { + // Wake-only yield for page→VM updates (breakpoints, etc.). + // Flip the session's requestedExit + enqueue a NOOP so + // both the inner Execute3 batch AND the outer loop break. + // DebugTick resets the flag after StartDebugging returns + // (see ClearYieldRequest) so the next worker tick + // resumes normally with the new state applied. + _debugSession?.RequestYield(); + } + }; - private static FadeWorkspace CreateWorkspace() - { - var ws = new FadeWorkspace( - new CommandCollection(new WebCommands(), new StandardCommands())); - // Surface rich command markdown on hover (parsed from the XML doc - // comments baked into StandardCommandsMetaData.COMMANDS_JSON). - ws.Docs = StandardCommandDocs.Build(); + // Pick the command set per project type. Web ships the console-style + // surface; monogame swaps in Fade.MonoGame.Lib's graphics commands. + // Both register StandardCommands at the bottom of the stack. + // Docs come from the source-generator's COMMANDS_JSON blobs and feed + // the same MarkdownDocParser pipeline; monogame includes both blobs. + CommandCollection commands; + ICommandDocsProvider docs; + switch (projectType) + { + case "monogame": + commands = new CommandCollection( + new global::Fade.MonoGame.Lib.FadeMonoGameCommands(), + new StandardCommands()); + docs = StandardCommandDocs.BuildMonoGame(); + break; + default: + commands = new CommandCollection(new WebCommands(), new StandardCommands()); + docs = StandardCommandDocs.BuildWeb(); + break; + } + + var ws = new FadeWorkspace(commands); + ws.Docs = docs; return ws; } + // Called by the worker (main.ts → worker.js) when the active fade.json + // type changes. Rebuilds the workspace with the right CommandCollection + // so the LSP picks up the new command surface. Returns the new type so + // the page can log/confirm. Idempotent. + [JSExport] + public static string SetProjectType(string projectType) + { + var t = (projectType ?? "web").ToLowerInvariant(); + if (t == _activeProjectType) return t; + _activeProjectType = t; + _workspace = CreateWorkspace(t); + return t; + } + // camelCase JSON to match LSP wire-protocol convention; TS interfaces in // Playground use lowercase field names. IncludeFields is critical — Core // DTOs use public FIELDS (not properties), which System.Text.Json ignores @@ -49,33 +146,34 @@ private static FadeWorkspace CreateWorkspace() // ─── Run ────────────────────────────────────────────────────────────── [JSInvokable] [JSExport] + // Returns a JSON envelope so the page can format different kinds of + // output (compile errors / runtime errors / printed stdout) with their + // own styling. Shape: { compileError, runtimeError, printed }. Any + // field may be null/empty. Print output also streams through `onPrint` + // during execution; we drain anything that wasn't flushed yet. public static string CompileAndRun(string source) { - var sb = new StringBuilder(); var commands = _workspace.Commands; - if (!Fade.TryCreateFromString(source, commands, out var ctx, out var errors)) + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) { - sb.AppendLine("Compile failed:"); - sb.Append(errors.ToDisplay()); - return sb.ToString(); + return JsonSerializer.Serialize(new + { + compileError = errors.ToDisplay(), + runtimeError = (string)null, + printed = "", + }, _jsonOpts); } + string runtimeError = null; try { ctx.Run(); } - catch (Exception ex) { sb.AppendLine($"Runtime error: {ex.GetType().Name}: {ex.Message}"); } + catch (Exception ex) { runtimeError = ex.GetType().Name + ": " + ex.Message; } - var printed = WebCommands.DrainPrintBuffer(); - if (!string.IsNullOrEmpty(printed)) + return JsonSerializer.Serialize(new { - sb.AppendLine("--- print output ---"); - sb.Append(printed); - } - - sb.AppendLine("--- variables ---"); - if (ctx.TryGetInteger("x", out var x)) sb.AppendLine($"x = {x}"); - if (ctx.TryGetInteger("y", out var y)) sb.AppendLine($"y = {y}"); - if (ctx.TryGetString("s", out var s)) sb.AppendLine($"s = \"{s}\""); - - return sb.ToString(); + compileError = (string)null, + runtimeError, + printed = WebCommands.DrainPrintBuffer() ?? "", + }, _jsonOpts); } // ─── LSP entry points — thin adapters over Core ─────────────────────── @@ -227,6 +325,85 @@ public static string LspRename(string uri, int line, int character, string newNa return edit == null ? "null" : JsonSerializer.Serialize(edit, _jsonOpts); } + // ─── Help / command docs ────────────────────────────────────────────── + // Returns a JSON array of every command currently loaded in the + // workspace's CommandCollection, with the same markdown the hover + // provider renders. Used by the page's Help tab to build a TOC + + // per-command reader. One row per UNIQUE command name (overloads + // collapse — the first signature wins). Sorted alphabetically. + [JSExport] + public static string ListCommandDocs() + { + try + { + var commands = _workspace.Commands?.Commands; + if (commands == null) + { + return "[]"; + } + // Dedupe by command.name. Overloads (e.g. `rgb` with 3 vs 4 + // args) share a name; we surface one row per name and use the + // first CommandInfo we find — BuildCommandMarkdown already + // describes all parameter slots from that signature. + var seen = new HashSet(); + var rows = new List(); + foreach (var c in commands) + { + if (string.IsNullOrEmpty(c.name)) continue; + if (!seen.Add(c.name)) continue; + string markdown; + try + { + markdown = FadeBasic.LSP.Core.Handlers.HoverHandler.BuildCommandMarkdown( + c, _workspace.Docs); + } + catch (Exception ex) + { + markdown = $"### {c.name}\n\n_Failed to render docs: {ex.Message}_"; + } + rows.Add(new + { + name = c.name, + signature = c.sig, + // Best-effort: classify into a "group" based on the + // command name's first word for the TOC. The native + // command-doc generator keeps a category in metadata + // we don't propagate here yet; this is a useful + // approximation until that's wired through. + group = GuessGroup(c.name), + markdown, + }); + } + // Stable alphabetical order so the TOC is deterministic. + rows.Sort((a, b) => + string.Compare( + (string)a.GetType().GetProperty("name").GetValue(a), + (string)b.GetType().GetProperty("name").GetValue(b), + StringComparison.OrdinalIgnoreCase)); + return JsonSerializer.Serialize(rows, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new + { + error = "Failed to enumerate command docs: " + ex.Message, + }, _jsonOpts); + } + } + + // Cheap heuristic: cluster commands by their first word so the TOC + // gets meaningful section headings (e.g. "print", "string", "wait"). + // For multi-word commands ("wait ms", "wait key") this also yields a + // shared bucket. Single-word commands get their own bucket named + // after themselves only when no peers share the prefix — to avoid + // a 200-bucket TOC, single-words fall back to a generic "Core" group. + private static string GuessGroup(string name) + { + if (string.IsNullOrEmpty(name)) return "Core"; + var idx = name.IndexOf(' '); + return idx > 0 ? name.Substring(0, idx) : "Core"; + } + // ─── Tests ──────────────────────────────────────────────────────────── // Compile the source and list the test entry points. Returns a JSON // array of { name, isAbstract, fromParent, sourceLine }. On compile @@ -237,7 +414,7 @@ public static string ListTests(string source) try { var commands = _workspace.Commands; - if (!Fade.TryCreateFromString(source, commands, out var ctx, out _)) + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out _)) return "[]"; var tests = new List(); foreach (var t in ctx.Compiler.TestManifest) @@ -268,7 +445,7 @@ public static string RunTests(string source, string testName) { var sb = new StringBuilder(); var commands = _workspace.Commands; - if (!Fade.TryCreateFromString(source, commands, out var ctx, out var errors)) + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) { return JsonSerializer.Serialize(new { @@ -392,7 +569,7 @@ public static string DebugStartTest(string source, string testName) { DebugTerminate(); var commands = _workspace.Commands; - if (!Fade.TryCreateFromString(source, commands, out var ctx, out var errors)) + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) { return JsonSerializer.Serialize(new { @@ -466,7 +643,7 @@ public static string DebugStart(string source) { DebugTerminate(); // reset any prior session. var commands = _workspace.Commands; - if (!Fade.TryCreateFromString(source, commands, out var ctx, out var errors)) + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) { return JsonSerializer.Serialize(new { @@ -532,6 +709,13 @@ public static string DebugTick(int ops) messages = Array.Empty(), }, _jsonOpts); } + // If WaitImpl flipped requestedExit to unwind early (kind=3 yield + // for breakpoint updates etc., or kind=2 terminate before the + // page's debug-terminate has landed), clear the flag now so the + // NEXT tick can resume normally. For genuine kind=2 terminate + // the debug-terminate message will null _debugSession on the + // next worker tick anyway, so the reset is harmless there. + _debugSession.ClearYieldRequest(); var drained = _debugSession.DrainOutbound(); var msgs = new List(drained.Count); @@ -722,3 +906,4 @@ private sealed class BreakpointRequestDto public int Column { get; set; } } } + diff --git a/WebRuntime/StandardCommandDocs.cs b/WebRuntime/StandardCommandDocs.cs index b9bcee9..b93f0fc 100644 --- a/WebRuntime/StandardCommandDocs.cs +++ b/WebRuntime/StandardCommandDocs.cs @@ -1,10 +1,16 @@ // Builds an ICommandDocsProvider for Core's HoverHandler by reusing the // existing ApplicationSupport parsing pipeline: // -// StandardCommandsMetaData.COMMANDS_JSON (raw XML doc strings) +// CommandsMetaData.COMMANDS_JSON (raw XML doc strings, source-generator +// output) // → CommandMetadata (System.Text.Json) // → ProjectDocs (ProjectDocMethods.LoadDocs) // → ICommandDocsProvider (ProjectDocsCommandDocsProvider) +// +// One file with two builders: web (StandardCommands only) and monogame +// (FadeMonoGameCommands + StandardCommands). Both go through the same +// LoadDocs pipeline, which accepts a list — so monogame just passes both +// metadata blobs. using System.Collections.Generic; using System.Text.Json; @@ -16,21 +22,39 @@ namespace WebRuntime; internal static class StandardCommandDocs { - public static ICommandDocsProvider Build() + private static readonly JsonSerializerOptions _opts = new() + { + IncludeFields = true, + PropertyNameCaseInsensitive = true, + }; + + /// Docs for the 'web' command surface — WebCommands + StandardCommands. + /// + /// WebCommands doesn't ship a source-generator metadata blob (it's tiny; + /// hover falls back to the basic signature header for those). Standard + /// is the only set with rich docs in this branch. + /// + public static ICommandDocsProvider BuildWeb() => + BuildFromMetadata(StandardCommandsMetaData.COMMANDS_JSON); + + /// Docs for the 'monogame' command surface — FadeMonoGameCommands + StandardCommands. + public static ICommandDocsProvider BuildMonoGame() => + BuildFromMetadata( + global::Fade.MonoGame.Lib.FadeMonoGameCommandsMetaData.COMMANDS_JSON, + StandardCommandsMetaData.COMMANDS_JSON); + + private static ICommandDocsProvider BuildFromMetadata(params string[] commandsJsonBlobs) { try { - var metadata = JsonSerializer.Deserialize( - StandardCommandsMetaData.COMMANDS_JSON, - new JsonSerializerOptions - { - IncludeFields = true, - PropertyNameCaseInsensitive = true, - }); - if (metadata == null) return null!; - - var docs = new List { metadata } - .LoadDocs(); + var metas = new List(commandsJsonBlobs.Length); + foreach (var json in commandsJsonBlobs) + { + var m = JsonSerializer.Deserialize(json, _opts); + if (m != null) metas.Add(m); + } + if (metas.Count == 0) return null!; + var docs = metas.LoadDocs(); return new ProjectDocsCommandDocsProvider(docs); } catch diff --git a/WebRuntime/WebCommands.cs b/WebRuntime/WebCommands.cs index 774a356..cdbdd66 100644 --- a/WebRuntime/WebCommands.cs +++ b/WebRuntime/WebCommands.cs @@ -41,6 +41,10 @@ public static void Print(params object[] elements) public static int TimeMs() => (int)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() & 0x7FFFFFFF); + /// + /// Displays a web based alert to the user + /// + /// the message [FadeBasicCommand("alert")] public static void Alert(string msg) => WebInterop.Alert(msg); @@ -69,4 +73,12 @@ internal static partial class WebInterop [JSImport("prompt", "web-commands")] internal static partial string Prompt(string msg); + + // Cooperative `wait ms` for WASM. The JS-side impl blocks on + // Atomics.wait(timeout=ms) over a shared buffer; the main thread can + // Atomics.notify the buffer to wake the wait early (e.g. when the + // user clicks Pause / Stop). Returns the milliseconds actually waited + // — call sites can ignore it. + [JSImport("waitMsInterruptible", "web-commands")] + internal static partial int WaitMsInterruptible(int milliseconds); } diff --git a/WebRuntime/WebDebugSession.cs b/WebRuntime/WebDebugSession.cs index 9e9314e..9fad3da 100644 --- a/WebRuntime/WebDebugSession.cs +++ b/WebRuntime/WebDebugSession.cs @@ -50,6 +50,57 @@ public void Enqueue(DebugMessage msg) receivedMessages.Enqueue(msg); } + // Synthesize a "stopped" outbound event. The base session's + // SendStopMessage is protected and only fires when a breakpoint is + // hit — manual REQUEST_PAUSE acks with a plain PROTO_ACK that the + // page's adapter (see DAP_AUDIT.md) reads as "running". WaitImpl + // calls this after enqueuing REQUEST_PAUSE so the page transitions + // to its paused UI state. + public void EmitStop() + { + outboundMessages.Enqueue(new DebugMessage + { + id = GetNextMessageId(), + type = DebugMessageType.REV_REQUEST_BREAKPOINT, + }); + } + + // True after a kind=3 (yield) interrupt has flipped requestedExit. + // DebugTick reads this after StartDebugging returns and resets + // requestedExit so the next tick can resume normally. This is the + // hook that makes the worker yield between waits — see WaitImpl + // in FadeBridge.CreateWorkspace. + public bool WasYieldRequest { get; private set; } + + public void RequestYield() + { + WasYieldRequest = true; + requestedExit = true; + // VirtualMachine.Execute3 checks `!isSuspendRequested` per + // instruction in its inner for-loop. Flipping it short-circuits + // the current batch *immediately*, so the very next instruction + // doesn't run. Without this, Execute3 keeps going until its + // budget exhausts and requestedExit only takes effect at the + // outer loop boundary — after at least one more instruction + // (the one right after `wait ms`) has already executed. + if (_vm != null) _vm.isSuspendRequested = true; + // Enqueue a no-op too so the Execute3 lambda's `receivedMessages.Count > 0` + // check is an *additional* yield path (some Execute paths take + // the lambda route, some take the field-flag route). + receivedMessages.Enqueue(new DebugMessage + { + id = GetNextMessageId(), + type = DebugMessageType.NOOP, + }); + } + + public void ClearYieldRequest() + { + if (!WasYieldRequest) return; + WasYieldRequest = false; + requestedExit = false; + } + // Drain everything the session has produced since the last call. The // worker re-posts each as a typed `debug-event` message to the page. public List DrainOutbound() diff --git a/WebRuntime/WebRuntime.csproj b/WebRuntime/WebRuntime.csproj index 0d428ba..7918efa 100644 --- a/WebRuntime/WebRuntime.csproj +++ b/WebRuntime/WebRuntime.csproj @@ -24,6 +24,18 @@ + + + TargetFramework=net8.0 + diff --git a/WebRuntime/wwwroot/worker.js b/WebRuntime/wwwroot/worker.js index 328d817..be23364 100644 --- a/WebRuntime/wwwroot/worker.js +++ b/WebRuntime/wwwroot/worker.js @@ -9,11 +9,37 @@ import { dotnet } from './_framework/dotnet.js'; let exports = null; const queue = []; +// Each worker hosts a .NET runtime + bridge. The page boots TWO of these: +// +// role='lsp' — handles LSP traffic (set-document, hover, completion, +// semantic tokens, …) plus the lightweight `list-tests` +// compile step. Stays responsive at all times. +// role='vm' — owns the live VM. Handles `run`, `run-tests`, and the +// whole `debug-*` family. May get sync-blocked by user +// code calling Thread.Sleep (e.g. `wait ms`) — that's +// expected; the lsp worker keeps the page responsive. +// +// Both workers post heartbeats so the UI can distinguish "page is alive" +// from "VM is alive". The role flips behavior at message-dispatch time; +// the .NET runtime + JS module bindings are identical on both. +let role = 'lsp'; function log(message) { - self.postMessage({ type: 'log', message }); + self.postMessage({ type: 'log', message, role }); } +// ─── Heartbeat ────────────────────────────────────────────────────────────── +// Posts a beat to the main thread every 500ms so the UI can show a +// "worker alive" indicator. A synchronous Thread.Sleep inside the VM +// (e.g. `wait ms`) blocks this worker thread entirely, which means the +// heartbeats stop until the sleep returns — that's exactly what we want +// to surface to the user as a busy state. +let heartbeatTick = 0; +setInterval(() => { + heartbeatTick = (heartbeatTick + 1) | 0; + self.postMessage({ type: 'heartbeat', tick: heartbeatTick, t: Date.now(), role }); +}, 500); + // ─── Synchronous prompt$ handshake ────────────────────────────────────────── // `prompt$` is a JSImport that must return synchronously from C#'s // perspective, even though the actual UI prompt happens on the main thread. @@ -29,6 +55,31 @@ let promptSab = null; let promptSync = null; let promptBytes = null; +// ─── Interruptible `wait ms` ─────────────────────────────────────────────── +// Atomics.wait blocks the worker thread up to `ms` milliseconds. Pause / +// stop on the page side writes a non-zero "kind" into the SAB and calls +// Atomics.notify, which wakes the wait early and tells C# what to do +// next. Return value is the kind C# should react to: +// 0 = wait completed normally (timed out, no interrupt) +// 1 = page wants the VM to PAUSE +// 2 = page wants the VM to TERMINATE +let waitSab = null; +let waitView = null; +function waitMsInterruptible(ms) { + if (!waitView) { + // SAB not wired — fall back to busy polling so at least the call + // doesn't crash. No interrupt capability in this mode. + const end = performance.now() + ms; + while (performance.now() < end) { /* spin */ } + return 0; + } + Atomics.store(waitView, 0, 0); + Atomics.wait(waitView, 0, 0, ms); + // Read + clear in one shot so the next wait starts from a clean slot. + const kind = Atomics.exchange(waitView, 0, 0); + return kind | 0; +} + // ─── Debug tick loop ──────────────────────────────────────────────────────── // While a debug session is active, we yield to the worker's message pump // between batches of VM instructions so inbound messages (step/continue/ @@ -56,15 +107,13 @@ function pumpDebugTick() { self.postMessage({ type: 'debug-event', event: m }); } } - if (result.printed) { - // Stream prints from the running program through the same `print` - // event channel as normal Run so the output panel updates live. - const lines = result.printed.split('\n'); - for (const line of lines) { - if (line.length === 0) continue; - self.postMessage({ type: 'print', line }); - } - } + // NOTE: do NOT re-emit `result.printed` here. The Print command in + // WebCommands.cs streams every line live via the `web-commands.onPrint` + // JSImport (handled below in setModuleImports), which means every + // line already reached the page as a `print` message during execution. + // Re-emitting the drained buffer would duplicate each line — and the + // duplicate is especially visible at end-of-session when DebugTick + // returns with `complete: true` and a fully-drained buffer. if (result.complete) { debugTicking = false; self.postMessage({ type: 'debug-event', event: { type: 'complete' } }); @@ -111,6 +160,7 @@ async function init() { getUserAgent: () => self.navigator?.userAgent ?? '(unavailable)', alert: (msg) => self.postMessage({ type: 'alert', msg }), prompt: (msg) => syncPromptFromMain(msg), + waitMsInterruptible: (ms) => waitMsInterruptible(ms), }); log('registering assembly exports...'); @@ -119,10 +169,52 @@ async function init() { log('exports loaded'); while (queue.length) handle(queue.shift()); - self.postMessage({ type: 'ready' }); + self.postMessage({ type: 'ready', role }); } +// Op → required role. Anything not listed is treated as either-side. +const VM_OPS = new Set([ + 'run', 'run-tests', 'prompt-sab', + 'debug-start', 'debug-start-test', 'debug-terminate', + 'debug-set-breakpoints', 'debug-step', 'debug-continue', 'debug-pause', + 'debug-stack-frames', 'debug-scopes', 'debug-variable-expansion', + 'debug-eval', 'debug-repl', 'debug-set-variable', +]); + function handle(msg) { + // Configuration is always accepted — it's what makes us either role. + if (msg.type === 'configure') { + role = msg.role === 'vm' ? 'vm' : 'lsp'; + return; + } + // Cheap roundtrip for the heartbeat probes. + if (msg.type === 'ping') { + self.postMessage({ type: 'pong', id: msg.id, t: Date.now() }); + return; + } + // Sanity guard: if the page accidentally sends a VM op to the LSP + // worker (or vice-versa), surface a clear error instead of silently + // dropping the message. + const isVmOp = VM_OPS.has(msg.type); + if (isVmOp && role !== 'vm') { + self.postMessage({ + type: 'worker-misroute', + requested: msg.type, + actualRole: role, + id: msg.id, + }); + return; + } + if (!isVmOp && role === 'vm' && /^(lsp-|list-tests|list-command-docs)/.test(msg.type)) { + self.postMessage({ + type: 'worker-misroute', + requested: msg.type, + actualRole: role, + id: msg.id, + }); + return; + } + if (msg.type === 'run') { let result; try { @@ -258,11 +350,25 @@ function handle(msg) { log('lsp-rename failed: ' + (e?.message ?? e)); } self.postMessage({ type: 'lsp-rename-result', id: msg.id, edit: json }); + } else if (msg.type === 'set-project-type') { + // Page sends this when the active fade.json switches between 'web' + // and 'monogame' so the LSP swaps its CommandCollection. The page + // should re-set every open document after this resolves so tokens + // and diagnostics recompute against the new command set. + let resolved = msg.projectType; + try { resolved = exports.WebRuntime.FadeBridge.SetProjectType(msg.projectType); } + catch (e) { log('set-project-type failed: ' + (e?.message ?? e)); } + self.postMessage({ type: 'set-project-type-result', id: msg.id, projectType: resolved }); } else if (msg.type === 'prompt-sab') { // Main thread is handing us the SharedArrayBuffer used by syncPromptFromMain. promptSab = msg.buffer; promptSync = new Int32Array(promptSab, 0, 2); promptBytes = new Uint8Array(promptSab, 8); + } else if (msg.type === 'wait-interrupt-sab') { + // SAB used by waitMsInterruptible() — main thread Atomics.notifies + // it to wake an in-flight wait early when the user pauses/stops. + waitSab = msg.buffer; + waitView = new Int32Array(waitSab, 0, 1); } else if (msg.type === 'debug-start' || msg.type === 'debug-start-test') { let json = '{}'; try { @@ -335,6 +441,14 @@ function handle(msg) { log('list-tests failed: ' + (e?.message ?? e)); } self.postMessage({ type: 'list-tests-result', id: msg.id, tests: json }); + } else if (msg.type === 'list-command-docs') { + let json = '[]'; + try { + json = exports.WebRuntime.FadeBridge.ListCommandDocs(); + } catch (e) { + log('list-command-docs failed: ' + (e?.message ?? e)); + } + self.postMessage({ type: 'list-command-docs-result', id: msg.id, docs: json }); } else if (msg.type === 'run-tests') { let json = '{}'; try { From 44001220657b169f4b424888f79aafc175c243b2 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Wed, 20 May 2026 09:16:44 -0400 Subject: [PATCH 12/30] checkpoint --- Playground/.gitignore | 1 + Playground/index.html | 52 +++- Playground/scripts/probe-help-height.mjs | 64 +++++ Playground/scripts/probe-help-monogame.mjs | 104 ++++++++ Playground/scripts/probe-help-screenshot.mjs | 49 ++++ Playground/scripts/probe-menu-class.mjs | 100 ++++++++ Playground/scripts/probe-menu-overlap.mjs | 54 ++++ Playground/scripts/probe-menu-visual.mjs | 117 +++++++++ Playground/scripts/probe-mg-help-active.mjs | 96 +++++++ Playground/scripts/probe-mg-run.mjs | 73 ++++++ Playground/scripts/probe-monaco-popup.mjs | 122 +++++++++ Playground/scripts/test-monogame-tests.mjs | 256 +++++++++++++++++++ Playground/src/main.ts | 210 +++++++++++---- Playground/src/monogame-host.ts | 12 + Playground/src/xnb/xnb-previews.ts | 169 +++++++++++- WebRuntime.MonoGame/Pages/Index.Debug.cs | 161 ++++++++++++ WebRuntime.MonoGame/Pages/Index.razor.cs | 223 +++++++++++++--- 17 files changed, 1777 insertions(+), 86 deletions(-) create mode 100644 Playground/scripts/probe-help-height.mjs create mode 100644 Playground/scripts/probe-help-monogame.mjs create mode 100644 Playground/scripts/probe-help-screenshot.mjs create mode 100644 Playground/scripts/probe-menu-class.mjs create mode 100644 Playground/scripts/probe-menu-overlap.mjs create mode 100644 Playground/scripts/probe-menu-visual.mjs create mode 100644 Playground/scripts/probe-mg-help-active.mjs create mode 100644 Playground/scripts/probe-mg-run.mjs create mode 100644 Playground/scripts/probe-monaco-popup.mjs create mode 100644 Playground/scripts/test-monogame-tests.mjs diff --git a/Playground/.gitignore b/Playground/.gitignore index 57f8ebf..d49a0eb 100644 --- a/Playground/.gitignore +++ b/Playground/.gitignore @@ -1,5 +1,6 @@ node_modules dist public/runtime +public/monogame-runtime *.log .vite diff --git a/Playground/index.html b/Playground/index.html index 0d5da16..cde5231 100644 --- a/Playground/index.html +++ b/Playground/index.html @@ -510,13 +510,63 @@ background: rgba(14, 99, 156, 0.08); } + /* Monaco overflowing widgets — context menu, suggest popup, hover, + signature help — get reparented to document.body via the editor's + `fixedOverflowWidgets: true` option (see main.ts editor opts). + Belt-and-suspenders z-index in case anything else fights them. */ + .context-view, + .context-view.fixed, + .monaco-editor-overflow-widgets-root, + body > .monaco-aria-container { + z-index: 100000 !important; + } + + /* dockview's `.dv-render-overlay` wraps every panel's content + and is built with four independent "trap fixed positioning" + properties at once: + transform: translate3d(0, 0, 0) + contain: layout paint + isolation: isolate + backface-visibility: hidden + ALL of these create a containing block for `position: fixed` + descendants — overriding only one isn't enough. Monaco's + right-click menu is `position: fixed` inside a shadow root + attached to the editor, which lives inside the overlay, so + without ALL four overrides the menu gets clipped at the + editor's panel-cell boundary when it tries to overflow into + an adjacent panel (Help, Game, Output tabs). + + Tradeoff: we lose dockview's GPU-compositing hint + paint + containment for this layer. Panel resizes still feel smooth + in practice; the win is the menu painting freely across + panel boundaries. */ + .dv-render-overlay { + transform: none !important; + contain: none !important; + isolation: auto !important; + backface-visibility: visible !important; + will-change: auto !important; + /* `position: absolute` + `z-index: 1` (dockview's default) + creates a stacking context — and even without the four + properties above, that means the editor's overlay and the + Help panel's overlay each form their own context. The menu + (z-index 2575 inside the editor's overlay) can't outrank + the Help panel's overlay because they're siblings in a + common parent stacking context, paint-ordered by DOM. So + drop the z-index too — dockview still positions overlays + correctly because `position: absolute` keeps them on top + of static content, and the menu can finally win the paint + order via its own much-higher z-index. */ + z-index: auto !important; + } + /* ── Help panel: command docs reference ──────────────────────── Reuses .md-preview-body for the right-hand reader so the command markdown picks up the same typography hover does. */ #help-pane { display: flex; flex-direction: column; - min-height: 100%; + min-height: 0; height: 100%; } .help-toolbar { diff --git a/Playground/scripts/probe-help-height.mjs b/Playground/scripts/probe-help-height.mjs new file mode 100644 index 0000000..32f7d57 --- /dev/null +++ b/Playground/scripts/probe-help-height.mjs @@ -0,0 +1,64 @@ +// Diagnostic: measure the bottom dockview group's height + Help panel +// content height on a fresh layout, so we can tell whether the "Help is +// too tall" complaint is about the group size or the help-pane forcing +// its parent taller than configured. +// +// Usage: node scripts/probe-help-height.mjs + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1000)); + +// Force-restore default layout to bypass any localStorage state. +await page.evaluate(() => localStorage.removeItem('fade.dockview.layout.v3')); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click the Help tab to force it active. +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise((r) => setTimeout(r, 500)); + +const measurements = await page.evaluate(() => { + function rect(el) { + if (!el) return null; + const r = el.getBoundingClientRect(); + return { width: Math.round(r.width), height: Math.round(r.height), top: Math.round(r.top) }; + } + const helpPane = document.getElementById('help-pane'); + const helpSplit = document.getElementById('help-split'); + const helpToc = document.getElementById('help-toc'); + const helpBody = document.getElementById('help-body'); + const panelCell = helpPane?.closest('.panel-cell'); + const dockviewContent = panelCell?.parentElement; + const dockGroup = dockviewContent?.closest('.dv-groupview, .dv-grid-view, .groupview'); + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + panelCell: rect(panelCell), + helpPane: rect(helpPane), + helpSplit: rect(helpSplit), + helpToc: rect(helpToc), + helpBody: rect(helpBody), + dockGroupAncestors: (() => { + const out = []; + let el = helpPane?.parentElement; + while (el && el !== document.body) { + out.push({ + tag: el.tagName, + cls: el.className, + h: Math.round(el.getBoundingClientRect().height), + }); + el = el.parentElement; + } + return out; + })(), + }; +}); +console.log(JSON.stringify(measurements, null, 2)); + +await browser.close(); diff --git a/Playground/scripts/probe-help-monogame.mjs b/Playground/scripts/probe-help-monogame.mjs new file mode 100644 index 0000000..9dc2d61 --- /dev/null +++ b/Playground/scripts/probe-help-monogame.mjs @@ -0,0 +1,104 @@ +// Check that FadeMonoGameCommands docs (Summary + Remarks + Examples) +// actually surface in the Help tab when the project type is 'monogame'. +// Before the GenerateDocumentationFile fix on Fade.MonoGame.Lib's net8 +// build, every monogame command had an empty docString in the metadata +// blob the LSP worker reads, so the Help tab showed names with no body. +// +// Pass criteria: every FadeMonoGame command we sample has a non-trivial +// markdown body (length > 60 chars, includes a Remarks or Examples +// section header). + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Force a monogame project on this run. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mghelp', { create: true }); + const writeText = async (name, text) => { + const fh = await dir.getFileHandle(name, { create: true }); + const w = await fh.createWritable(); + await w.write(text); + await w.close(); + }; + await writeText('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', + name: 'mghelp', + type: 'monogame', + commandDlls: [], + sources: ['main.fbasic'], + }, null, 2) + '\n'); + await writeText('main.fbasic', 'do\n sync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mghelp'); +}); + +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 2000)); + +// Open the Help tab so its DOM populates. +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise((r) => setTimeout(r, 800)); + +// Sample a handful of FadeMonoGame commands that we know have rich XML +// docs (we inspected the metadata blob earlier). +const targets = ['push asset', 'rename asset', 'load sfx clip', 'sfx', 'play sfx', 'texture', 'sprite']; + +const results = []; +for (const name of targets) { + const ok = await page.evaluate((n) => window.__fadeHelp?.openCommand(n) ?? false, name); + if (!ok) { + results.push({ name, ok: false, reason: 'openCommand returned false (not in TOC)' }); + continue; + } + await new Promise((r) => setTimeout(r, 200)); + const body = await page.evaluate(() => { + const b = document.getElementById('help-body'); + return b ? b.textContent || '' : ''; + }); + const hasRemarks = /\bRemarks\b/.test(body); + const hasExamples = /\bExamples?\b/.test(body); + const hasParams = /\bParameters?\b/.test(body); + results.push({ + name, + ok: true, + len: body.length, + hasRemarks, + hasExamples, + hasParams, + firstChars: body.replace(/\s+/g, ' ').slice(0, 100), + }); +} + +console.log(JSON.stringify(results, null, 2)); + +// Cleanup. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + try { await ws.removeEntry('mghelp', { recursive: true }); } catch {} + localStorage.setItem('fade.activeProject', 'default'); +}); + +let failed = 0; +for (const r of results) { + if (!r.ok) { console.log('FAIL', r.name, r.reason); failed++; continue; } + if (r.len < 60 || (!r.hasRemarks && !r.hasExamples)) { + console.log('FAIL', r.name, '- body too thin:', r.firstChars); + failed++; + } +} +if (errors.length) console.log('PAGE ERRORS:', errors); +console.log(failed === 0 ? 'PASS: all monogame commands have rich docs' : `FAIL: ${failed} commands missing docs`); +await browser.close(); +process.exit(failed > 0 || errors.length > 0 ? 1 : 0); diff --git a/Playground/scripts/probe-help-screenshot.mjs b/Playground/scripts/probe-help-screenshot.mjs new file mode 100644 index 0000000..c476795 --- /dev/null +++ b/Playground/scripts/probe-help-screenshot.mjs @@ -0,0 +1,49 @@ +// Take a screenshot of the fresh-default-layout with Help active so we +// can see what the user means by "way too tall". Captures at several +// viewport sizes — maybe the issue is monitor-size-dependent. +// +// Usage: node scripts/probe-help-screenshot.mjs + +import { chromium } from 'playwright'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +mkdirSync('/tmp/fade-help-probe', { recursive: true }); +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); + +const viewports = [ + { name: '1280x800', w: 1280, h: 800 }, + { name: '1920x1080', w: 1920, h: 1080 }, + { name: '1440x900', w: 1440, h: 900 }, + { name: 'mac-13in', w: 1280, h: 720 }, + { name: '4k-tall', w: 1500, h: 2000 }, + { name: '5k-tall', w: 2560, h: 2880 }, +]; + +for (const vp of viewports) { + const page = await browser.newPage({ viewport: { width: vp.w, height: vp.h } }); + await page.goto(URL, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + // Force-fresh layout. + await page.evaluate(() => localStorage.removeItem('fade.dockview.layout.v3')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + await new Promise((r) => setTimeout(r, 1500)); + + await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); + await new Promise((r) => setTimeout(r, 500)); + + const png = await page.screenshot({ fullPage: false }); + writeFileSync(`/tmp/fade-help-probe/${vp.name}.png`, png); + const m = await page.evaluate(() => { + const c = document.querySelector('.panel-cell[data-panel="help"], #help-pane') + ?.closest('.panel-cell'); + const r = c?.getBoundingClientRect(); + return r ? { panelHeight: Math.round(r.height), viewportH: window.innerHeight } : null; + }); + console.log(`${vp.name}: ${JSON.stringify(m)}`); + await page.close(); +} + +await browser.close(); +console.log('screenshots written to /tmp/fade-help-probe/'); diff --git a/Playground/scripts/probe-menu-class.mjs b/Playground/scripts/probe-menu-class.mjs new file mode 100644 index 0000000..0898d10 --- /dev/null +++ b/Playground/scripts/probe-menu-class.mjs @@ -0,0 +1,100 @@ +// Open the editor's context menu and dump EVERY positioned element on +// the page so we can ID which class the menu actually uses. Uses +// page.locator click which is more reliable than page.mouse. + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click on real text in the editor to focus + place cursor, then right-click +// on the same word so a menu actually opens. +const word = await page.locator('.monaco-editor .view-line').nth(0).locator('span').first(); +await word.click(); +await new Promise((r) => setTimeout(r, 200)); +await word.click({ button: 'right' }); +await new Promise((r) => setTimeout(r, 1000)); + +// Dump every visible positioned element + parent of "Go to Definition" text. +const dump = await page.evaluate(() => { + const out = { positionedClasses: [], menuFinds: [], shadowRoots: 0 }; + // Recursively walk shadow roots too, in case Monaco renders into one. + function collect(root, arr) { + const list = root.querySelectorAll('*'); + for (const el of list) { + arr.push(el); + if (el.shadowRoot) { + out.shadowRoots++; + collect(el.shadowRoot, arr); + } + } + } + const all = []; + collect(document, all); + // Locate the shadow host for the menu container. + let host = null; + for (const el of all) { + if (el.shadowRoot) { + for (const ch of el.shadowRoot.querySelectorAll('*')) { + if (ch.classList?.contains('monaco-menu-container')) { + host = el; + break; + } + } + } + if (host) break; + } + if (host) { + const r = host.getBoundingClientRect(); + out.menuHost = { + tag: host.tagName, + id: host.id || null, + cls: (host.className && typeof host.className === 'string') ? host.className : '', + parent: host.parentElement?.tagName + '#' + (host.parentElement?.id || '') + '.' + (host.parentElement?.className || '').slice(0, 60), + rect: { w: Math.round(r.width), h: Math.round(r.height) }, + }; + } + for (const el of all) { + const cs = getComputedStyle(el); + if (cs.position !== 'fixed' && cs.position !== 'absolute') continue; + if (cs.display === 'none' || cs.visibility === 'hidden') continue; + const cls = (el.className && typeof el.className === 'string') ? el.className : ''; + if (!cls) continue; + if (/context|menu|popup|action/i.test(cls)) { + const r = el.getBoundingClientRect(); + out.positionedClasses.push({ + tag: el.tagName, + cls: cls.slice(0, 200), + pos: cs.position, + z: cs.zIndex, + parent: el.parentElement?.tagName + '.' + (el.parentElement?.className || '').slice(0, 40), + w: Math.round(r.width), + h: Math.round(r.height), + }); + } + } + // Search for any element whose text starts with "Go to" + for (const el of all) { + const t = (el.textContent || '').trim(); + if (t.startsWith('Go to ') && el.children.length <= 3) { + out.menuFinds.push({ + tag: el.tagName, + cls: (el.className && typeof el.className === 'string') ? el.className.slice(0, 100) : '', + text: t.slice(0, 50), + }); + if (out.menuFinds.length >= 4) break; + } + } + return out; +}); +console.log(JSON.stringify(dump, null, 2)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-menu-class.png', png); + +await browser.close(); diff --git a/Playground/scripts/probe-menu-overlap.mjs b/Playground/scripts/probe-menu-overlap.mjs new file mode 100644 index 0000000..6c9c768 --- /dev/null +++ b/Playground/scripts/probe-menu-overlap.mjs @@ -0,0 +1,54 @@ +// Open the editor context menu near the RIGHT edge of the editor so the +// menu has to overflow into the adjacent Help/Game tab group. With the +// transform-strip fix on .dv-render-overlay, the menu should now extend +// across panel boundaries instead of being clipped at the panel edge. + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click + right-click near the right edge of a real text token. +// Last token in line 0 should be furthest right. +const lastSpan = page.locator('.monaco-editor .view-line').nth(0).locator('span').last(); +await lastSpan.click(); +await new Promise((r) => setTimeout(r, 200)); +await lastSpan.click({ button: 'right' }); +await new Promise((r) => setTimeout(r, 800)); + +// Locate the menu container (inside shadow DOM) and check its rendered +// bounding rect. If our fix worked, the rect's right edge should extend +// past the editor's right edge OR the rect should be fully visible. +const summary = await page.evaluate(() => { + function findInShadows(root) { + for (const el of root.querySelectorAll('*')) { + if (el.classList?.contains('monaco-menu-container')) return el; + if (el.shadowRoot) { + const hit = findInShadows(el.shadowRoot); + if (hit) return hit; + } + } + return null; + } + const menu = findInShadows(document); + if (!menu) return { menuFound: false }; + const r = menu.getBoundingClientRect(); + const editor = document.querySelector('.monaco-editor .view-lines'); + const er = editor.getBoundingClientRect(); + return { + menuFound: true, + menuRect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + editorRightEdge: Math.round(er.right), + menuExtendsPastEditor: r.right > er.right + 5, + }; +}); +console.log(JSON.stringify(summary, null, 2)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-menu-overlap.png', png); +await browser.close(); diff --git a/Playground/scripts/probe-menu-visual.mjs b/Playground/scripts/probe-menu-visual.mjs new file mode 100644 index 0000000..65bd926 --- /dev/null +++ b/Playground/scripts/probe-menu-visual.mjs @@ -0,0 +1,117 @@ +// Quick visual-check probe for the editor's right-click menu. Opens a +// playground tab, right-clicks the editor at three positions that put +// the menu near a panel boundary, screenshots each one, and dumps the +// menu's computed position + every ancestor's transform / overflow / +// stacking properties so we can see exactly what's clipping it. +// +// Output: +// /tmp/fade-menu-left.png menu opens in editor middle (control) +// /tmp/fade-menu-right.png menu opens near editor's right edge +// /tmp/fade-menu-bottom.png menu opens near editor's bottom edge +// stdout: rect + ancestor-chain JSON for each case +// +// Usage: node scripts/probe-menu-visual.mjs + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Quick check: do my CSS overrides actually load? +const sanityCheck = await page.evaluate(() => { + const ro = document.querySelector('.dv-render-overlay'); + if (!ro) return { error: 'no .dv-render-overlay in DOM' }; + const cs = getComputedStyle(ro); + return { + transform: cs.transform, + contain: cs.contain, + isolation: cs.isolation, + backfaceVisibility: cs.backfaceVisibility, + }; +}); +console.log('--- CSS sanity check ---'); +console.log(JSON.stringify(sanityCheck, null, 2)); + +async function dumpMenu(label) { + const info = await page.evaluate(() => { + function findInShadows(root) { + for (const el of root.querySelectorAll('*')) { + if (el.classList?.contains('monaco-menu-container')) return el; + if (el.shadowRoot) { + const hit = findInShadows(el.shadowRoot); + if (hit) return hit; + } + } + return null; + } + const menu = findInShadows(document); + if (!menu) return { found: false }; + const cs = getComputedStyle(menu); + const r = menu.getBoundingClientRect(); + // Walk up through normal DOM ancestors AND across shadow boundaries. + const chain = []; + let p = menu; + while (p && chain.length < 25) { + const pcs = (p.nodeType === 1) ? getComputedStyle(p) : null; + chain.push({ + tag: p.tagName ?? '#' + p.nodeName, + cls: (typeof p.className === 'string' ? p.className : '').slice(0, 80), + id: p.id || null, + transform: pcs ? (pcs.transform === 'none' ? '' : pcs.transform.slice(0, 40)) : null, + overflow: pcs ? (pcs.overflow + '/' + pcs.overflowX + '/' + pcs.overflowY) : null, + position: pcs?.position ?? null, + zIndex: pcs?.zIndex ?? null, + filter: pcs ? (pcs.filter === 'none' ? '' : pcs.filter.slice(0, 20)) : null, + }); + p = p.parentNode; + if (p && p instanceof ShadowRoot) { + chain.push({ tag: '#shadow-root', host: p.host?.tagName + '.' + (p.host?.className || '').slice(0, 40) }); + p = p.host; + } + } + return { + found: true, + position: cs.position, + zIndex: cs.zIndex, + rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + chain, + }; + }); + console.log('=== ' + label + ' ==='); + console.log(JSON.stringify(info, null, 2)); +} + +async function openMenuAt(span, label) { + // Dismiss any prior menu by clicking the body. + await page.mouse.click(50, 50); + await new Promise((r) => setTimeout(r, 200)); + await span.click(); + await new Promise((r) => setTimeout(r, 150)); + await span.click({ button: 'right' }); + await new Promise((r) => setTimeout(r, 700)); + await dumpMenu(label); + const png = await page.screenshot(); + writeFileSync(`/tmp/fade-menu-${label}.png`, png); +} + +// Three positions: leftmost token (control), rightmost token in line 1 (overlaps Help/Game), +// rightmost token in the last visible line (overlaps Output panel below). +await openMenuAt( + page.locator('.monaco-editor .view-line').nth(0).locator('span').first(), + 'left', +); +await openMenuAt( + page.locator('.monaco-editor .view-line').nth(0).locator('span').last(), + 'right', +); +await openMenuAt( + page.locator('.monaco-editor .view-line').nth(6).locator('span').last(), + 'bottom', +); + +await browser.close(); diff --git a/Playground/scripts/probe-mg-help-active.mjs b/Playground/scripts/probe-mg-help-active.mjs new file mode 100644 index 0000000..f6c697d --- /dev/null +++ b/Playground/scripts/probe-mg-help-active.mjs @@ -0,0 +1,96 @@ +// Reproduce the case where Help is the active tab in the right column +// at the moment the user clicks Run — does dockview detach the Game +// panel's #mg-blazor-root mount point, causing Blazor's router to render +// inside a stale/detached element? + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); + +const consoleAll = []; +page.on('console', (m) => consoleAll.push(`[${m.type()}] ${m.text()}`)); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgrun', { create: true }); + const w = async (n, t) => { + const fh = await dir.getFileHandle(n, { create: true }); + const sw = await fh.createWritable(); + await sw.write(t); await sw.close(); + }; + await w('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', + name: 'mgrun', + type: 'monogame', + commandDlls: [], + sources: ['main.fbasic'], + }, null, 2) + '\n'); + await w('main.fbasic', 'do\n sync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgrun'); + localStorage.removeItem('fade.dockview.layout.v4'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Before clicking Run: where is mg-blazor-root and which tab is active? +const before = await page.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + return { + rootInDom: !!root, + rootRect: root?.getBoundingClientRect()?.height, + rootParentVisible: root && getComputedStyle(root.parentElement).display !== 'none', + activePanelInRightCol: window.__fadeDockview?.activeGroup?.activePanel?.id ?? null, + }; +}); +console.log('before Run:', JSON.stringify(before)); + +// Force Help to be the active tab in the right column. +await page.evaluate(() => window.__fadeDockview?.getPanel?.('help')?.api?.setActive?.()); +await new Promise((r) => setTimeout(r, 400)); +const afterHelp = await page.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + return { + rootInDom: !!root, + rootVisible: root && getComputedStyle(root).display !== 'none', + rootParentVisible: root && getComputedStyle(root.parentElement).display !== 'none', + }; +}); +console.log('Help active:', JSON.stringify(afterHelp)); + +// Click Run. +await page.click('#run'); +console.log('clicked Run, waiting for boot…'); +await new Promise((r) => setTimeout(r, 12_000)); + +const after = await page.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + return { + rootInDom: !!root, + rootText: root?.textContent?.slice(0, 200) ?? '', + notFound: document.body.textContent?.includes("Sorry, there's nothing"), + canvasExists: !!document.getElementById('theCanvas'), + }; +}); +console.log('after Run:', JSON.stringify(after)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-mg-help-active.png', png); + +console.log('---console (last 25)---'); +for (const m of consoleAll.slice(-25)) console.log(' ', m.slice(0, 350)); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + try { await ws.removeEntry('mgrun', { recursive: true }); } catch {} + localStorage.setItem('fade.activeProject', 'default'); +}); +await browser.close(); diff --git a/Playground/scripts/probe-mg-run.mjs b/Playground/scripts/probe-mg-run.mjs new file mode 100644 index 0000000..88f51a6 --- /dev/null +++ b/Playground/scripts/probe-mg-run.mjs @@ -0,0 +1,73 @@ +// Reproduce "Sorry, there's nothing at this address." in the Game panel +// after the Help-next-to-Game layout change. Sets up a monogame project, +// clicks Run, screenshots the Game panel and dumps its inner HTML. + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); + +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const consoleAll = []; +page.on('console', (m) => consoleAll.push(`[${m.type()}] ${m.text()}`)); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgrun', { create: true }); + const w = async (n, t) => { + const fh = await dir.getFileHandle(n, { create: true }); + const sw = await fh.createWritable(); + await sw.write(t); await sw.close(); + }; + await w('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', + name: 'mgrun', + type: 'monogame', + commandDlls: [], + sources: ['main.fbasic'], + }, null, 2) + '\n'); + await w('main.fbasic', 'do\n sync\nloop\n'); + localStorage.setItem('fade.activeProject', 'mgrun'); + localStorage.removeItem('fade.dockview.layout.v4'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click Run. +await page.click('#run'); +console.log('clicked Run, waiting for boot…'); +await new Promise((r) => setTimeout(r, 15_000)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-mg-run.png', png); +const summary = await page.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + const canvas = document.getElementById('theCanvas'); + return { + rootHTML: root ? root.outerHTML.slice(0, 600) : null, + canvasExists: !!canvas, + canvasDims: canvas ? { w: canvas.width, h: canvas.height } : null, + bodyText: document.body.innerText.includes("Sorry, there's nothing"), + notFoundInRoot: root?.textContent?.includes("Sorry, there's nothing"), + }; +}); +console.log('summary:', JSON.stringify(summary, null, 2)); +console.log('---recent console---'); +for (const m of consoleAll.slice(-20)) console.log(' ', m.slice(0, 400)); + +// Cleanup +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + try { await ws.removeEntry('mgrun', { recursive: true }); } catch {} + localStorage.setItem('fade.activeProject', 'default'); +}); +await browser.close(); diff --git a/Playground/scripts/probe-monaco-popup.mjs b/Playground/scripts/probe-monaco-popup.mjs new file mode 100644 index 0000000..aca7110 --- /dev/null +++ b/Playground/scripts/probe-monaco-popup.mjs @@ -0,0 +1,122 @@ +// Right-click in the editor and dump the popup container's full DOM +// ancestor chain (positions, z-indices, transforms) so we can pinpoint +// what CSS rule needs to apply to keep it above other dockview tabs. + +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); + +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// Click near the BOTTOM of the editor so the context menu opens upward +// — but its drop-shadow / bottom edge ought to overlap the Output panel's +// tab strip below. That's the case where z-index matters most. +const editorPos = await page.evaluate(() => { + const lines = document.querySelector('.monaco-editor .view-lines'); + if (!lines) return null; + const r = lines.getBoundingClientRect(); + return { x: r.left + 80, y: r.bottom - 30 }; +}); +await page.mouse.click(editorPos.x, editorPos.y); +await new Promise((r) => setTimeout(r, 150)); +await page.mouse.click(editorPos.x, editorPos.y, { button: 'right' }); +// Wait until ANY new element with class containing "context-view" appears +// (or 3s timeout, in which case the probe will report what's actually there). +await page.waitForFunction(() => { + return Array.from(document.querySelectorAll('*')).some((el) => { + const c = el.className; + if (typeof c !== 'string') return false; + return c.includes('context-view') || c.includes('monaco-menu'); + }); +}, { timeout: 3000 }).catch(() => {}); + +// Dump the full HTML to disk so we can grep. +const html = await page.content(); +import('node:fs').then(({ writeFileSync }) => writeFileSync('/tmp/fade-monaco-popup.html', html)); + +const info = await page.evaluate(() => { + // Dump all direct children of document.body so we can see where Monaco + // puts the context menu. + const bodyChildren = Array.from(document.body.children).map((c) => { + const cs = getComputedStyle(c); + return { + tag: c.tagName, + cls: (c.className && typeof c.className === 'string') ? c.className.slice(0, 200) : '', + id: c.id || null, + position: cs.position, + zIndex: cs.zIndex, + display: cs.display, + visibility: cs.visibility, + rect: (() => { const r = c.getBoundingClientRect(); return r.width === 0 && r.height === 0 ? null : { w: Math.round(r.width), h: Math.round(r.height) }; })(), + textPreview: (c.textContent || '').replace(/\s+/g, ' ').slice(0, 100), + }; + }); + // Target the menu by its known vscode class names directly. + const matches = []; + const selectors = ['.context-view', '.context-view.fixed', '.monaco-menu', '.monaco-menu-container']; + for (const sel of selectors) { + const els = document.querySelectorAll(sel); + for (const el of els) { + const cs = getComputedStyle(el); + const r = el.getBoundingClientRect(); + matches.push({ + selector: sel, + tag: el.tagName, + cls: (el.className && typeof el.className === 'string') ? el.className.slice(0, 200) : '', + pos: cs.position, + z: cs.zIndex, + rect: r.width === 0 && r.height === 0 ? null : { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + parentChain: (() => { + const chain = []; + let p = el.parentElement; + let d = 0; + while (p && d < 6) { + const pcs = getComputedStyle(p); + chain.push({ + tag: p.tagName, + id: p.id || null, + cls: (p.className && typeof p.className === 'string') ? p.className.slice(0, 60) : '', + z: pcs.zIndex, + pos: pcs.position, + }); + p = p.parentElement; + d++; + } + return chain; + })(), + }); + } + } + // Brute force: enumerate every element whose computed position is fixed/absolute + // and rect is non-empty, regardless of class name. The menu has to be one of these. + const positioned = []; + const walker = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_ELEMENT); + let node; + while ((node = walker.nextNode())) { + const cs = getComputedStyle(node); + if (cs.position !== 'fixed' && cs.position !== 'absolute') continue; + if (cs.display === 'none' || cs.visibility === 'hidden') continue; + const r = node.getBoundingClientRect(); + if (r.width < 100 || r.height < 50) continue; + const cls = (node.className && typeof node.className === 'string') ? node.className : ''; + positioned.push({ + tag: node.tagName, + cls: cls.slice(0, 200), + id: node.id || null, + pos: cs.position, + z: cs.zIndex, + rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, + }); + } + return { bodyChildren, matches, positioned: positioned.slice(0, 20) }; +}); +console.log(JSON.stringify(info, null, 2)); + +const png = await page.screenshot(); +writeFileSync('/tmp/fade-monaco-popup.png', png); +await browser.close(); diff --git a/Playground/scripts/test-monogame-tests.mjs b/Playground/scripts/test-monogame-tests.mjs new file mode 100644 index 0000000..1bfb346 --- /dev/null +++ b/Playground/scripts/test-monogame-tests.mjs @@ -0,0 +1,256 @@ +// End-to-end smoke for the test-runner + debugger on monogame projects. +// +// Creates a monogame project with three tests (one pass, one fail, one +// abstract), then: +// 1. Asserts listTests via monoGameHost returns all three with the +// right metadata. +// 2. Runs all tests + verifies the run envelope counts + per-test +// results match expectations. +// 3. Starts a debug session against the passing test, asserts +// `{ok: true, statementLines: [...]}`, then terminates. +// +// Doesn't try to drive the UI buttons — talks directly to monoGameHost +// + the dbg dispatcher exposed via __fadeRunnerHelpers — so we test the +// canvas-side bridge without racing the dockview test panel's renders. + +import { chromium } from 'playwright'; + +const URL = process.env.URL || 'http://localhost:5311/'; +const BOOT_BUDGET_MS = 60_000; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +const consoleErrors = []; +const consoleAll = []; +page.on('console', (m) => { + const t = m.text(); + consoleAll.push(`[${m.type()}] ${t}`); + if (m.type() === 'error') consoleErrors.push(t); +}); +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(e.message)); + +console.log(`→ navigate ${URL}`); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); + +// Drop a monogame project with three tests into OPFS and reload. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + const dir = await ws.getDirectoryHandle('mgtests', { create: true }); + const w = async (name, text) => { + const fh = await dir.getFileHandle(name, { create: true }); + const sw = await fh.createWritable(); + await sw.write(text); await sw.close(); + }; + await w('fade.json', JSON.stringify({ + $schema: '/fade.schema.json', + name: 'mgtests', + type: 'monogame', + commandDlls: [], + sources: ['main.fbasic'], + }, null, 2) + '\n'); + await w('main.fbasic', + // Top-level program — does nothing (test mode swaps the VM + // entry into one of the tests below). + 'do\n sync\nloop\n' + + '\n' + + 'test passes\n' + + ' assert 1 = 1\n' + + 'endtest\n' + + '\n' + + 'test fails\n' + + ' assert 1 = 2\n' + + 'endtest\n' + + '\n' + + 'abstract test base\n' + + ' assert 0 = 0\n' + + 'endtest\n'); + localStorage.setItem('fade.activeProject', 'mgtests'); +}); + +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.__fadeBootstrapDone, { timeout: 30_000 }); +await page.waitForFunction(() => { + const el = document.getElementById('status'); + return el && /Ready/i.test(el.textContent || ''); +}, { timeout: 30_000 }); + +// Click Run once so the monogame runtime boots (lazy WASM load + Game1 +// construction). Test debug needs _game to exist on the canvas side. +console.log('→ booting canvas runtime (click Run)…'); +await page.click('#run'); +try { + await page.waitForSelector('#theCanvas', { timeout: BOOT_BUDGET_MS }); +} catch { + console.log('canvas never appeared'); + process.exit(1); +} +// Wait until the Blazor DotNetObjectReference is installed — `window.theInstance` +// gets assigned inside monoGameHost.onInitRenderJS, which fires after Blazor +// renders the Game panel. The canvas appearing doesn't guarantee theInstance +// is set yet (the rAF loop and the DotNet ref handoff race), so block on it +// before the smoke tests start poking through the bridge. +try { + await page.waitForFunction( + () => typeof window.theInstance?.invokeMethodAsync === 'function', + { timeout: BOOT_BUDGET_MS }, + ); +} catch { + console.log('window.theInstance never appeared'); + process.exit(1); +} +await page.waitForTimeout(500); + +const tests = []; +function test(name, fn) { tests.push({ name, fn }); } + +test('listTests returns three entries with correct flags + names', async () => { + const source = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace'); + const dir = await ws.getDirectoryHandle('mgtests'); + const fh = await dir.getFileHandle('main.fbasic'); + return (await fh.getFile()).text(); + }); + const list = await page.evaluate( + (src) => window.__fadeRunnerHelpers.listTests({ source: src }), + source, + ); + // We invoke via runner helpers which always hit the worker. For the + // monogame-aware path we'd go through monoGameHost — exercise that + // directly through window.theInstance. + if (!Array.isArray(list)) throw new Error('worker listTests did not return array'); + + const mg = await page.evaluate(async (src) => { + await window.theInstance.invokeMethodAsync('LoadProgram', 'do\n sync\nloop\n'); // make sure game is alive + const json = await window.theInstance.invokeMethodAsync('ListTests', src); + return JSON.parse(json); + }, source); + if (!Array.isArray(mg)) throw new Error('monoGame ListTests did not return array'); + const byName = new Map(mg.map((t) => [t.name, t])); + if (!byName.has('passes')) throw new Error('missing test "passes"'); + if (!byName.has('fails')) throw new Error('missing test "fails"'); + if (!byName.has('base')) throw new Error('missing test "base"'); + if (!byName.get('base').isAbstract) throw new Error('test "base" should be abstract'); + if (byName.get('passes').isAbstract) throw new Error('test "passes" should NOT be abstract'); + return { count: mg.length, names: mg.map((t) => t.name) }; +}); + +test('runTests on canvas: passing test reports passed=1', async () => { + const r = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace'); + const dir = await ws.getDirectoryHandle('mgtests'); + const fh = await dir.getFileHandle('main.fbasic'); + const src = await (await fh.getFile()).text(); + const json = await window.theInstance.invokeMethodAsync('RunTests', src, 'passes'); + return JSON.parse(json); + }); + if (r.passed !== 1 || r.failed !== 0) { + throw new Error(`expected passed=1 failed=0, got ${JSON.stringify(r)}`); + } + return { passed: r.passed, failed: r.failed }; +}); + +test('runTests on canvas: failing test reports passed=0 failed=1 + reason', async () => { + const r = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace'); + const dir = await ws.getDirectoryHandle('mgtests'); + const fh = await dir.getFileHandle('main.fbasic'); + const src = await (await fh.getFile()).text(); + const json = await window.theInstance.invokeMethodAsync('RunTests', src, 'fails'); + return JSON.parse(json); + }); + if (r.passed !== 0 || r.failed !== 1) { + throw new Error(`expected passed=0 failed=1, got ${JSON.stringify(r)}`); + } + const fail = (r.results || [])[0]; + if (!fail || fail.passed) throw new Error('expected first result to be a failure'); + if (!(fail.failureMessage || fail.failureReason || fail.failureSourceText)) { + throw new Error('failure result has no message/reason/sourceText'); + } + return { passed: r.passed, failed: r.failed, message: (fail.failureMessage || fail.failureReason || '').slice(0, 60) }; +}); + +test('DebugStartTest opens a session paused at the test entry', async () => { + const r = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace'); + const dir = await ws.getDirectoryHandle('mgtests'); + const fh = await dir.getFileHandle('main.fbasic'); + const src = await (await fh.getFile()).text(); + const json = await window.theInstance.invokeMethodAsync('DebugStartTest', src, 'passes'); + return JSON.parse(json); + }); + if (!r.ok) throw new Error('DebugStartTest returned not-ok: ' + JSON.stringify(r)); + if (!Array.isArray(r.statementLines) || r.statementLines.length === 0) { + throw new Error('statementLines should be a non-empty array, got ' + JSON.stringify(r.statementLines)); + } + // Terminate so the canvas doesn't sit in a paused-debug state for + // the rest of this run. + await page.evaluate(() => window.theInstance.invokeMethodAsync('DebugTerminate')); + return { ok: r.ok, statementLineCount: r.statementLines.length }; +}); + +test('DebugStartTest rejects an abstract test cleanly', async () => { + const r = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace'); + const dir = await ws.getDirectoryHandle('mgtests'); + const fh = await dir.getFileHandle('main.fbasic'); + const src = await (await fh.getFile()).text(); + const json = await window.theInstance.invokeMethodAsync('DebugStartTest', src, 'base'); + return JSON.parse(json); + }); + if (r.ok) throw new Error('expected ok=false for abstract test'); + if (!/abstract/i.test(r.error || '')) throw new Error('error message should mention "abstract": ' + r.error); + return { ok: r.ok, error: r.error }; +}); + +test('DebugStartTest rejects an unknown test name with a useful error', async () => { + const r = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace'); + const dir = await ws.getDirectoryHandle('mgtests'); + const fh = await dir.getFileHandle('main.fbasic'); + const src = await (await fh.getFile()).text(); + const json = await window.theInstance.invokeMethodAsync('DebugStartTest', src, 'does-not-exist'); + return JSON.parse(json); + }); + if (r.ok) throw new Error('expected ok=false for unknown test name'); + if (!/no test/i.test(r.error || '')) throw new Error('error message should mention "no test": ' + r.error); + return { ok: r.ok, error: r.error }; +}); + +let passed = 0, failed = 0; +for (const { name, fn } of tests) { + try { + const detail = await fn(); + console.log('PASS', name, detail ? JSON.stringify(detail) : ''); + passed++; + } catch (e) { + console.log('FAIL', name, '\n ', e.message); + failed++; + } +} + +if (pageErrors.length) console.log('PAGE ERRORS:', pageErrors); +if (failed > 0) { + console.log('--- recent console (last 25) ---'); + for (const m of consoleAll.slice(-25)) console.log(' ', m.slice(0, 300)); +} + +// Cleanup. +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace', { create: true }); + try { await ws.removeEntry('mgtests', { recursive: true }); } catch {} + localStorage.setItem('fade.activeProject', 'default'); +}); + +await browser.close(); +process.exit(failed > 0 || pageErrors.length > 0 ? 1 : 0); diff --git a/Playground/src/main.ts b/Playground/src/main.ts index f0ba780..e4e3a6d 100644 --- a/Playground/src/main.ts +++ b/Playground/src/main.ts @@ -74,7 +74,7 @@ import { LEGACY_BINARY_PREVIEW_ID_PREFIX, isBinaryFileName, } from './binary-preview'; -import { patchSoundEffectForKni } from './xnb/xnb-previews'; +import { patchEffectMgfxVersionForKni, patchSoundEffectForKni } from './xnb/xnb-previews'; import { mountHelpPanel } from './help'; import { monoGameHost } from './monogame-host'; import type { CommandDocEntry as HelpCommandDocEntry } from './help'; @@ -2099,6 +2099,13 @@ async function bootstrap() { // edits don't change the type. let lastWorkerProjectType: string | null = null; + // Set after the Help panel is mounted (further down in bootstrap). The + // project-type-change branch below uses this to re-fetch the command + // doc list so Help reflects whichever CommandCollection the LSP just + // swapped to. Null while bootstrap is still wiring things up — the + // initial population happens unconditionally after helpCtl mounts. + let refreshHelpEntriesFromWorker: (() => void) | null = null; + // Live-content lookup: prefer Monaco's model (so dirty edits compile // even before the save timer fires), fall back to the OPFS-persisted // copy. Models exist for every workspace file because bootstrap @@ -2162,6 +2169,11 @@ async function bootstrap() { } } renderProblems(); + // The LSP worker just swapped its CommandCollection (web → + // monogame or vice-versa). Re-fetch the command-doc list + // so the Help tab reflects the new surface. Null-safe for + // the first refresh that runs before helpCtl is mounted. + refreshHelpEntriesFromWorker?.(); } catch (e) { console.warn('[fade] setProjectType failed', e); } @@ -2544,7 +2556,14 @@ async function bootstrap() { renderTests(); return; } - const list = await runner.listTests(source); + // monogame compiles against FadeMonoGameCommands+StandardCommands; + // the worker's command surface may or may not match, depending on + // whether the LSP has swapped command sets. Route through the + // canvas-side bridge for monogame so the test manifest reflects + // the exact compile env the run will use. + const list = currentProject?.type === 'monogame' + ? await monoGameHost.listTests(source) + : await runner.listTests(source); testEntries = list.map((t) => ({ ...t, status: 'idle' as const })); renderTests(); } @@ -2683,6 +2702,34 @@ async function bootstrap() { testsRefreshBtn.disabled = busy; } + // Adapt the canvas-side test-run envelope to the worker-side shape + // applyResult/applyResultAll consume. The canvas-side result is missing + // the `printed` aggregator (test-print streams are best-effort on the + // canvas) and may omit `duration` on the all-tests path; fill defaults + // so the consumer doesn't need to branch. + function mgToTestRunResult(mg: import('./monogame-host').MonoGameRunTestsResult): TestRunResult { + return { + passed: mg.passed, + failed: mg.failed, + duration: mg.duration ?? 0, + // Reuse the per-test result shape — fields align 1:1 modulo + // failureFrames/failureInstructionIndex which the canvas + // bridge doesn't populate yet (FailureFrames support is a + // worker-only thing today; logic-test failures still surface + // via failureMessage + failureSourceText). + results: (mg.results || []).map((r) => ({ + name: r.name, + passed: r.passed, + duration: r.duration, + failureMessage: r.failureMessage, + failureReason: r.failureReason, + failureSourceText: r.failureSourceText, + })), + printed: '', + error: mg.error, + }; + } + async function runSingleTest(name: string) { const source = await getProjectSource(); if (!source) return; @@ -2699,7 +2746,24 @@ async function bootstrap() { revealPanel('output'); appendTestLog(`▶ ${name}`, 'dim'); try { - const r = await runner.runTests(source, name); + // monogame tests run on the canvas runtime so MonoGame + // commands resolve against the live Game1; web tests run + // through the LSP/VM worker. Same shape returned either way, + // modulo `printed` + `duration` which the canvas bridge + // doesn't include — fill them so applyResult can be agnostic. + // + // For monogame: push assets first. The test's bytecode may + // call `texture`/`load sfx clip`/etc. which look the asset + // up via Game1.Content (BrowserContentManager). Without this + // the asset cache is empty and the asset commands log + // "asset 'X' is not registered" — exactly the failure mode + // the user reported before the host-driven test path landed. + if (currentProject?.type === 'monogame') { + await syncAssetsToRuntime(); + } + const r: TestRunResult = currentProject?.type === 'monogame' + ? mgToTestRunResult(await monoGameHost.runTests(source, name)) + : await runner.runTests(source, name); applyResult(r); } catch (e: any) { testEntries[idx].status = 'fail'; @@ -2729,7 +2793,12 @@ async function bootstrap() { revealPanel('output'); appendTestLog(`▶ Run all`, 'dim'); try { - const r = await runner.runTests(source); + if (currentProject?.type === 'monogame') { + await syncAssetsToRuntime(); + } + const r: TestRunResult = currentProject?.type === 'monogame' + ? mgToTestRunResult(await monoGameHost.runTests(source)) + : await runner.runTests(source); applyResult(r); } finally { setTestsBusy(false); @@ -2835,7 +2904,11 @@ async function bootstrap() { // back into a single `debug` panel with collapsible sections (matches // VSCode's Run-and-Debug sidebar). healLayout still recovers older // sessions but the version bump avoids a confusing intermediate state. - const LAYOUT_STORAGE_KEY = 'fade.dockview.layout.v3'; + // Bumped v3 → v4 when the bottom-panel default height shrank and + // Help moved into that group's tab strip. Old v3 layouts persisted + // 240+ px for the bottom group; v4 starts users on the new 180 px + // default so the Help tab doesn't feel oversized. + const LAYOUT_STORAGE_KEY = 'fade.dockview.layout.v4'; function setupDockview(): DockviewApi { const dockRoot = document.getElementById('dock-root')!; @@ -3020,14 +3093,19 @@ async function bootstrap() { position: { referencePanel: bottomRef, direction: 'within' }, renderer: RENDER_ALWAYS, title: 'Debug Console', }); - addMissing('help', { - position: { referencePanel: bottomRef, direction: 'within' }, - renderer: RENDER_ALWAYS, title: 'Help', - }); addMissing('game', { position: { referencePanel: ref?.id ?? 'editor', direction: 'right' }, renderer: RENDER_ALWAYS, title: 'Game', }); + // Help shares the right-column tab group with Game (matches + // buildDefaultLayout). Fall back to the bottom group if Game + // isn't around — that keeps Help reachable even in + // weirdly-broken restored layouts. + const helpRef = dock.getPanel('game')?.id ?? bottomRef; + addMissing('help', { + position: { referencePanel: helpRef, direction: 'within' }, + renderer: RENDER_ALWAYS, title: 'Help', + }); } catch (e) { console.warn('[fade] healLayout failed — falling back to default', e); try { dock.clear(); } catch { /* dockview clear may not exist */ } @@ -3080,13 +3158,17 @@ async function bootstrap() { position: { referencePanel: workspacePanel.id, direction: 'below' }, renderer: RENDER_ALWAYS, }); - // Bottom tab group: Output / Problems / Tests / Debug Console. + // Bottom tab group: Output / Problems / Tests / Debug Console / Help. + // Default height kept modest — the editor + game canvas should + // dominate the viewport, with the bottom panel showing a few lines + // of output by default. Users can drag the splitter taller when + // they want to dig into Tests / Help / etc. const outputPanel = dock.addPanel({ id: 'output', component: 'output', title: 'Output', position: { referencePanel: editorPanel.id, direction: 'below' }, - initialHeight: 240, + initialHeight: 180, renderer: RENDER_ALWAYS, }); dock.addPanel({ @@ -3110,20 +3192,11 @@ async function bootstrap() { position: { referencePanel: outputPanel.id, direction: 'within' }, renderer: RENDER_ALWAYS, }); - // Help: command reference. Lives in the same bottom tab group - // as Output/Problems/Tests so it's a click away from the source. - dock.addPanel({ - id: 'help', - component: 'help', - title: 'Help', - position: { referencePanel: outputPanel.id, direction: 'within' }, - renderer: RENDER_ALWAYS, - }); // Game panel — only meaningful for fade.json type='monogame', but we // add it eagerly so users can preview the empty splash and the runOnce // branch always has somewhere to reveal. Initial size budget biased // toward the editor — game is a peek-on-Run thing for v1. - dock.addPanel({ + const gamePanel = dock.addPanel({ id: 'game', component: 'game', title: 'Game', @@ -3131,6 +3204,17 @@ async function bootstrap() { initialWidth: 360, renderer: RENDER_ALWAYS, }); + // Help: command reference. Shares the right-column tab group with + // Game so users can flip from the running game to the docs in one + // click without losing horizontal real estate to the editor or + // bottom panel. + dock.addPanel({ + id: 'help', + component: 'help', + title: 'Help', + position: { referencePanel: gamePanel.id, direction: 'within' }, + renderer: RENDER_ALWAYS, + }); const out = dock.getPanel('output'); if (out) out.api.setActive(); @@ -3145,7 +3229,7 @@ async function bootstrap() { const ws = dock.getPanel('workspace'); if (ws) ws.api.setSize({ width: 220 }); const o = dock.getPanel('output'); - if (o) o.api.setSize({ height: 240 }); + if (o) o.api.setSize({ height: 180 }); } catch (e) { console.warn('[fade] dockview setSize failed', e); } @@ -3174,10 +3258,18 @@ async function bootstrap() { } // Fire-and-forget on ready — the LSP worker has the workspace // populated by the time runner.ready resolves, so this returns - // every loaded command (Standard + Web + anything else). - void runner.listCommandDocs().then((entries: HelpCommandDocEntry[]) => { - helpCtl.setEntries(entries); - }).catch((e) => console.warn('[fade] help: list-command-docs failed', e)); + // every loaded command (Standard + Web + Standard+MonoGame depending + // on which CommandCollection the worker currently has loaded). + // + // Stash the fetcher in the closure-scoped slot so refreshFadeProject + // (defined earlier in bootstrap) can re-invoke it whenever the LSP's + // CommandCollection swaps because fade.json's `type` changed. + refreshHelpEntriesFromWorker = () => { + void runner.listCommandDocs().then((entries: HelpCommandDocEntry[]) => { + helpCtl.setEntries(entries); + }).catch((e) => console.warn('[fade] help: list-command-docs failed', e)); + }; + refreshHelpEntriesFromWorker(); // Test probe / public API surface. (window as any).__fadeHelp = { @@ -3385,8 +3477,20 @@ async function bootstrap() { fontSize: 14, hover: { enabled: 'on', delay: 200, sticky: true }, 'semanticHighlighting.enabled': true, + // Reparent overflow widgets (suggest popup, hover, signature help) + // to document.body. Doesn't cover the right-click context menu — + // that one ships via the IContextViewService and uses its own + // container; see the reparent-on-mutation observer below. + fixedOverflowWidgets: true, } as monaco.editor.IStandaloneEditorConstructionOptions); + // (Earlier attempt to reparent the context-menu container lived here + // — turned out vscode-vscode-api creates the menu inside a shadow root + // attached to the editor, so a MutationObserver on document.body + // couldn't see it. Real fix is the CSS override in index.html that + // removes `transform: translate3d(0,0,0)` from .dv-render-overlay so + // `position: fixed` once again anchors to the viewport.) + // Watch ALL fade models in the registry and push when any change. Picks // up changes regardless of which model object the live editor uses (we // can have duplicates with the same URI under codingame's services). @@ -3468,7 +3572,10 @@ async function bootstrap() { // OPFS take effect on the next Run. // // SoundEffect XNBs get a loopLength patch on the way through — see - // patchSoundEffectForKni for the KNI Blazor bug it works around. + // patchSoundEffectForKni for the KNI Blazor bug it works around. Effect + // XNBs from modern MGCB (MGFX v11) get the version byte downgraded to v10 + // so KNI 4.2.9001's Effect ctor doesn't reject them; see + // patchEffectMgfxVersionForKni. async function syncAssetsToRuntime(): Promise { await monoGameHost.clearAssets(); const names = await workspace.list(); @@ -3476,7 +3583,7 @@ async function bootstrap() { if (!/\.xnb$/i.test(name)) continue; try { const raw = await workspace.readBytes(name); - const bytes = patchSoundEffectForKni(raw); + const bytes = patchEffectMgfxVersionForKni(patchSoundEffectForKni(raw)); const assetName = name.replace(/\.xnb$/i, ''); await monoGameHost.registerAsset(assetName, bytes); } catch (e) { @@ -4343,11 +4450,12 @@ async function bootstrap() { } // Dispatch debug ops to the right backend based on the active fade.json - // type. 'web' uses the existing worker debug API; 'monogame' uses the - // canvas-side bridge (monoGameHost). Same logical operations on both - // sides — the result shapes match so call sites consume them - // identically. Parses JSON strings returned by monoGameHost so callers - // see the same shape as runner's pre-parsed responses. + // type. 'web' uses the existing worker debug API (runner.debugX); + // 'monogame' uses the canvas-side bridge (monoGameHost.debugX). + // Same logical operations on both sides — the result shapes match + // so call sites consume them identically. Parses JSON strings + // returned by monoGameHost so callers see the same shape as + // runner's pre-parsed responses. const dbg = { start: (source: string): Promise => currentProject?.type === 'monogame' @@ -4357,55 +4465,61 @@ async function bootstrap() { ? syncAssetsToRuntime() .then(() => monoGameHost.debugStart(source)) .then((s) => JSON.parse(s)) - : dbg.start(source), + : runner.debugStart(source), startTest: (source: string, testName: string): Promise => - // No test-debug for monogame yet — tests go through the - // worker today. - dbg.startTest(source, testName), + currentProject?.type === 'monogame' + // Test debug on the canvas needs Game1 not actively + // running the user program — sync assets, then ask the + // canvas runtime to swap in a fresh test-VM. See + // Index.Debug.cs's DebugStartTest. + ? syncAssetsToRuntime() + .then(() => monoGameHost.debugStartTest(source, testName)) + .then((s) => JSON.parse(s)) + : runner.debugStartTest(source, testName), continue: (): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugContinue() - : dbg.continue(), + : runner.debugContinue(), pause: (): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugPause() - : dbg.pause(), + : runner.debugPause(), step: (kind: 'over' | 'in' | 'out'): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugStep(kind) - : dbg.step(kind), + : runner.debugStep(kind), terminate: (): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugTerminate() - : dbg.terminate(), + : runner.debugTerminate(), setBreakpoints: (payload: any): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugSetBreakpoints(JSON.stringify(payload)) - : dbg.setBreakpoints(payload), + : runner.debugSetBreakpoints(payload), stackFrames: (): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugStackFrames().then((s) => JSON.parse(s)) - : dbg.stackFrames(), + : runner.debugStackFrames(), scopes: (frameId: number): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugScopes(frameId).then((s) => JSON.parse(s)) - : dbg.scopes(frameId), + : runner.debugScopes(frameId), expandVariable: (variableId: number): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugVariableExpansion(variableId).then((s) => JSON.parse(s)) - : dbg.expandVariable(variableId), + : runner.debugExpandVariable(variableId), eval: (frameId: number, expression: string): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugEval(frameId, expression).then((s) => JSON.parse(s)) - : dbg.eval(frameId, expression), + : runner.debugEval(frameId, expression), repl: (frameId: number, code: string): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugRepl(frameId, code).then((s) => JSON.parse(s)) - : dbg.repl(frameId, code), + : runner.debugRepl(frameId, code), setVariable: (frameId: number, variableId: number, rhs: string): Promise => currentProject?.type === 'monogame' ? monoGameHost.debugSetVariable(frameId, variableId, rhs).then((s) => JSON.parse(s)) - : dbg.setVariable(frameId, variableId, rhs), + : runner.debugSetVariable(frameId, variableId, rhs), }; const startDebug = async () => { diff --git a/Playground/src/monogame-host.ts b/Playground/src/monogame-host.ts index c8f9d29..68896e7 100644 --- a/Playground/src/monogame-host.ts +++ b/Playground/src/monogame-host.ts @@ -313,6 +313,18 @@ class MonoGameHost { } return await window.theInstance!.invokeMethodAsync('DebugStart') as string; } + + /** Compile + start a debug session at a specific test's entry point. + * Mirrors FadeRunner.debugStartTest's contract — same + * `{ok, error, statementLines}` JSON envelope — but the test runs + * through Game1's main tick loop so MonoGame commands (sprite, + * texture, sync, audio, …) actually have a live GraphicsDevice. */ + async debugStartTest(source: string, testName: string): Promise { + await this.ensureBooted(); + return await window.theInstance!.invokeMethodAsync( + 'DebugStartTest', source, testName, + ) as string; + } async debugTerminate(): Promise { if (!this.isReady()) return; await window.theInstance!.invokeMethodAsync('DebugTerminate'); diff --git a/Playground/src/xnb/xnb-previews.ts b/Playground/src/xnb/xnb-previews.ts index f04a102..b883825 100644 --- a/Playground/src/xnb/xnb-previews.ts +++ b/Playground/src/xnb/xnb-previews.ts @@ -6,7 +6,7 @@ // Decoders never throw on malformed input — they return null. The preview // pane falls back to the metadata card when null comes back. -import { ByteCursor, XnbParseError, type XnbClassification } from './xnb-reader'; +import { ByteCursor, XnbParseError, classifyXnb, type XnbClassification } from './xnb-reader'; // MonoGame SurfaceFormat enum values. Mirrors the .NET enum byte-for-byte // so the int we read at offset 0 of the Texture2D payload maps directly. @@ -221,7 +221,172 @@ function writeAscii(out: Uint8Array, offset: number, s: string) { // int32 loopLength ← patched // int32 durationMs -import { classifyXnb } from './xnb-reader'; +// ─── KNI version-skew workaround: MGFX v11 → v10 downgrader ───────────────── +// Desktop MGCB (recent NuGets) emits MGFX header Version = 11. KNI 4.2.9001's +// Effect constructor caps at v10 and throws "This effect seems to be for a +// newer version of KNI." on anything higher. +// +// The v10 → v11 bump (MonoGame PR #8813, commit 08677e96b) added two strings +// per shader record — `SourceFile` and `Entrypoint` — for runtime error +// diagnostics. Mainline MonoGame's v10 reader is otherwise byte-identical, +// so a v11 blob with those two strings spliced out of every shader record is +// a valid v10 blob. +// +// EffectReader payload (objectData) layout: +// int32 dataSize ← length of MGFX blob; needs adjustment +// bytes 'MGFX' (4) +// byte version ← needs to be rewritten to 10 +// byte profileId +// int32 effectKey ← content hash; written by MGFXC, ignored on read +// ── MGFX body ── +// int32 cbufferCount +// cbufferCount × ConstantBuffer { string name, int16 size, int32 paramCount, +// paramCount × (int32 idx, uint16 offset) } +// int32 shaderCount +// shaderCount × Shader { +// bool isVertexShader +// string SourceFile ← v11 only; SPLICED OUT +// string Entrypoint ← v11 only; SPLICED OUT +// int32 shaderLength +// bytes shaderBytecode [shaderLength] +// byte samplerCount +// samplerCount × Sampler { byte×3 (type, slots), bool hasState, +// [if state: byte×8 (addr×3, color×4, filter) +// + int32×2 (anis, mip) +// + float (bias)] = 20 bytes, +// string name, byte parameter } +// byte cbufferRefCount +// cbufferRefCount × byte +// byte attributeCount +// attributeCount × Attribute { string name, byte usage, byte index, +// int16 location } +// } +// …Parameters + Techniques follow (unchanged, not walked here) +// +// Outputs a fresh Uint8Array with: spliced shader strings removed, MGFX +// version byte set to 10, EffectReader dataSize prefix and XNB header +// fileSize both decremented by the total bytes removed. Idempotent — when +// the input is already v10 or doesn't look like an MGFX effect, returns the +// input unchanged. +const MGFX_VERSION_KNI = 10; + +export function patchEffectMgfxVersionForKni(bytes: Uint8Array): Uint8Array { + let cls; + try { cls = classifyXnb(bytes); } catch { return bytes; } + if (cls.kind !== 'effect' || !cls.objectData) return bytes; + const payloadStart = cls.objectData.byteOffset - bytes.byteOffset; + const od = cls.objectData; + // 4-byte dataSize + 4-byte 'MGFX' magic + 1 version + 1 profile minimum + if (od.length < 10) return bytes; + if ( + od[4] !== 0x4D || od[5] !== 0x47 || od[6] !== 0x46 || od[7] !== 0x58 + ) { + return bytes; // not an MGFX blob in the expected slot; bail + } + const version = od[8]; + if (version === MGFX_VERSION_KNI) return bytes; // already v10 + if (version !== 11) return bytes; // only v11 → v10 is implemented + + // Walk the MGFX body, recording the byte ranges (in objectData coords) + // of every (SourceFile, Entrypoint) string pair so we can splice them + // out. Anything goes wrong, leave bytes alone and let KNI surface the + // real error. + const removeRanges: Array<[number, number]> = []; + try { + const cur = new ByteCursor(od, /* MGFX body starts after */ 14); + + const cbufferCount = cur.readInt32LE(); + for (let c = 0; c < cbufferCount; c++) { + cur.read7BitPrefixedString(); // name + cur.readUint16LE(); // sizeInBytes (int16) + const paramCount = cur.readInt32LE(); + for (let p = 0; p < paramCount; p++) { + cur.readInt32LE(); // paramIdx + cur.readUint16LE(); // offset + } + } + + const shaderCount = cur.readInt32LE(); + for (let s = 0; s < shaderCount; s++) { + cur.readUint8(); // isVertexShader bool + const removeStart = cur.offset; + cur.read7BitPrefixedString(); // SourceFile (v11) + cur.read7BitPrefixedString(); // Entrypoint (v11) + removeRanges.push([removeStart, cur.offset]); + + const shaderLength = cur.readInt32LE(); + cur.readBytes(shaderLength); // bytecode + + const samplerCount = cur.readUint8(); + for (let i = 0; i < samplerCount; i++) { + cur.readUint8(); cur.readUint8(); cur.readUint8(); + if (cur.readUint8() !== 0) { // hasState + cur.readBytes(20); // sampler state body + } + cur.read7BitPrefixedString(); // name + cur.readUint8(); // parameter index + } + + const cbufRefCount = cur.readUint8(); + cur.readBytes(cbufRefCount); // cbuffer indices + + const attrCount = cur.readUint8(); + for (let i = 0; i < attrCount; i++) { + cur.read7BitPrefixedString(); // name + cur.readUint8(); // usage + cur.readUint8(); // index + cur.readUint16LE(); // location (int16; sign-extend + // unused — we just skip) + } + } + } catch { + return bytes; + } + + const totalRemoved = removeRanges.reduce((acc, [a, b]) => acc + (b - a), 0); + + // Build the output. Translate objectData-relative ranges to bytes-relative. + const absRanges = removeRanges.map(([s, e]) => [ + payloadStart + s, payloadStart + e, + ] as [number, number]); + + const out = new Uint8Array(bytes.length - totalRemoved); + let inOff = 0; + let outOff = 0; + for (const [absStart, absEnd] of absRanges) { + const span = absStart - inOff; + out.set(bytes.subarray(inOff, absStart), outOff); + outOff += span; + inOff = absEnd; + } + out.set(bytes.subarray(inOff), outOff); + + // Patch the MGFX version byte. All our splice ranges sit AFTER this byte, + // so the position is the same in both input and output. + out[payloadStart + 8] = MGFX_VERSION_KNI; + + // Patch the EffectReader's `int32 dataSize` prefix (objectData[0..3]). + writeUint32LE(out, payloadStart, readUint32LE(bytes, payloadStart) - totalRemoved); + + // Patch the XNB header's `uint32 fileSize` (at byte offset 6 of the + // file). Compressed XNBs would also have a `decompressedSize` at offset + // 10, but Fade's content pipeline emits with /compress:False, so we don't + // chase that here — if a future caller pipes a compressed XNB through, + // the classifier returns objectData=null and we bail at the top. + writeUint32LE(out, 6, readUint32LE(bytes, 6) - totalRemoved); + + return out; +} + +function readUint32LE(b: Uint8Array, off: number): number { + return (b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24)) >>> 0; +} +function writeUint32LE(b: Uint8Array, off: number, value: number): void { + b[off] = value & 0xFF; + b[off + 1] = (value >>> 8) & 0xFF; + b[off + 2] = (value >>> 16) & 0xFF; + b[off + 3] = (value >>> 24) & 0xFF; +} export function patchSoundEffectForKni(bytes: Uint8Array): Uint8Array { let cls; diff --git a/WebRuntime.MonoGame/Pages/Index.Debug.cs b/WebRuntime.MonoGame/Pages/Index.Debug.cs index 9d8d0ea..9c6778a 100644 --- a/WebRuntime.MonoGame/Pages/Index.Debug.cs +++ b/WebRuntime.MonoGame/Pages/Index.Debug.cs @@ -9,11 +9,21 @@ using System; using System.Collections.Generic; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Fade.MonoGame.Core; using FadeBasic; using FadeBasic.Json; // for Jsonify() extension on DebugMessage etc. using FadeBasic.Launch; +using FadeBasic.Lib.Standard; +using FadeBasic.Sdk; +using FadeBasic.Testing; // FadeTestRunContext, FadeTestSessionContext, FadeTestResult using FadeBasic.Virtual; using Microsoft.JSInterop; +// `using` aliases are file-scoped — Index.razor.cs aliases Fade as FadeSdk +// for unambiguous resolution against Fade.MonoGame.* namespaces; mirror +// that here so DebugStartTest can call FadeSdk.TryCreateFromString. +using FadeSdk = FadeBasic.Sdk.Fade; namespace WebRuntime.MonoGame.Pages { @@ -106,6 +116,157 @@ public string DebugStart() }, _debugJsonOpts); } + // Browser-side "debug a single test" — enters test mode WITH + // debug enabled, then enqueues the named test through the same + // MonoGameTestHost the run-test path uses. Returns immediately + // with a `{ok, statementLines}` envelope so the editor can paint + // breakpoint hint glyphs; the test runs concurrently via Game1's + // tick loop, driving _debugSession just like the regular Debug + // button does. When the test VM completes, _debugSession is kept + // alive (suppressExitOnProgramEnd) so the user can subsequently + // debug another test or resume Run mode. + // + // Why this isn't async like RunTests: the page wants the + // `statementLines` payload synchronously so breakpoints can land + // before the test body executes. The test itself is fire-and- + // forget from this method's perspective — its outcome surfaces + // through the debug event drain (REV_REQUEST_EXPLODE for an + // assertion failure, REV_REQUEST_EXIT for clean completion). + [JSInvokable] + public string DebugStartTest(string source, string testName) + { + try + { + if (_game == null) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Game not booted — Run a monogame program first to warm up the runtime.", + statementLines = Array.Empty(), + }, _debugJsonOpts); + } + + var ctx = CompileForTests(source, out var compileError); + if (ctx == null) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Compile failed:\n" + compileError, + statementLines = Array.Empty(), + }, _debugJsonOpts); + } + + TestManifestEntry foundEntry = null; + foreach (var t in ctx.Compiler.TestManifest) + { + if (string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)) + { + foundEntry = t; + break; + } + } + if (foundEntry == null || foundEntry.isAbstract) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = foundEntry == null + ? $"No test named '{testName}' found in the source." + : $"Test '{testName}' is abstract and cannot be debugged.", + statementLines = Array.Empty(), + }, _debugJsonOpts); + } + + // Enter test mode with debug armed. _debugSession's + // suppressExitOnProgramEnd is flipped inside SetTestMode + // so the session survives the test VM hitting end-of- + // program — letting the user pause/step into a second + // test without re-booting Game1. + _game.SetTestMode(true, withDebug: true); + _paused = false; + + // Sync GameReloader.LatestRuntime to the test ctx so + // Game1.Update's assertion-failure source-map lookup + // reads the right source, not a stale boot stub. + GameReloader.SetBuild(ctx); + + // The host lifecycle (Initialize / BeforeAll) is normally + // awaited inside RunTests's async flow. For debug we + // start the test "fire and forget" so we can return + // statementLines synchronously — kick the lifecycle on + // a background continuation that the rAF tick can drive + // forward while the JS side sets breakpoints + resumes. + EnqueueBasic(DebugMessageType.REQUEST_PAUSE); + _ = QueueTestForDebugAsync(ctx, foundEntry); + + var lines = new SortedSet(); + var dbgData = _game.BrowserDebugSession?.DebugDataAccess; + if (dbgData != null) + { + foreach (var t in dbgData.statementTokens) + { + if (t?.token != null) lines.Add(t.token.lineNumber); + } + } + _status = $"debug test: {testName}"; + StateHasChanged(); + return JsonSerializer.Serialize(new + { + ok = true, + statementLines = lines, + }, _debugJsonOpts); + } + catch (Exception ex) + { + Console.Error.WriteLine("DebugStartTest threw: " + ex); + return JsonSerializer.Serialize(new + { + ok = false, + error = "DebugStartTest exception: " + ex.Message, + statementLines = Array.Empty(), + }, _debugJsonOpts); + } + } + + // Fire-and-forget continuation for DebugStartTest. Awaits the + // host lifecycle + the test's QueueTest TCS in the background; + // any exception is logged but not surfaced (the test outcome + // flows through the debug-event drain). Cancellation is not + // currently wired through — clicking Stop in the debug toolbar + // sends REQUEST_TERMINATE through Index.Debug.cs DebugTerminate + // (which un-pauses the session); the test continues until end of + // program or hits another breakpoint. + private async Task QueueTestForDebugAsync(FadeRuntimeContext launchable, TestManifestEntry entry) + { + try + { + await EnsureHostInitializedAsync(launchable).ConfigureAwait(false); + var hostMethods = HostMethodTable.FromCommandCollection(launchable.CommandCollection); + var runCtx = new FadeTestRunContext(launchable, entry, hostMethods); + _testCts?.Dispose(); + _testCts = new CancellationTokenSource(); + var result = await _testHost.RunTestAsync(runCtx, _testCts.Token).ConfigureAwait(false); + // Surface as a one-shot log so the editor's debug console + // shows whether the assertion held. Detailed failure data + // is also on r.failureMessage/r.failureSourceText. + if (result.passed) + { + Console.WriteLine($"[fade test] {entry.name} passed in {result.duration.TotalMilliseconds:F0}ms"); + } + else + { + var msg = result.failureMessage ?? result.failureReason ?? "test failed"; + Console.Error.WriteLine($"[fade test] {entry.name} FAILED: {msg}"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"DebugStartTest background: {ex}"); + } + } + [JSInvokable] public string DebugTerminate() { diff --git a/WebRuntime.MonoGame/Pages/Index.razor.cs b/WebRuntime.MonoGame/Pages/Index.razor.cs index 12db47b..b14b476 100644 --- a/WebRuntime.MonoGame/Pages/Index.razor.cs +++ b/WebRuntime.MonoGame/Pages/Index.razor.cs @@ -1,12 +1,17 @@ using System; using System.Collections.Generic; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; +using Fade.MonoGame; // MonoGameTestHost using Fade.MonoGame.Core; using Fade.MonoGame.Lib; using FadeBasic; +using FadeBasic.Launch; // ITestLaunchable using FadeBasic.Lib.Standard; using FadeBasic.Sdk; +using FadeBasic.Testing; // IFadeTestHost, FadeTestSessionContext, FadeTestRunContext, FadeTestResult +using FadeBasic.Virtual; // HostMethodTable, TestManifestEntry // FadeBasic.Sdk.Fade collides with the Fade.* MonoGame namespaces in name // resolution. Alias it so `FadeSdk.TryCreateFromString(...)` is unambiguous. using FadeSdk = FadeBasic.Sdk.Fade; @@ -124,11 +129,19 @@ public void OnGameTimedOut(double frameMs) // keeps Game1 + GraphicsDevice alive so the next LoadProgram reloads // instantly. The canvas keeps showing the last frame; we don't // black it out so the user can see what they last saw. + // + // Also halts every active audio instance — WebAudio playback does + // not respect _paused on its own (the rAF tick loop only drives + // VM update/draw, not the audio output), so without this any + // currently-playing `play sfx`-spawned SoundEffectInstance keeps + // emitting samples until the clip naturally ends. [JSInvokable] public void StopGame() { _paused = true; _status = "stopped"; + try { AudioInstanceSystem.StopAll(); } + catch (Exception e) { Console.Error.WriteLine("StopGame: StopAll threw: " + e); } StateHasChanged(); } @@ -216,16 +229,15 @@ private bool LoadProgramInternal(string source, bool initialBoot) } // ─── Testing bridge ──────────────────────────────────────────── - // Mirrors WebRuntime/FadeBridge.cs ListTests/RunTests but compiles - // against FadeMonoGameCommands so graphics-touching tests can call - // sprite/texture/etc. Tests run via FadeRuntimeContext.RunAllTests - // (a fresh VM per test) — they don't disturb the main game's VM. - // GameSystem.game stays set, so commands that read graphics state - // (e.g., `texture` which loads via ContentWatcher) will *attempt* - // their work; commands that need Game1 lifecycle (e.g., `sync`) - // run unbatched against the main GraphicsDevice. Good enough for - // logic tests; graphics tests will land properly once Game1's - // testMode + QueueTest flow is wired into the JS rAF loop. + // Lists + runs tests through the same MonoGameTestHost + + // Game1.QueueTest plumbing that desktop's MTP-driven test runs + // use. Each test runs on the live Game1 VM (so MonoGame commands + // like sprite/texture/sync hit a real GraphicsDevice), with the + // test-queue Update-loop dispatching dequeues + ResetFade-with- + // entry. The host's RunTestAsync awaits a TCS that Game1.Update + // SetResults on test-VM completion; the rAF tick loop keeps + // ticking under the await thanks to RunContinuationsAsynchronously + // on the TCS (see Game1.QueueTest). private static readonly JsonSerializerOptions _testJsonOpts = new() { @@ -233,16 +245,34 @@ private bool LoadProgramInternal(string source, bool initialBoot) IncludeFields = true, }; + // Test session state. The host + session context are constructed + // lazily on first test invocation and reused across calls until + // the page reloads — InitializeAsync / BeforeAllTestsAsync fire + // once per session, not per test, matching MTP's contract. We + // intentionally do NOT call AfterAllTestsAsync between + // interactive single-test invocations because doing so would set + // game.allTestsDone = true and freeze the Update loop's test-mode + // dispatch. + private MonoGameTestHost _testHost; + private FadeTestSessionContext _testSession; + private bool _testHostInitialized; + + // Reusable cancellation source for the active test. The page can + // (eventually) signal cancellation through a JSInvokable; for now + // the source is created per-run and left ungated. + private CancellationTokenSource _testCts; + [JSInvokable] public string ListTests(string source) { try { - var commands = new CommandCollection( - new FadeMonoGameCommands(), - new StandardCommands()); - if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out _)) + var ctx = CompileForTests(source, out var compileError); + if (ctx == null) + { + Console.Error.WriteLine("ListTests compile error: " + compileError); return "[]"; + } var tests = new List(); foreach (var t in ctx.Compiler.TestManifest) @@ -265,53 +295,139 @@ public string ListTests(string source) } } + // Runs a single test (or all concrete tests when testName is + // empty) on the live Game1 VM via MonoGameTestHost. Async because + // host.RunTestAsync awaits the QueueTest TCS that Game1.Update + // resolves when the test VM finishes. [JSInvokable] - public string RunTests(string source, string testName) + public async Task RunTests(string source, string testName) { try { - var commands = new CommandCollection( - new FadeMonoGameCommands(), - new StandardCommands()); + var ctx = CompileForTests(source, out var compileError); + if (ctx == null) + { + return JsonSerializer.Serialize(new + { + passed = 0, + failed = 0, + error = "Compile failed:\n" + compileError, + results = Array.Empty(), + }, _testJsonOpts); + } - if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + if (_game == null) { return JsonSerializer.Serialize(new { passed = 0, failed = 0, - error = "Compile failed:\n" + errors.ToDisplay(), + error = "Game not booted — Run a monogame program first.", results = Array.Empty(), }, _testJsonOpts); } - object payload; + // Resolve which entries we want to execute. Empty + // testName → run every concrete (non-abstract) test in + // the manifest. Named → exactly that one. + var entries = new List(); if (string.IsNullOrWhiteSpace(testName)) { - var run = ctx.RunAllTests(); - payload = new + foreach (var t in ctx.Compiler.TestManifest) { - passed = run.passedCount, - failed = run.failedCount, - duration = run.duration.TotalMilliseconds, - results = ResultsToObjects(run.tests), - }; + if (!t.isAbstract) entries.Add(t); + } } else { - var r = ctx.RunTest(testName); - payload = new + TestManifestEntry hit = null; + foreach (var t in ctx.Compiler.TestManifest) + { + if (string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)) + { + hit = t; + break; + } + } + if (hit == null) + { + return JsonSerializer.Serialize(new + { + passed = 0, + failed = 0, + error = $"No test named '{testName}' found in the source.", + results = Array.Empty(), + }, _testJsonOpts); + } + if (hit.isAbstract) + { + return JsonSerializer.Serialize(new + { + passed = 0, + failed = 0, + error = $"Test '{testName}' is abstract and cannot be run directly.", + results = Array.Empty(), + }, _testJsonOpts); + } + entries.Add(hit); + } + + // Enter test mode without debug. The page-side dbg.start + // path also re-enters test mode with debug for the + // Debug-Test button — see Index.Debug.cs DebugStartTest. + _game.SetTestMode(true, withDebug: false); + _paused = false; + + // Keep LatestRuntime in sync with the test source so + // Game1.Update's assertion-failure source-map lookup uses + // the right FadeRuntimeContext (not a stale boot stub). + GameReloader.SetBuild(ctx); + + await EnsureHostInitializedAsync(ctx).ConfigureAwait(false); + + var startedAt = DateTime.UtcNow; + var results = new List(); + var passedCount = 0; + var failedCount = 0; + _testCts?.Dispose(); + _testCts = new CancellationTokenSource(); + var hostMethods = HostMethodTable.FromCommandCollection(ctx.CommandCollection); + + foreach (var entry in entries) + { + if (_testCts.IsCancellationRequested) break; + var runCtx = new FadeTestRunContext(ctx, entry, hostMethods); + FadeTestResult r; + try { - passed = r.passed ? 1 : 0, - failed = r.passed ? 0 : 1, - duration = r.duration.TotalMilliseconds, - results = ResultsToObjects(new List { r }), - }; + r = await _testHost.RunTestAsync(runCtx, _testCts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + r = new FadeTestResult + { + testName = entry.name, + passed = false, + failureMessage = "test host threw: " + ex.Message, + }; + } + results.Add(r); + if (r.passed) passedCount++; + else failedCount++; } + + var payload = new + { + passed = passedCount, + failed = failedCount, + duration = (DateTime.UtcNow - startedAt).TotalMilliseconds, + results = ResultsToObjects(results), + }; return JsonSerializer.Serialize(payload, _testJsonOpts); } catch (Exception ex) { + Console.Error.WriteLine("RunTests threw: " + ex); return JsonSerializer.Serialize(new { passed = 0, @@ -322,6 +438,43 @@ public string RunTests(string source, string testName) } } + // Shared between ListTests, RunTests, and DebugStartTest — every + // test-related JSInvokable compiles fresh against the FadeMonoGame + // command surface, then walks the produced FadeRuntimeContext + // (which implements ITestLaunchable) to find / iterate tests. + // Returns null + a formatted error message on compile failure. + internal FadeRuntimeContext CompileForTests(string source, out string error) + { + var commands = new CommandCollection( + new FadeMonoGameCommands(), + new StandardCommands()); + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + error = errors.ToDisplay(); + return null; + } + error = null; + return ctx; + } + + // Lazy host bring-up. The host + session-context survive across + // every test invocation in this Index instance's lifetime; only + // the first call pays the InitializeAsync + BeforeAllTestsAsync + // cost. Re-entrancy-safe because each invocation awaits on the + // same _testHostInitialized flag before doing work. + internal async Task EnsureHostInitializedAsync(FadeRuntimeContext launchable) + { + _testHost ??= new MonoGameTestHost(_game); + _testSession ??= new FadeTestSessionContext(launchable, services: null); + + if (!_testHostInitialized) + { + await _testHost.InitializeAsync(_testSession, CancellationToken.None).ConfigureAwait(false); + await _testHost.BeforeAllTestsAsync(_testSession, CancellationToken.None).ConfigureAwait(false); + _testHostInitialized = true; + } + } + private static List ResultsToObjects(List results) { var list = new List(results.Count); From 4d3d052ed04ca0e54a900e56cd4dafbe98f9a9e4 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Wed, 20 May 2026 13:36:49 -0400 Subject: [PATCH 13/30] checpoint --- Playground/index.html | 211 ++++++++++++-- Playground/scripts/test-monogame-tests.mjs | 36 +++ Playground/src/main.ts | 310 +++++++++++++++++---- WebRuntime.MonoGame/Pages/Index.Debug.cs | 29 +- WebRuntime.MonoGame/Pages/Index.razor.cs | 34 +++ WebRuntime/FadeBridge.cs | 13 + WebRuntime/wwwroot/worker.js | 8 + 7 files changed, 559 insertions(+), 82 deletions(-) diff --git a/Playground/index.html b/Playground/index.html index cde5231..651e37a 100644 --- a/Playground/index.html +++ b/Playground/index.html @@ -98,36 +98,145 @@ white-space: nowrap; } .project-name[hidden] { display: none; } - /* Worker heartbeat indicators — one dot per worker (lsp + vm). - Each pulses while its worker is alive and turns red when its - worker hasn't beat in >1.2s. The vm dot going red while the - lsp dot stays green is the signature of a Thread.Sleep / `wait ms` - blocking the VM worker — page interactivity is preserved - because the lsp worker keeps draining messages. */ - .worker-heartbeat { - display: inline-flex; + .grow { flex: 1; } + + /* ─── Diagnostics panel ──────────────────────────────────────────── */ + .diag-body { + padding: 10px 14px; + overflow-y: auto; + height: 100%; + box-sizing: border-box; + font-size: 0.78rem; + } + .diag-section { margin-bottom: 14px; } + .diag-section-title { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + margin-bottom: 6px; + } + .diag-row { + display: flex; align-items: center; - gap: 4px; - margin-left: 0.4rem; + gap: 8px; + padding: 3px 0; + border-bottom: 1px solid var(--border-2); } - .worker-heartbeat .hb-dot { + .diag-row:last-child { border-bottom: none; } + .diag-label { color: var(--fg-muted); flex: 0 0 110px; } + .diag-value { color: var(--fg); flex: 1; word-break: break-all; } + /* Heartbeat dot — shared between diag panel and any future surface */ + .hb-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; + flex-shrink: 0; background: #444; transition: background 100ms linear, box-shadow 100ms linear; } - .worker-heartbeat .hb-dot[data-state="on"] { - background: #4caf50; - box-shadow: 0 0 6px rgba(76, 175, 80, 0.6); + .hb-dot[data-state="on"] { background: #4caf50; box-shadow: 0 0 6px rgba(76,175,80,0.6); } + .hb-dot[data-state="off"] { background: #2e7d32; } + .hb-dot[data-state="busy"] { background: #f14c4c; box-shadow: 0 0 6px rgba(241,76,76,0.6); } + /* ─────────────────────────────────────────────────────────────────── */ + + /* ─── View menu dropdown ─────────────────────────────────────────── */ + .view-menu-wrap { + position: relative; + } + .view-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 1000; + min-width: 220px; + background: var(--bg-3); + border: 1px solid var(--border-2); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + padding: 4px 0; + font-size: 0.78rem; + } + .view-menu[hidden] { display: none; } + .view-menu-section { + padding: 4px 12px 2px; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + user-select: none; } - .worker-heartbeat .hb-dot[data-state="off"] { background: #2e7d32; } - .worker-heartbeat .hb-dot[data-state="busy"] { - background: #f14c4c; - box-shadow: 0 0 6px rgba(241, 76, 76, 0.6); + .view-menu-sep { + height: 1px; + background: var(--border-2); + margin: 4px 0; } - .grow { flex: 1; } + .view-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 12px; + background: transparent; + border: none; + border-radius: 0; + color: var(--fg); + font-size: 0.78rem; + font-family: inherit; + cursor: pointer; + text-align: left; + white-space: nowrap; + } + .view-menu-item:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); } + .view-menu-item:disabled { opacity: 0.4; cursor: default; } + .view-menu-item--danger { color: var(--fg-muted); } + .view-menu-item--danger:hover { color: var(--fg); } + .view-menu-item .check-col { + width: 14px; + text-align: center; + flex-shrink: 0; + color: var(--accent); + } + .view-menu-item .item-label { flex: 1; } + .view-menu-item .item-del { + color: var(--fg-muted); + padding: 0 2px; + font-size: 0.7rem; + line-height: 1; + } + .view-menu-item .item-del:hover { color: var(--fg); } + .view-saved-layout-row { + display: flex; + align-items: center; + } + .view-saved-layout-row .layout-name-btn { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px 5px 12px; + background: transparent; + border: none; + color: var(--fg); + font-size: 0.78rem; + font-family: inherit; + cursor: pointer; + text-align: left; + white-space: nowrap; + } + .view-saved-layout-row .layout-name-btn:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); } + .view-saved-layout-row .layout-del-btn { + padding: 4px 10px; + background: transparent; + border: none; + color: var(--fg-muted); + font-size: 0.7rem; + cursor: pointer; + } + .view-saved-layout-row .layout-del-btn:hover { color: var(--fg); background: var(--bg-hover, rgba(255,255,255,0.06)); } + /* ─────────────────────────────────────────────────────────────────── */ + button { padding: 0.3rem 0.9rem; background: var(--accent); @@ -1449,9 +1558,25 @@

Fade Playground

Loading… - - + +
+ View + +
Run (⌘R) Stop Debug (⌘D) @@ -1640,6 +1765,50 @@

Fade Playground

with the same markdown the hover provider renders. The TOC on the left filters by search; the body on the right shows the selected command's docs. --> +
+
+
+
Workers
+
+ + LSP / Compiler + +
+
+ + VM + +
+
+
+
Worker Runtime
+
+ FadeBasic + +
+
+ .NET + +
+
+
+
MonoGame Runtime
+
+ FadeBasic + +
+
+ KNI + +
+
+ .NET + +
+
+
+
+
diff --git a/Playground/scripts/test-monogame-tests.mjs b/Playground/scripts/test-monogame-tests.mjs index 1bfb346..1d4ef0e 100644 --- a/Playground/scripts/test-monogame-tests.mjs +++ b/Playground/scripts/test-monogame-tests.mjs @@ -226,6 +226,42 @@ test('DebugStartTest rejects an unknown test name with a useful error', async () return { ok: r.ok, error: r.error }; }); +test('game can be re-run via LoadProgram after RunTests completes', async () => { + // RunTests leaves _testMode=true without the fix, which blocks LoadProgram: + // Game1.Update returns early from the test-mode branch before it ever + // reaches the _reloadRequestedFromUi check. + const src = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const ws = await root.getDirectoryHandle('workspace'); + const dir = await ws.getDirectoryHandle('mgtests'); + const fh = await dir.getFileHandle('main.fbasic'); + return (await fh.getFile()).text(); + }); + + // Run a test to enter (and exit) test mode. + const testJson = await page.evaluate( + (s) => window.theInstance.invokeMethodAsync('RunTests', s, 'passes'), + src, + ); + const testResult = JSON.parse(testJson); + if (testResult.passed !== 1) throw new Error('setup: expected passing test, got ' + JSON.stringify(testResult)); + + // Now call LoadProgram — this is the path the Run button takes. + const ok = await page.evaluate(() => + window.theInstance.invokeMethodAsync('LoadProgram', 'do\n sync\nloop\n'), + ); + if (!ok) throw new Error('LoadProgram returned false after tests completed'); + + // Wait a few frames and confirm the runtime is still alive (no crash). + await page.waitForTimeout(600); + const alive = await page.evaluate(() => + typeof window.theInstance?.invokeMethodAsync === 'function', + ); + if (!alive) throw new Error('theInstance died after LoadProgram following RunTests'); + + return { ok }; +}); + let passed = 0, failed = 0; for (const { name, fn } of tests) { try { diff --git a/Playground/src/main.ts b/Playground/src/main.ts index e4e3a6d..af46bc3 100644 --- a/Playground/src/main.ts +++ b/Playground/src/main.ts @@ -111,7 +111,12 @@ const statusEl = document.getElementById('status')!; const runBtn = document.getElementById('run') as HTMLElement & { disabled: boolean }; const stopBtn = document.getElementById('stop') as HTMLElement & { disabled: boolean }; const debugBtn = document.getElementById('debug') as HTMLElement & { disabled: boolean }; -const resetLayoutBtn = document.getElementById('reset-layout') as HTMLElement; +const viewMenuBtn = document.getElementById('view-menu-btn') as HTMLElement; +const viewMenu = document.getElementById('view-menu') as HTMLElement; +const viewMenuPanels = document.getElementById('view-menu-panels') as HTMLElement; +const viewSaveLayoutBtn = document.getElementById('view-save-layout') as HTMLButtonElement; +const viewResetLayoutBtn = document.getElementById('view-reset-layout') as HTMLButtonElement; +const viewSavedLayouts = document.getElementById('view-saved-layouts') as HTMLElement; const newFileBtn = document.getElementById('new-file') as HTMLButtonElement; const fileListEl = document.getElementById('file-list')!; const tabsEl = document.getElementById('tabs')!; @@ -803,6 +808,7 @@ class FadeRunner { if (msg.type === 'set-project-type-result') { this.resolvePending(msg.id, msg.projectType); return; } if (msg.type === 'list-tests-result') { this.resolvePending(msg.id, msg.tests); return; } if (msg.type === 'list-command-docs-result') { this.resolvePending(msg.id, msg.docs); return; } + if (msg.type === 'get-version-info-result') { this.resolvePending(msg.id, msg.info); return; } if (msg.type === 'run-tests-result') { this.resolvePending(msg.id, msg.result); return; } if (msg.type === 'debug-start-result') { this.resolvePending(msg.id, msg.result); return; } if (msg.type === 'debug-terminate-result' @@ -1077,6 +1083,17 @@ class FadeRunner { }); } + async getVersionInfo(): Promise<{ fadeBasic: string; dotnet: string } | null> { + const id = ++this.nextId; + return new Promise((resolve) => { + this.pending.set(id, (json: string) => { + try { resolve(JSON.parse(json)); } + catch { resolve(null); } + }); + this.lspWorker.postMessage({ type: 'get-version-info', id }); + }); + } + async runTests(source: string, testName?: string): Promise { const id = ++this.nextId; return new Promise((resolve) => { @@ -1455,37 +1472,35 @@ async function bootstrap() { statusEl.textContent = 'Booting Fade runtime worker…'; - // Heartbeat indicators. Each worker (lsp + vm) posts a beat every - // 500ms; the corresponding dot pulses while alive and turns red when - // we haven't heard from it in >1.2s. The vm dot going red while the - // lsp dot stays green is the signature of a Thread.Sleep / `wait ms` - // blocking the VM worker — the page itself stays responsive because - // the lsp worker keeps draining messages. - const heartbeatEl = document.getElementById('worker-heartbeat')!; + // Heartbeat indicators — displayed in the Diagnostics panel. + // Each worker (lsp + vm) posts a beat every 500ms; the dot pulses + // while alive and turns red when we haven't heard from it in >1.2s. type BeatState = { lastAt: number; tick: number }; const beats: { lsp: BeatState; vm: BeatState } = { lsp: { lastAt: Date.now(), tick: 0 }, vm: { lastAt: Date.now(), tick: 0 }, }; - // Render two dots inside the heartbeat span — lsp on the left, vm - // on the right. Tooltip carries the freeze hint for the busy state. - heartbeatEl.innerHTML = ` - - - `; - const dotLsp = heartbeatEl.querySelector('.hb-dot[data-role="lsp"]')!; - const dotVm = heartbeatEl.querySelector('.hb-dot[data-role="vm"]')!; + const dotLsp = document.getElementById('diag-dot-lsp') as HTMLElement; + const dotVm = document.getElementById('diag-dot-vm') as HTMLElement; + const detailLsp = document.getElementById('diag-lsp-detail') as HTMLElement; + const detailVm = document.getElementById('diag-vm-detail') as HTMLElement; function paintHeartbeat() { - for (const [role, el] of [['lsp', dotLsp], ['vm', dotVm]] as const) { + for (const [role, dot, detail] of [ + ['lsp', dotLsp, detailLsp], + ['vm', dotVm, detailVm], + ] as const) { const b = beats[role]; const dt = Date.now() - b.lastAt; - el.dataset.state = dt > 1200 ? 'busy' : (b.tick % 2 === 0 ? 'on' : 'off'); - el.title = dt > 1200 - ? `${role} worker is busy — last beat ${(dt / 1000).toFixed(1)}s ago.` - + (role === 'vm' - ? ' Likely Thread.Sleep / wait ms inside user code.' - : '') - : `${role} worker alive — beat ${b.tick}`; + const busy = dt > 1200; + dot.dataset.state = busy ? 'busy' : (b.tick % 2 === 0 ? 'on' : 'off'); + if (busy) { + const hint = role === 'vm' ? ' (Thread.Sleep / wait ms?)' : ''; + dot.title = `${role} worker busy — last beat ${(dt / 1000).toFixed(1)}s ago${hint}`; + detail.textContent = `stalled ${(dt / 1000).toFixed(1)}s ago${hint}`; + } else { + dot.title = `${role} worker alive — beat ${b.tick}`; + detail.textContent = `alive — beat #${b.tick}`; + } } } setInterval(paintHeartbeat, 250); @@ -3009,7 +3024,7 @@ async function bootstrap() { const KNOWN_COMPONENTS = new Set([ 'workspace', 'editor', 'debug', 'output', 'problems', 'tests', 'debug-console', - 'help', + 'game', 'help', 'diagnostics', // Dynamic — created on demand by the markdown preview button. 'markdown-preview', // Dynamic — created on demand when a binary file is opened @@ -3271,17 +3286,203 @@ async function bootstrap() { }; refreshHelpEntriesFromWorker(); + // Populate the Diagnostics panel version rows from the worker runtime. + void runner.getVersionInfo().then((info) => { + if (!info) return; + const el = (id: string) => document.getElementById(id); + const short = (v: string) => v.split('+')[0]; // strip git hash suffix + const wFade = el('diag-w-fade'); + const wDot = el('diag-w-dotnet'); + if (wFade) wFade.textContent = short(info.fadeBasic); + if (wDot) wDot.textContent = info.dotnet; + }).catch(() => { /* diagnostics are best-effort */ }); + + // MonoGame runtime versions — polled until theInstance is available. + // Blazor boots lazily (Game panel must open first), so we wait up to + // 5 minutes without forcing a boot ourselves. + { + let mgVersionFetched = false; + const mgPollHandle = setInterval(async () => { + if (mgVersionFetched || !window.theInstance?.invokeMethodAsync) return; + mgVersionFetched = true; + clearInterval(mgPollHandle); + try { + const json = await window.theInstance.invokeMethodAsync('GetVersionInfo') as string; + const info = JSON.parse(json) as { fadeBasic: string; kni: string; dotnet: string }; + const el = (id: string) => document.getElementById(id); + const short = (v: string) => v.split('+')[0]; + const mgFade = el('diag-mg-fade'); + const mgKni = el('diag-mg-kni'); + const mgDot = el('diag-mg-dotnet'); + if (mgFade) mgFade.textContent = short(info.fadeBasic); + if (mgKni) mgKni.textContent = info.kni; + if (mgDot) mgDot.textContent = info.dotnet; + } catch { /* diagnostics are best-effort */ } + }, 1000); + } + // Test probe / public API surface. (window as any).__fadeHelp = { openCommand: (name: string) => openHelpForCommand(name), getController: () => helpCtl, }; - // Reset Layout: nuke the persisted layout and reload. Useful when - // something puts the dock into an awkward state we can't recover via - // healLayout alone. - resetLayoutBtn.addEventListener('click', () => { - if (!confirm('Reset all panel layout to defaults?')) return; + // ── View menu ───────────────────────────────────────────────────────────── + // All static panels the user can open/close via the View menu. + const VIEW_PANELS: Array<{ id: string; label: string }> = [ + { id: 'editor', label: 'Editor' }, + { id: 'workspace', label: 'Workspace' }, + { id: 'debug', label: 'Debug' }, + { id: 'output', label: 'Output' }, + { id: 'problems', label: 'Problems' }, + { id: 'tests', label: 'Tests' }, + { id: 'debug-console', label: 'Debug Console' }, + { id: 'game', label: 'Game' }, + { id: 'help', label: 'Help' }, + { id: 'diagnostics', label: 'Diagnostics' }, + ]; + const SAVED_LAYOUTS_KEY = 'fade.dockview.savedLayouts'; + + function loadSavedLayouts(): Array<{ name: string; layout: object }> { + try { + const raw = localStorage.getItem(SAVED_LAYOUTS_KEY); + if (raw) return JSON.parse(raw) as Array<{ name: string; layout: object }>; + } catch { /* ignore */ } + return []; + } + + function saveSavedLayouts(layouts: Array<{ name: string; layout: object }>) { + try { localStorage.setItem(SAVED_LAYOUTS_KEY, JSON.stringify(layouts)); } catch { /* ignore */ } + } + + // Open a named panel that is currently absent from the dock. + // Core panels use healLayout (which knows their default positions). + // Panels that are hidden by default (e.g. diagnostics) are added + // directly into a sensible group rather than through healLayout, + // so they don't get injected into every restored layout. + function openPanelById(id: string) { + const RENDER_ALWAYS = 'always' as const; + if (id === 'diagnostics') { + const ref = dockApi.getPanel('output')?.id + ?? dockApi.getPanel('problems')?.id + ?? dockApi.getPanel('editor')?.id; + try { + dockApi.addPanel({ + id: 'diagnostics', + component: 'diagnostics', + title: 'Diagnostics', + position: ref ? { referencePanel: ref, direction: 'within' } : undefined, + renderer: RENDER_ALWAYS, + }); + dockApi.getPanel('diagnostics')?.api?.setActive(); + } catch (e) { console.warn('[fade] failed to open diagnostics panel', e); } + } else { + // For standard panels, healLayout handles re-adding with the + // right default position. + healLayout(dockApi); + } + } + + function renderViewMenu() { + // ── Panels list ─────────────────────────────────────────────────── + viewMenuPanels.innerHTML = ''; + const openIds = new Set(dockApi.panels.map((p) => p.id)); + for (const { id, label } of VIEW_PANELS) { + const isOpen = openIds.has(id); + const btn = document.createElement('button'); + btn.className = 'view-menu-item'; + btn.innerHTML = `${isOpen ? '✓' : ''}${label}`; + btn.addEventListener('click', () => { + closeViewMenu(); + if (isOpen) { + // Panel is open — activate it. + try { dockApi.getPanel(id)?.api?.setActive(); } catch { /* ignore */ } + } else { + openPanelById(id); + } + }); + viewMenuPanels.appendChild(btn); + } + + // ── Saved layouts ───────────────────────────────────────────────── + viewSavedLayouts.innerHTML = ''; + const saved = loadSavedLayouts(); + for (let i = 0; i < saved.length; i++) { + const { name, layout } = saved[i]; + const row = document.createElement('div'); + row.className = 'view-saved-layout-row'; + + const nameBtn = document.createElement('button'); + nameBtn.className = 'layout-name-btn'; + nameBtn.innerHTML = `${name}`; + nameBtn.addEventListener('click', () => { + closeViewMenu(); + try { + dockApi.fromJSON(layout as any); + } catch (e) { + console.warn('[fade] failed to restore layout', e); + healLayout(dockApi); + } + }); + + const delBtn = document.createElement('button'); + delBtn.className = 'layout-del-btn'; + delBtn.title = `Delete "${name}"`; + delBtn.textContent = '✕'; + delBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const layouts = loadSavedLayouts(); + layouts.splice(i, 1); + saveSavedLayouts(layouts); + renderViewMenu(); + }); + + row.appendChild(nameBtn); + row.appendChild(delBtn); + viewSavedLayouts.appendChild(row); + } + } + + function openViewMenu() { + renderViewMenu(); + viewMenu.removeAttribute('hidden'); + // Close on outside click. + setTimeout(() => document.addEventListener('click', onOutsideClick), 0); + } + + function closeViewMenu() { + viewMenu.setAttribute('hidden', ''); + document.removeEventListener('click', onOutsideClick); + } + + function onOutsideClick(e: MouseEvent) { + if (!viewMenu.contains(e.target as Node) && e.target !== viewMenuBtn) { + closeViewMenu(); + } + } + + viewMenuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (viewMenu.hasAttribute('hidden')) openViewMenu(); + else closeViewMenu(); + }); + + viewSaveLayoutBtn.addEventListener('click', () => { + closeViewMenu(); + const name = prompt('Layout name:'); + if (!name?.trim()) return; + const layouts = loadSavedLayouts(); + // Replace existing layout with the same name if present. + const idx = layouts.findIndex((l) => l.name === name.trim()); + const entry = { name: name.trim(), layout: dockApi.toJSON() as object }; + if (idx >= 0) layouts[idx] = entry; + else layouts.push(entry); + saveSavedLayouts(layouts); + }); + + viewResetLayoutBtn.addEventListener('click', () => { + closeViewMenu(); + if (!confirm('Reset all panels to the default layout?')) return; try { localStorage.removeItem(LAYOUT_STORAGE_KEY); } catch { /* ignore */ } location.reload(); }); @@ -4402,26 +4603,33 @@ async function bootstrap() { break; } case 'PROTO_ACK': { - // Two kinds of PROTO_ACKs reach us: - // 1. StepNextResponseMessage with status=1 — a step landed - // successfully. DebugSession only signals step completion - // this way (no separate stop event), so the native DAP - // adapter translates this ACK into a DAP "Stopped" event. - // We do the same here: refresh the call stack + variables - // and stay paused. - // 2. Plain ACK for set-breakpoints / continue / etc. — - // treat as "session resumed". + // Two kinds of PROTO_ACKs require UI updates: + // 1. StepNextResponseMessage with status=1 — a step landed. + // DebugSession signals this only via PROTO_ACK (no separate + // stop event), so we treat it like a "Stopped" event: + // refresh the call stack + variables and stay paused. + // 2. Breakpoints-resync ACK — carries a `breakpoints` array. + // Fired whenever syncBreakpointsToWorker() sends + // REQUEST_BREAKPOINTS. No state change; ignore. + // + // All other PROTO_ACKs (continue, pause, initial-pause) are + // handled by their button/call-site handlers, which already + // update debugPaused synchronously. Acting on them here would + // race or override that state. let stepLanded = false; + let isBreakpointsAck = false; if (event.json) { try { const parsed = JSON.parse(event.json); if (parsed && parsed.status === 1 && typeof parsed.reason === 'string') { stepLanded = true; + } else if (parsed && Array.isArray(parsed.breakpoints)) { + isBreakpointsAck = true; } } catch { /* not a structured response */ } } // eslint-disable-next-line no-console - console.log('[DBG-EV] PROTO_ACK stepLanded=', stepLanded); + console.log('[DBG-EV] PROTO_ACK stepLanded=', stepLanded, 'isBreakpointsAck=', isBreakpointsAck); if (stepLanded) { debugPaused = true; setDebugStatus('paused on step', 'paused'); @@ -4429,17 +4637,6 @@ async function bootstrap() { await refreshDebugView(); // eslint-disable-next-line no-console console.log('[DBG-EV] STEP refreshDebugView done'); - } else { - debugPaused = false; - setDebugStatus('running', 'running'); - setCurrentLine(null); - // While running, show a guidance message in the inspection - // panes instead of "No active debug session" — there IS - // a session, the user just can't see anything yet. - setDebugEmptyStates(true, 'Running — hit a breakpoint or pause to inspect'); - setDebugButtons(); - // eslint-disable-next-line no-console - console.log('[DBG-EV] PROTO_ACK fell into RUNNING branch — var panel cleared'); } break; } @@ -4639,7 +4836,16 @@ async function bootstrap() { }); debugPauseBtn.addEventListener('click', async () => { await dbg.pause(); - // The 'paused' state is asserted by the next breakpoint event. + // REQUEST_PAUSE is now in-flight. The PROTO_ACK that comes back + // is a plain ack with no useful payload — the desktop DAP turns + // it into a StoppedEvent via a callback, but we have no callback + // here. Update paused state immediately and sample the call stack + // from the current instruction pointer — the VM stops on the + // next iteration of its debug loop, so frames are accurate enough. + debugPaused = true; + setDebugStatus('paused', 'paused'); + setDebugButtons(); + await refreshDebugView(); }); debugStepOverBtn.addEventListener('click', async () => { await dbg.step('over'); diff --git a/WebRuntime.MonoGame/Pages/Index.Debug.cs b/WebRuntime.MonoGame/Pages/Index.Debug.cs index 9c6778a..f101e48 100644 --- a/WebRuntime.MonoGame/Pages/Index.Debug.cs +++ b/WebRuntime.MonoGame/Pages/Index.Debug.cs @@ -248,22 +248,33 @@ private async Task QueueTestForDebugAsync(FadeRuntimeContext launchable, TestMan _testCts?.Dispose(); _testCts = new CancellationTokenSource(); var result = await _testHost.RunTestAsync(runCtx, _testCts.Token).ConfigureAwait(false); - // Surface as a one-shot log so the editor's debug console - // shows whether the assertion held. Detailed failure data - // is also on r.failureMessage/r.failureSourceText. if (result.passed) - { Console.WriteLine($"[fade test] {entry.name} passed in {result.duration.TotalMilliseconds:F0}ms"); - } else - { - var msg = result.failureMessage ?? result.failureReason ?? "test failed"; - Console.Error.WriteLine($"[fade test] {entry.name} FAILED: {msg}"); - } + Console.Error.WriteLine($"[fade test] {entry.name} FAILED: {result.failureMessage ?? result.failureReason ?? "test failed"}"); + + // Test VM has finished. Send REV_REQUEST_EXITED so the + // editor clears its debug UI. The auto-emit is suppressed + // by suppressExitOnProgramEnd=true, so we fire it manually. + // We do NOT pause here — TickDotNet skips the drain when + // _paused=true, so the EXITED message must be left to drain + // on the next tick. The game idles safely (Quit() is a no-op + // in browser) until the user clicks Run to reload. + try { _game?.BrowserDebugSession?.SendExitedMessage(); } + catch (Exception e) { Console.Error.WriteLine("QueueTestForDebugAsync SendExited: " + e); } + + // Stop audio and exit test mode so the next LoadProgram + // (Run button) reloads normally. + try { AudioInstanceSystem.StopAll(); } + catch (Exception e) { Console.Error.WriteLine("QueueTestForDebugAsync StopAll: " + e); } + _game?.SetTestMode(false); + _status = "debug test done"; + StateHasChanged(); } catch (Exception ex) { Console.Error.WriteLine($"DebugStartTest background: {ex}"); + _game?.SetTestMode(false); } } diff --git a/WebRuntime.MonoGame/Pages/Index.razor.cs b/WebRuntime.MonoGame/Pages/Index.razor.cs index b14b476..f80934b 100644 --- a/WebRuntime.MonoGame/Pages/Index.razor.cs +++ b/WebRuntime.MonoGame/Pages/Index.razor.cs @@ -181,6 +181,25 @@ public void ClearAssets() _game?.BrowserContent?.ClearAssets(); } + // Returns JSON with FadeBasic + KNI + .NET version strings for the + // browser's Diagnostics panel. + [JSInvokable] + public string GetVersionInfo() + { + var asm = typeof(FadeBasic.Virtual.VirtualMachine).Assembly; + var attrs = (System.Reflection.AssemblyInformationalVersionAttribute[]) + asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false); + var fadeVersion = attrs.Length > 0 ? attrs[0].InformationalVersion : asm.GetName().Version?.ToString() ?? "unknown"; + var kniVersion = typeof(Microsoft.Xna.Framework.Game).Assembly.GetName().Version?.ToString() ?? "unknown"; + var dotnetVersion = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; + return System.Text.Json.JsonSerializer.Serialize(new + { + fadeBasic = fadeVersion, + kni = kniVersion, + dotnet = dotnetVersion, + }); + } + private bool LoadProgramInternal(string source, bool initialBoot) { try @@ -423,11 +442,26 @@ public async Task RunTests(string source, string testName) duration = (DateTime.UtcNow - startedAt).TotalMilliseconds, results = ResultsToObjects(results), }; + + // Exit test mode so the next LoadProgram (Run button) can + // reload normally. Without this, _testMode stays true and + // Game1.Update returns early from the test-mode block before + // it ever reaches the _reloadRequestedFromUi check, blocking + // any subsequent game run. + _game?.SetTestMode(false); + _paused = true; + try { AudioInstanceSystem.StopAll(); } + catch (Exception e) { Console.Error.WriteLine("RunTests StopAll: " + e); } + _status = "tests done"; + StateHasChanged(); + return JsonSerializer.Serialize(payload, _testJsonOpts); } catch (Exception ex) { Console.Error.WriteLine("RunTests threw: " + ex); + _game?.SetTestMode(false); + _paused = true; return JsonSerializer.Serialize(new { passed = 0, diff --git a/WebRuntime/FadeBridge.cs b/WebRuntime/FadeBridge.cs index 7a1581b..09fcb73 100644 --- a/WebRuntime/FadeBridge.cs +++ b/WebRuntime/FadeBridge.cs @@ -905,5 +905,18 @@ private sealed class BreakpointRequestDto public int Line { get; set; } public int Column { get; set; } } + + // Returns a JSON object with FadeBasic + .NET runtime version strings + // for display in the browser's Diagnostics panel. + [JSExport] + public static string GetVersionInfo() + { + var asm = typeof(FadeBasic.Virtual.VirtualMachine).Assembly; + var attrs = (System.Reflection.AssemblyInformationalVersionAttribute[]) + asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false); + var fadeVersion = attrs.Length > 0 ? attrs[0].InformationalVersion : asm.GetName().Version?.ToString() ?? "unknown"; + var dotnetVersion = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; + return JsonSerializer.Serialize(new { fadeBasic = fadeVersion, dotnet = dotnetVersion }); + } } diff --git a/WebRuntime/wwwroot/worker.js b/WebRuntime/wwwroot/worker.js index be23364..c009a54 100644 --- a/WebRuntime/wwwroot/worker.js +++ b/WebRuntime/wwwroot/worker.js @@ -449,6 +449,14 @@ function handle(msg) { log('list-command-docs failed: ' + (e?.message ?? e)); } self.postMessage({ type: 'list-command-docs-result', id: msg.id, docs: json }); + } else if (msg.type === 'get-version-info') { + let json = '{}'; + try { + json = exports.WebRuntime.FadeBridge.GetVersionInfo(); + } catch (e) { + log('get-version-info failed: ' + (e?.message ?? e)); + } + self.postMessage({ type: 'get-version-info-result', id: msg.id, info: json }); } else if (msg.type === 'run-tests') { let json = '{}'; try { From c92abe73afa2b35c4848bb73038c9b933ba83460 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Thu, 21 May 2026 15:39:36 -0400 Subject: [PATCH 14/30] checkpoint --- .gitignore | 5 + .../FadeBasic.Export.Web}/DAP_AUDIT.md | 0 .../FadeBasic.Export.Web.csproj | 90 + FadeBasic/FadeBasic.Export.Web/FadeBridge.cs | 1601 +++++++++++++++++ .../FadeBasic.Export.Web}/Program.cs | 0 .../Properties/launchSettings.json | 0 .../StandardCommandDocs.cs | 8 +- .../FadeBasic.Export.Web}/WebDebugSession.cs | 4 +- .../build/FadeBasic.Export.Web.targets | 59 + .../FadeBasic.Export.Web}/wwwroot/css/app.css | 0 .../FadeBasic.Export.Web/wwwroot/index.html | 317 ++++ .../wwwroot/web-commands.js | 0 .../FadeBasic.Export.Web}/wwwroot/worker.html | 4 +- .../FadeBasic.Export.Web/wwwroot/worker.js | 744 ++++++++ .../FadeBasic.Lib.Web.csproj | 22 + FadeBasic/FadeBasic.Lib.Web/WebCommands.cs | 94 + FadeBasic/FadeBasic.sln | 12 + FadeBasic/FadeBasic/Launch/LaunchUtil.cs | 4 + FadeBasic/FadeBasic/Sdk/FadeTestRunner.cs | 77 +- FadeBasic/FadeBasic/Virtual/HostBridge.cs | 56 + FadeBasic/FadeBasic/Virtual/HostStackOps.cs | 101 ++ FadeBasic/Tests/CooperativePumpTests.cs | 217 +++ FadeBasic/Tests/Tests.csproj | 1 + FadeBasic/build.sln | 8 +- FadeBasic/global.json | 2 +- FadeBasic/install.sh | 33 +- Playground/index.html | 569 +++++- Playground/package-lock.json | 30 + Playground/package.json | 2 + Playground/public/fade.schema.json | 22 +- Playground/scripts/build-monogame-runtime.mjs | 4 +- Playground/scripts/build-runtime.mjs | 71 +- Playground/src/ai-chat.ts | 1163 ++++++++++++ Playground/src/ai-worker.ts | 4 + Playground/src/fade-config.ts | 23 +- Playground/src/help.ts | 2 +- Playground/src/main.ts | 938 ++++++++-- WebRuntime.MonoGame/App.razor | 11 - WebRuntime.MonoGame/MainLayout.razor | 3 - WebRuntime.MonoGame/Pages/Index.Debug.cs | 451 ----- WebRuntime.MonoGame/Pages/Index.razor | 24 - WebRuntime.MonoGame/Pages/Index.razor.cs | 537 ------ WebRuntime.MonoGame/Program.cs | 26 - .../WebRuntime.MonoGame.csproj | 47 - WebRuntime.MonoGame/_Imports.razor | 6 - WebRuntime.MonoGame/global.json | 7 - WebRuntime.MonoGame/scripts/boot-check.mjs | 116 -- WebRuntime.MonoGame/scripts/reload-check.mjs | 88 - .../scripts/test-bridge-check.mjs | 81 - WebRuntime.MonoGame/wwwroot/css/app.css | 87 - WebRuntime.MonoGame/wwwroot/index.html | 160 -- WebRuntime/FadeBridge.cs | 922 ---------- WebRuntime/WebCommands.cs | 84 - WebRuntime/WebRuntime.csproj | 41 - WebRuntime/global.json | 6 - WebRuntime/wwwroot/index.html | 54 - WebRuntime/wwwroot/worker.js | 481 ----- 57 files changed, 6108 insertions(+), 3411 deletions(-) rename {WebRuntime => FadeBasic/FadeBasic.Export.Web}/DAP_AUDIT.md (100%) create mode 100644 FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj create mode 100644 FadeBasic/FadeBasic.Export.Web/FadeBridge.cs rename {WebRuntime => FadeBasic/FadeBasic.Export.Web}/Program.cs (100%) rename {WebRuntime => FadeBasic/FadeBasic.Export.Web}/Properties/launchSettings.json (100%) rename {WebRuntime => FadeBasic/FadeBasic.Export.Web}/StandardCommandDocs.cs (88%) rename {WebRuntime => FadeBasic/FadeBasic.Export.Web}/WebDebugSession.cs (98%) create mode 100644 FadeBasic/FadeBasic.Export.Web/build/FadeBasic.Export.Web.targets rename {WebRuntime => FadeBasic/FadeBasic.Export.Web}/wwwroot/css/app.css (100%) create mode 100644 FadeBasic/FadeBasic.Export.Web/wwwroot/index.html rename {WebRuntime => FadeBasic/FadeBasic.Export.Web}/wwwroot/web-commands.js (100%) rename {WebRuntime => FadeBasic/FadeBasic.Export.Web}/wwwroot/worker.html (97%) create mode 100644 FadeBasic/FadeBasic.Export.Web/wwwroot/worker.js create mode 100644 FadeBasic/FadeBasic.Lib.Web/FadeBasic.Lib.Web.csproj create mode 100644 FadeBasic/FadeBasic.Lib.Web/WebCommands.cs create mode 100644 FadeBasic/FadeBasic/Virtual/HostBridge.cs create mode 100644 FadeBasic/FadeBasic/Virtual/HostStackOps.cs create mode 100644 FadeBasic/Tests/CooperativePumpTests.cs create mode 100644 Playground/src/ai-chat.ts create mode 100644 Playground/src/ai-worker.ts delete mode 100644 WebRuntime.MonoGame/App.razor delete mode 100644 WebRuntime.MonoGame/MainLayout.razor delete mode 100644 WebRuntime.MonoGame/Pages/Index.Debug.cs delete mode 100644 WebRuntime.MonoGame/Pages/Index.razor delete mode 100644 WebRuntime.MonoGame/Pages/Index.razor.cs delete mode 100644 WebRuntime.MonoGame/Program.cs delete mode 100644 WebRuntime.MonoGame/WebRuntime.MonoGame.csproj delete mode 100644 WebRuntime.MonoGame/_Imports.razor delete mode 100644 WebRuntime.MonoGame/global.json delete mode 100644 WebRuntime.MonoGame/scripts/boot-check.mjs delete mode 100644 WebRuntime.MonoGame/scripts/reload-check.mjs delete mode 100644 WebRuntime.MonoGame/scripts/test-bridge-check.mjs delete mode 100644 WebRuntime.MonoGame/wwwroot/css/app.css delete mode 100644 WebRuntime.MonoGame/wwwroot/index.html delete mode 100644 WebRuntime/FadeBridge.cs delete mode 100644 WebRuntime/WebCommands.cs delete mode 100644 WebRuntime/WebRuntime.csproj delete mode 100644 WebRuntime/global.json delete mode 100644 WebRuntime/wwwroot/index.html delete mode 100644 WebRuntime/wwwroot/worker.js diff --git a/.gitignore b/.gitignore index 1da3c07..fe9fe41 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ project.lock.json *.userosscache *.sln.docstates +# install.sh WASM staging output +FadeBasic/FadeBasic.Export.Web/staging/ + # Build results (scoped to .NET bin/obj so they don't match source folders named debug/release) **/bin/[Dd]ebug/ **/bin/[Dd]ebugPublic/ @@ -27,6 +30,8 @@ project.lock.json x64/ x86/ build/ +!FadeBasic/FadeBasic.Export.Web/build/ +!FadeBasic/FadeBasic.Export.Web/build/** bld/ [Bb]in/ [Oo]bj/ diff --git a/WebRuntime/DAP_AUDIT.md b/FadeBasic/FadeBasic.Export.Web/DAP_AUDIT.md similarity index 100% rename from WebRuntime/DAP_AUDIT.md rename to FadeBasic/FadeBasic.Export.Web/DAP_AUDIT.md diff --git a/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj b/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj new file mode 100644 index 0000000..676f5e8 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj @@ -0,0 +1,90 @@ + + + + net8.0 + enable + enable + true + FadeBasic.Export.Web + FadeBasic.Export.Web + + + false + + + + + + FadeBasic.Export.Web + FadeBasic Web Export + Adds dotnet publish support for exporting a FadeBasic project as a self-contained static web bundle (itch.io / static host). + true + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs b/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs new file mode 100644 index 0000000..df361ae --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs @@ -0,0 +1,1601 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.Loader; +using System.Runtime.InteropServices.JavaScript; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; +using Microsoft.JSInterop; +using FadeBasic; +using FadeBasic.Json; +using FadeBasic.Launch; +using FadeBasic.Lib.Standard; +using FadeBasic.Sdk; +using FadeBasic.Virtual; +using FadeSdk = FadeBasic.Sdk.Fade; +using FadeBasic.LSP.Core; +using FadeBasic.LSP.Core.Handlers; + +namespace FadeBasic.Export.Web; + +// FadeBridge is the browser-side adapter between the worker's postMessage +// surface and the cross-platform LSP logic in FadeBasic.LSP.Core. The native +// LSP server in FadeBasic/LSP/ will get the same Core handlers behind its +// OmniSharp transport once it's refactored. +[SupportedOSPlatform("browser")] +public static partial class FadeBridge +{ + // Dynamically-registered command sources. Cleared by ClearCommandAssemblies + // and rebuilt from fade.json's commandDlls on every project-type change. + // MUST be declared before _workspace — static field initializers run in + // declaration order and CreateWorkspace reads this list. + private static readonly List _registeredSources = new(); + + // Dynamically-loaded assemblies keyed by simple name. WASM's default + // AssemblyLoadContext doesn't fall back to "scan loaded assemblies by + // simple name" when binding type references the way desktop CLR does — + // so when the entry assembly's static cctor does `new SomeLib.Foo()`, + // resolution fails unless we hand the assembly back through Resolving. + private static readonly Dictionary _dynamicAssemblies = new(); + private static bool _resolverHooked; + + private static void EnsureResolverHooked() + { + if (_resolverHooked) return; + _resolverHooked = true; + AssemblyLoadContext.Default.Resolving += (_, name) => + _dynamicAssemblies.TryGetValue(name.Name ?? "", out var asm) ? asm : null; + } + + // Load `bytes` into the default ALC and register the loaded assembly so + // the Resolving handler can hand it back when the entry's type binder + // looks it up by simple name. Duplicates with _framework/ are harmless: + // default resolution finds those first, and Resolving only fires when + // default resolution fails — so the byte-loaded copy is reachable only + // for the assemblies that aren't already in _framework/. + private static Assembly LoadAndRegister(byte[] bytes) + { + EnsureResolverHooked(); + var asm = Assembly.Load(bytes); + var simpleName = asm.GetName().Name; + if (!string.IsNullOrEmpty(simpleName)) + _dynamicAssemblies[simpleName] = asm; + return asm; + } + + // Active workspace — rebuilt by SetProjectType and RegisterCommandAssembly. + private static FadeWorkspace _workspace = CreateWorkspace("web"); + private static string _activeProjectType = "web"; + + private static FadeWorkspace CreateWorkspace(string projectType) + { + var sources = new List(_registeredSources) { new StandardCommands() }; + var commands = new CommandCollection(sources.ToArray()); + ICommandDocsProvider docs = StandardCommandDocs.BuildWeb(); + _ = projectType; // reserved for future type-specific doc providers + + var ws = new FadeWorkspace(commands); + ws.Docs = docs; + return ws; + } + + // Called by the worker (main.ts → worker.js) when the active fade.json + // type changes. Rebuilds the workspace with the right CommandCollection + // so the LSP picks up the new command surface. Returns the new type so + // the page can log/confirm. Idempotent. + [JSExport] + public static string SetProjectType(string projectType) + { + var t = (projectType ?? "web").ToLowerInvariant(); + if (t == _activeProjectType) return t; + _activeProjectType = t; + _workspace = CreateWorkspace(t); + return t; + } + + // Load a command DLL from raw bytes, instantiate the named class, and + // merge it into the workspace. Both workers (LSP + VM) receive this call + // so hover/completion and execution see the same command surface. + // dllBytes is a Uint8Array on the JS side; className is fully-qualified. + [JSExport] + public static string RegisterCommandAssembly(byte[] dllBytes, string className) + { + try + { + var asm = LoadAndRegister(dllBytes); + var type = asm.GetType(className) + ?? throw new Exception($"Type '{className}' not found in assembly"); + var instance = Activator.CreateInstance(type) as IMethodSource + ?? throw new Exception($"'{className}' does not implement IMethodSource"); + _registeredSources.Add(instance); + _workspace = CreateWorkspace(_activeProjectType); + return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + } + } + + // Remove all dynamically-registered command sources and rebuild the workspace. + // Called by the page before re-registering whenever fade.json's commandDlls changes. + [JSExport] + public static string ClearCommandAssemblies() + { + _registeredSources.Clear(); + _workspace = CreateWorkspace(_activeProjectType); + return "true"; + } + + // Load a side-by-side dependency DLL into the AppDomain without + // registering it as a command source. Used by the export loader to pull + // in the game's transitive deps (e.g. FadeBasic.Lib.Web.dll) BEFORE the + // entry assembly is loaded — otherwise resolving the entry's ILaunchable + // type would fail because referenced assemblies aren't yet present. + [JSExport] + public static string LoadAssembly(byte[] dllBytes) + { + try + { + LoadAndRegister(dllBytes); + return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + } + } + + // ─── Cooperative pump model ─────────────────────────────────────── + // The web runtime can't run the VM to completion in one synchronous + // call: while C# is executing, the worker's JS event loop is blocked, + // so any postMessage from the page (prompt answers, pause/stop) sits + // undelivered in the queue. Instead, we hold the VM as static state + // and the worker pumps it in small budgeted batches, yielding to its + // event loop between batches. `prompt$` and `wait ms` cooperate by + // calling vm.Suspend() and stashing wake-up state in WebRuntimeBridge; + // the JS pump reads that state to decide how (and when) to schedule + // the next tick. See worker.js for the pump driver, WebCommands.cs + // for the prompt$/wait ms command implementations. + + private static VirtualMachine? _runVm; + private static string? _runError; + // The single owner of suspend/resume state for this runtime. Library + // commands only see HostBridge — they don't reach into these fields + // directly. Plugins (other libraries / pages) extend behavior by + // adding new HostBridge.PostMessage channels, not by adding new + // fields here. + private static bool _waitingForHostReply; + private static int _pendingWaitMs; + // Set by StopRun to terminate the cooperative pump regardless of + // what state the VM is in (mid-batch, waiting on a host reply, + // sleeping between wait-ms timeouts). RunTick observes it on the + // next call and emits a synthetic complete=true result with an + // error of "stopped" so callers can distinguish from a normal end. + private static bool _runStopRequested; + + // ─── Cooperative test-runner state ─────────────────────────────── + // When _testRunActive is true, RunTick treats _runVm as "the + // current test's VM" instead of a single run-to-end VM. As each + // test's VM completes, RunTick finalizes that test's result and + // advances _runVm to the next test in _testQueue. When the queue + // is empty, RunTick emits complete=true with the aggregated + // testFinal envelope. wait ms / prompt$ / stop all reuse the + // existing Run-pump infrastructure unchanged — tests are just a + // sequence of cooperative runs sharing the same pump. + private static bool _testRunActive; + private static FadeRuntimeContext? _testCtx; + private static List? _testQueue; + private static int _testIndex; + private static List? _testResults; + private static System.Diagnostics.Stopwatch? _testRunSw; + private static System.Diagnostics.Stopwatch? _currentTestSw; + private static Exception? _currentTestException; + + // Routes C# → JS for HostBridge.PostMessage. Worker.js binds the + // 'fade-runtime' module to a fan-out that posts `host-message` to + // the page; the page dispatches by `channel` and replies with a + // typed `host-reply` that flows back into DepositResultString etc. + [JSImport("postHostMessage", "fade-runtime")] + internal static partial void PostHostMessage(string channel, string payload); + + // Static wire-up: install our cooperative-scheduling primitives. + // Library commands call these via HostBridge; we own the VM and + // the suspend/resume bookkeeping. Each runtime host does the same + // dance — MonoGame swaps WaitImpl in its own startup, a native CLI + // would do similar but with synchronous semantics. Runs once on + // first touch of FadeBridge (which is at worker boot, when the + // worker resolves the assembly's JS exports). + // + // Adding a new cooperative command in a future library does NOT + // require any changes here — the library invokes PostMessage with + // its own channel name and SuspendVm to pause; the page-side + // handler registers in hostHandlers. The runtime is channel-agnostic. + static FadeBridge() + { + StandardCommands.WaitImpl = ms => + { + // Two cooperative paths plus a defensive fallback: + // + // - Cooperative pump (Run flow OR test flow): _runVm is + // non-null. Both share the same pump infrastructure — + // tests just point _runVm at one test's VM at a time, + // advancing between tests inside RunTick. Record the + // wait, suspend, let the JS pump schedule the next tick + // after `ms`. Worker thread stays responsive. + // + // - Debug session: _debugSession is non-null. Mirror of + // the above for DebugTick / pumpDebugTick. + // + // - Fallback: a Fade program ran outside any host driver + // we know about (shouldn't happen in normal use). Block + // via Thread.Sleep so behavior matches a desktop host. + if (_runVm != null) + { + _pendingWaitMs = ms; + _runVm.Suspend(); + } + else if (_debugSession != null) + { + _pendingWaitMs = ms; + _debugSession._vm?.Suspend(); + } + else + { + System.Threading.Thread.Sleep(ms); + } + }; + HostBridge.PostMessage = (channel, payload) => + PostHostMessage(channel, payload); + HostBridge.SuspendVm = () => + { + _waitingForHostReply = true; + _runVm?.Suspend(); + }; + } + + // Begin a run. Resolves the ILaunchable from the entry assembly and + // builds the VM, but does NOT execute. The worker then drives the VM + // forward via RunTick until it reports complete=true. Mirrors the + // setup half of the old synchronous LoadAndRun. + [JSExport] + public static string RunStart(byte[] entryDllBytes) + { + try + { + var asm = LoadAndRegister(entryDllBytes); + Type launchableType = null; + foreach (var t in asm.GetTypes()) + { + if (!t.IsClass || t.IsAbstract) continue; + if (typeof(ILaunchable).IsAssignableFrom(t)) { launchableType = t; break; } + } + if (launchableType == null) + throw new Exception("No ILaunchable implementation found in entry assembly"); + var instance = (ILaunchable)Activator.CreateInstance(launchableType); + _runVm = new VirtualMachine(instance.Bytecode) + { + hostMethods = HostMethodTable.FromCommandCollection(instance.CommandCollection), + }; + _runError = null; + _waitingForHostReply = false; + _pendingWaitMs = 0; + _runStopRequested = false; + _testRunActive = false; + return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + } + } + + // Begin a run from raw Fade source. The Playground and any other + // host that compiles-on-the-fly uses this instead of the DLL-based + // RunStart. Commands come from _workspace.Commands (which already + // includes the StandardCommands + every RegisterCommandAssembly). + // Returns { ok, compileError? }; compile failures surface here + // rather than at the first tick. + [JSExport] + public static string RunStartFromSource(string source) + { + try + { + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + ok = false, + compileError = errors.ToDisplay(), + }, _jsonOpts); + } + _runVm = ctx.Machine; + _runError = null; + _waitingForHostReply = false; + _pendingWaitMs = 0; + _runStopRequested = false; + _testRunActive = false; + return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + } + } + + // Compile Fade source to a raw bytecode blob. Used by the export + // download path and by the Playground's preview iframe — both want + // the compiled program as bytes they can hand to RunStartFromBytecode + // (in another process / iframe / future runtime). Returns the + // bytecode directly; callers should check for empty (compile fail) + // via the companion CompileToBytecodeStatus method. + // + // We compile against the host's current _workspace.Commands so the + // bytecode's host-method indices match what a re-loaded runtime + // will resolve them against (assuming it loads the same DLLs). + [JSExport] + public static byte[] CompileToBytecode(string source) + { + try + { + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out _)) + return Array.Empty(); + return ctx.Machine.program; + } + catch + { + return Array.Empty(); + } + } + + // Companion to CompileToBytecode — returns compile diagnostics as + // JSON so callers can surface errors when CompileToBytecode returns + // an empty buffer. Split into two calls because JSExport doesn't + // give us a clean way to return both bytes AND a status struct. + [JSExport] + public static string CompileToBytecodeStatus(string source) + { + try + { + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + ok = false, + compileError = errors.ToDisplay(), + }, _jsonOpts); + } + return JsonSerializer.Serialize(new { ok = true, byteCount = ctx.Machine.program.Length }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + } + } + + // Boot the cooperative pump from pre-compiled bytecode (the output + // of CompileToBytecode, possibly produced by a different runtime + // instance / process / build step). Commands resolve against the + // current workspace — callers must have already RegisterCommandAssembly'd + // every DLL the bytecode references, otherwise CALL_HOST opcodes + // will hit a null method during the first tick. + [JSExport] + public static string RunStartFromBytecode(byte[] bytecode) + { + try + { + if (bytecode == null || bytecode.Length == 0) + throw new Exception("RunStartFromBytecode: empty bytecode"); + var commands = _workspace.Commands; + _runVm = new VirtualMachine(bytecode) + { + hostMethods = HostMethodTable.FromCommandCollection(commands), + }; + _runError = null; + _waitingForHostReply = false; + _pendingWaitMs = 0; + _runStopRequested = false; + _testRunActive = false; + return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + } + } + + // Run one cooperative batch of the program. `budget` caps the number + // of opcodes dispatched (0 = unlimited; not recommended — see below). + // Returns the run status so the JS pump can choose how to schedule + // the next tick: + // complete=true → run is finished; stop pumping + // waitingForHostReply=true → halt pump entirely; resume only after + // a host-reply deposits a value + // waitMs > 0 → setTimeout(tick, waitMs) before next + // suspended=true otherwise → setTimeout(tick, 0) (yield + continue) + // none of the above → setTimeout(tick, 0) (also yield) + // Budget 0 would defeat the whole point of the pump model (no yield + // between opcodes), so the worker passes a finite number — high enough + // that VM overhead per batch is small, low enough to keep heartbeats + // alive. + [JSExport] + public static string RunTick(int budget) + { + // Queue exhausted in test mode (AdvanceTest set _runVm = null + // because we ran the last test on the previous tick). Emit the + // aggregated testFinal envelope. + if (_runVm == null && _testRunActive) + return BuildTestRunCompleteJson(stopped: false); + + if (_runVm == null) + return JsonSerializer.Serialize(new { complete = true }, _jsonOpts); + + if (_runError != null && !_testRunActive) + return JsonSerializer.Serialize(new { complete = true, error = _runError }, _jsonOpts); + + // Honor a pending stop request before doing any more work. The + // pump treats this terminal result the same as a normal complete + // and stops scheduling new ticks. Tear down the VM reference too + // so a stale Suspend doesn't leak across runs. + if (_runStopRequested) + { + _runStopRequested = false; + _runVm = null; + if (_testRunActive) + return BuildTestRunCompleteJson(stopped: true); + return JsonSerializer.Serialize(new + { + complete = true, + error = "stopped", + }, _jsonOpts); + } + + // Per-tick reset for the wake-up hint. The host-reply flag is NOT + // reset here — it persists across the suspend until DepositResultXxx + // clears it (since the whole point is that the VM stays paused + // across multiple JS event-loop ticks while we wait for the page). + _pendingWaitMs = 0; + Exception? testException = null; + try + { + _runVm.Execute3(budget); + } + catch (Exception ex) + { + if (_testRunActive) + { + // Test runs don't bail on a single test's exception — the + // test fails, we move on. The exception goes into the + // current test's result via BuildResultFromVm below. + testException = ex; + } + else + { + _runError = DescribeException(ex); + } + } + + // Test-mode transition: if the current test's VM is finished + // (either ran past the program end or threw), finalize that + // test's result and start the next one. Don't surface complete + // to the pump yet — keep ticking until the queue is exhausted. + if (_testRunActive) + { + var vmFinished = _runVm.instructionIndex >= _runVm.program.Length + || testException != null; + if (vmFinished && _testQueue != null && _testCtx != null + && _testIndex >= 0 && _testIndex < _testQueue.Count) + { + _currentTestSw?.Stop(); + var entry = _testQueue[_testIndex]; + var result = FadeBasic.Sdk.FadeTestExecutor.BuildResultFromVm( + _runVm, + entry, + _currentTestSw?.Elapsed ?? System.TimeSpan.Zero, + _testCtx.Compiler.DebugData, + testException); + _testResults?.Add(result); + var progress = TestResultToObject(result); + + if (!AdvanceTest()) + { + // Last test in the queue — emit testProgress for + // this final test alongside the terminal envelope, + // so the Playground's per-test stream is uniform + // (no special-case for "the run that just ended"). + return BuildTestRunCompleteJson(stopped: false, lastProgress: progress); + } + + // Started next test. Tell the pump to schedule another + // tick immediately (waitMs=0, not complete, not suspended) + // and surface this test's result so the UI flips its + // row from "running" to pass/fail before the next test + // visibly starts. `testStarting` names the test that + // just became active — the iframe uses it to clear its + // output area so each test's prints start on a clean + // slate. + var nextName = _testQueue![_testIndex].name; + return JsonSerializer.Serialize(new + { + complete = false, + suspended = false, + waitMs = 0, + waitingForHostReply = false, + testProgress = progress, + testStarting = new { name = nextName }, + }, _jsonOpts); + } + // Test's VM is still mid-flight — suspended on a wait or + // host-reply, or just used up its budget. Surface like a + // normal Run tick so the pump schedules appropriately. + } + + var complete = _runError != null + || _runVm.instructionIndex >= _runVm.program.Length; + + return JsonSerializer.Serialize(new + { + complete, + suspended = !complete && _runVm.isSuspendRequested, + waitMs = _pendingWaitMs, + waitingForHostReply = _waitingForHostReply, + error = _runError, + }, _jsonOpts); + } + + // Terminate an in-flight run. Sets a flag the next RunTick honors, + // clears the prompt-wait so the pump can resume even from a halted + // state, and asks the VM to break out of the current Execute3 batch. + // Safe to call when no run is active — it's a no-op then. + // + // The pump driver (worker.js) is expected to follow this by either + // calling RunTick once (to flush the synthetic stopped result) or + // by simply letting an in-flight setTimeout fire — the next tick + // observes the stop flag and exits. + [JSExport] + public static string StopRun() + { + _runStopRequested = true; + _waitingForHostReply = false; + _pendingWaitMs = 0; + // _runVm is also the test pump's current VM, so a single + // Suspend handles both cases. The next RunTick sees the stop + // flag, finalizes (with stopped error), and emits the terminal + // event. + _runVm?.Suspend(); + return "true"; + } + + // ─── Deposit-result entry points ────────────────────────────────── + // The worker calls into one of these in response to a `host-reply` + // message from the page. The library command earlier pushed a + // type-shaped placeholder onto the operand stack (source-generated + // executors push the default-value bytes + the type code based on + // the command's C# return type) and suspended the VM via + // HostBridge.SuspendVm. The matching Deposit* call swaps that + // placeholder for the real value; the next RunTick resumes and + // the consuming opcode pops the real value. + // + // The set of supported result types matches the FadeBasic VM's + // primitive type table (see Virtual/OpCodes.cs:TypeCodes). String + // is heap-allocated; scalars overwrite the placeholder bytes in + // place. Void is a no-op stack-wise (the command pushed nothing). + + // Helper: gate every Deposit* on "we're actually paused waiting". + // Returns true if the deposit should proceed; flips the flag off + // either way so the pump can resume on the next tick. + private static bool BeginDeposit() + { + if (_runVm == null || !_waitingForHostReply) return false; + return true; + } + + private static string EndDeposit(bool ok) + { + _waitingForHostReply = false; + return ok ? "true" : "false"; + } + + [JSExport] + public static string DepositResultString(string value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopString(_runVm, value)); + + // ─── Scalar deposits ────────────────────────────────────────────── + // JS Number is double-precision, so each of these accepts whatever + // JS-native type maps cleanly to its C# parameter; the worker.js + // dispatcher coerces JS values into the right shape before calling. + // BitConverter.GetBytes handles endianness consistently with what + // the source-generated executors push. + + [JSExport] + public static string DepositResultInt(int value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopPrimitive(_runVm, + TypeCodes.INT, BitConverter.GetBytes(value))); + + [JSExport] + public static string DepositResultReal(float value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopPrimitive(_runVm, + TypeCodes.REAL, BitConverter.GetBytes(value))); + + [JSExport] + public static string DepositResultBool(bool value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopPrimitive(_runVm, + TypeCodes.BOOL, BitConverter.GetBytes(value))); + + [JSExport] + public static string DepositResultByte(byte value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopPrimitive(_runVm, + TypeCodes.BYTE, new[] { value })); + + // ushort isn't a clean JS-interop type, and the page-side number is + // already a double anyway; the worker mask/coerces and we narrow + // here. Same shape for dword below. + [JSExport] + public static string DepositResultWord(int value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopPrimitive(_runVm, + TypeCodes.WORD, BitConverter.GetBytes((ushort)value))); + + [JSExport] + public static string DepositResultDword(int value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopPrimitive(_runVm, + TypeCodes.DWORD, BitConverter.GetBytes((uint)value))); + + // int64. The JSMarshalAs annotation tells the JS generator to use + // BigInt on the JS side — without it the generator refuses to + // marshal `long` (SYSLIB1072). The page handler should return + // `{ resultType: 'dint', value: BigInt(...) }` to preserve values + // past 2^53. + [JSExport] + public static string DepositResultDint( + [JSMarshalAs] long value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopPrimitive(_runVm, + TypeCodes.DINT, BitConverter.GetBytes(value))); + + [JSExport] + public static string DepositResultDfloat(double value) => + EndDeposit(BeginDeposit() && HostStackOps.SwapTopPrimitive(_runVm, + TypeCodes.DFLOAT, BitConverter.GetBytes(value))); + + // Void-returning command: the executor pushed nothing, so there's + // no placeholder to swap. Just clear the wait flag so the pump can + // resume on the next tick. + [JSExport] + public static string DepositResultVoid() => + EndDeposit(BeginDeposit()); + + // Unwrap TargetInvocationException / TypeInitializationException so the + // page sees the real cause instead of "Arg_TargetInvocationException". + // Resource-key messages are common under WASM trimming — strings like + // ArgumentNull_Generic land in the report; the chain plus type name + // usually narrows things down. + private static string DescribeException(Exception ex) + { + var sb = new StringBuilder(); + var current = ex; + var depth = 0; + while (current != null && depth < 6) + { + if (sb.Length > 0) sb.Append("\n → "); + sb.Append(current.GetType().FullName).Append(": ").Append(current.Message); + if (!string.IsNullOrEmpty(current.StackTrace) && depth == 0) + sb.Append('\n').Append(current.StackTrace); + current = current.InnerException; + depth++; + } + return sb.ToString(); + } + + // camelCase JSON to match LSP wire-protocol convention; TS interfaces in + // Playground use lowercase field names. IncludeFields is critical — Core + // DTOs use public FIELDS (not properties), which System.Text.Json ignores + // by default. Without this every diagnostic serializes as {} and the + // Playground throws "d.range is undefined". + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + IncludeFields = true, + }; + + // ─── Run ────────────────────────────────────────────────────────────── + [JSInvokable] + [JSExport] + // Returns a JSON envelope so the page can format different kinds of + // output (compile errors / runtime errors / printed stdout) with their + // own styling. Shape: { compileError, runtimeError, printed }. Any + // field may be null/empty. Print output also streams through `onPrint` + // during execution; we drain anything that wasn't flushed yet. + public static string CompileAndRun(string source) + { + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + compileError = errors.ToDisplay(), + runtimeError = (string)null, + printed = "", + }, _jsonOpts); + } + + string runtimeError = null; + try { ctx.Run(); } + catch (Exception ex) { runtimeError = ex.GetType().Name + ": " + ex.Message; } + + return JsonSerializer.Serialize(new + { + compileError = (string)null, + runtimeError, + printed = "", + }, _jsonOpts); + } + + // ─── LSP entry points — thin adapters over Core ─────────────────────── + + [JSExport] + public static string LspSetDocument(string uri, string text) + { + try + { + var doc = _workspace.SetDocument(uri, text); + return JsonSerializer.Serialize(DiagnosticsHandler.Compute(doc), _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new LspDiagnostic[] + { + new LspDiagnostic + { + Severity = LspDiagnosticSeverity.Error, + Range = new LspRange + { + Start = new LspPosition { Line = 0, Character = 0 }, + End = new LspPosition { Line = 0, Character = 1 }, + }, + Message = $"LSP internal error: {ex.GetType().Name}: {ex.Message}", + Code = "INT-001", + Source = "fade", + }, + }, _jsonOpts); + } + } + + [JSExport] + public static string LspGetSemanticTokens(string uri) + { + var doc = _workspace.Get(uri); + return JsonSerializer.Serialize(SemanticTokensHandler.Compute(doc), _jsonOpts); + } + + [JSExport] + public static string LspHover(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var hover = HoverHandler.Compute(doc, line, character); + return hover == null ? "null" : JsonSerializer.Serialize(hover, _jsonOpts); + } + + [JSExport] + public static string LspCompletion(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var items = CompletionHandler.Compute(doc, line, character); + return JsonSerializer.Serialize(items, _jsonOpts); + } + + [JSExport] + public static string LspGetAllDiagnostics() + { + var all = new Dictionary>(); + foreach (var doc in _workspace.AllDocuments) + all[doc.Uri] = DiagnosticsHandler.Compute(doc); + return JsonSerializer.Serialize(all, _jsonOpts); + } + + [JSExport] + public static string LspSignatureHelp(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var sig = SignatureHelpHandler.Compute(doc, line, character); + return sig == null ? "null" : JsonSerializer.Serialize(sig, _jsonOpts); + } + + [JSExport] + public static string LspReferences(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var refs = ReferencesHandler.Compute(doc, line, character); + return JsonSerializer.Serialize(refs ?? new List(), _jsonOpts); + } + + [JSExport] + public static string LspDefinition(string uri, int line, int character) + { + var doc = _workspace.Get(uri); + var def = DefinitionHandler.Compute(doc, line, character); + return def == null ? "null" : JsonSerializer.Serialize(def, _jsonOpts); + } + + [JSExport] + public static string LspDocumentSymbols(string uri) + { + var doc = _workspace.Get(uri); + var syms = DocumentSymbolHandler.Compute(doc); + return JsonSerializer.Serialize(syms ?? new List(), _jsonOpts); + } + + [JSExport] + public static string LspFoldingRanges(string uri) + { + var doc = _workspace.Get(uri); + var ranges = FoldingRangeHandler.Compute(doc); + return JsonSerializer.Serialize(ranges ?? new List(), _jsonOpts); + } + + // optionsJson is an LspFormattingOptions in camelCase JSON. + [JSExport] + public static string LspFormat(string uri, string optionsJson) + { + var doc = _workspace.Get(uri); + var opts = string.IsNullOrEmpty(optionsJson) + ? new LspFormattingOptions() + : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); + var edits = FormattingHandler.Compute(doc, opts); + return JsonSerializer.Serialize(edits, _jsonOpts); + } + + [JSExport] + public static string LspFormatRange(string uri, string optionsJson, int startLine, int startCh, int endLine, int endCh) + { + var doc = _workspace.Get(uri); + var opts = string.IsNullOrEmpty(optionsJson) + ? new LspFormattingOptions() + : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); + var range = new LspRange + { + Start = new LspPosition { Line = startLine, Character = startCh }, + End = new LspPosition { Line = endLine, Character = endCh }, + }; + var edits = FormattingHandler.ComputeRange(doc, opts, range); + return JsonSerializer.Serialize(edits, _jsonOpts); + } + + [JSExport] + public static string LspFormatOnType(string uri, string optionsJson, int line, int character) + { + var doc = _workspace.Get(uri); + var opts = string.IsNullOrEmpty(optionsJson) + ? new LspFormattingOptions() + : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); + var edits = FormattingHandler.ComputeOnType(doc, opts, new LspPosition { Line = line, Character = character }); + return JsonSerializer.Serialize(edits, _jsonOpts); + } + + [JSExport] + public static string LspRename(string uri, int line, int character, string newName) + { + var doc = _workspace.Get(uri); + var edit = RenameHandler.Compute(doc, line, character, newName); + return edit == null ? "null" : JsonSerializer.Serialize(edit, _jsonOpts); + } + + // ─── Help / command docs ────────────────────────────────────────────── + // Returns a JSON array of every command currently loaded in the + // workspace's CommandCollection, with the same markdown the hover + // provider renders. Used by the page's Help tab to build a TOC + + // per-command reader. One row per UNIQUE command name (overloads + // collapse — the first signature wins). Sorted alphabetically. + [JSExport] + public static string ListCommandDocs() + { + try + { + var commands = _workspace.Commands?.Commands; + if (commands == null) + { + return "[]"; + } + // Dedupe by command.name. Overloads (e.g. `rgb` with 3 vs 4 + // args) share a name; we surface one row per name and use the + // first CommandInfo we find — BuildCommandMarkdown already + // describes all parameter slots from that signature. + var seen = new HashSet(); + var rows = new List(); + foreach (var c in commands) + { + if (string.IsNullOrEmpty(c.name)) continue; + if (!seen.Add(c.name)) continue; + string markdown; + try + { + markdown = FadeBasic.LSP.Core.Handlers.HoverHandler.BuildCommandMarkdown( + c, _workspace.Docs); + } + catch (Exception ex) + { + markdown = $"### {c.name}\n\n_Failed to render docs: {ex.Message}_"; + } + rows.Add(new + { + name = c.name, + signature = c.sig, + // Best-effort: classify into a "group" based on the + // command name's first word for the TOC. The native + // command-doc generator keeps a category in metadata + // we don't propagate here yet; this is a useful + // approximation until that's wired through. + group = GuessGroup(c.name), + markdown, + }); + } + // Stable alphabetical order so the TOC is deterministic. + rows.Sort((a, b) => + string.Compare( + (string)a.GetType().GetProperty("name").GetValue(a), + (string)b.GetType().GetProperty("name").GetValue(b), + StringComparison.OrdinalIgnoreCase)); + return JsonSerializer.Serialize(rows, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new + { + error = "Failed to enumerate command docs: " + ex.Message, + }, _jsonOpts); + } + } + + // Cheap heuristic: cluster commands by their first word so the TOC + // gets meaningful section headings (e.g. "print", "string", "wait"). + // For multi-word commands ("wait ms", "wait key") this also yields a + // shared bucket. Single-word commands get their own bucket named + // after themselves only when no peers share the prefix — to avoid + // a 200-bucket TOC, single-words fall back to a generic "Core" group. + private static string GuessGroup(string name) + { + if (string.IsNullOrEmpty(name)) return "Core"; + var idx = name.IndexOf(' '); + return idx > 0 ? name.Substring(0, idx) : "Core"; + } + + // ─── Tests ──────────────────────────────────────────────────────────── + // Compile the source and list the test entry points. Returns a JSON + // array of { name, isAbstract, fromParent, sourceLine }. On compile + // failure returns an empty array (errors surface via LspSetDocument). + [JSExport] + public static string ListTests(string source) + { + try + { + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out _)) + return "[]"; + var tests = new List(); + foreach (var t in ctx.Compiler.TestManifest) + { + tests.Add(new + { + name = t.name, + isAbstract = t.isAbstract, + fromParent = t.fromParent, + sourceLine = t.sourceLine, + sourceChar = t.sourceChar, + }); + } + return JsonSerializer.Serialize(tests, _jsonOpts); + } + catch + { + return "[]"; + } + } + + // Compile + run either all tests (testName empty / null) or a single + // named test. Returns JSON with { passed, failed, duration, results[], + // printed, error? }. `printed` is the captured stdout from any + // `print` statements run during testing. + // Begin a cooperative test run. Compiles `source`, builds the + // queue of tests to run (`testName` empty/null = all non-abstract + // tests; otherwise just the named one), and starts the first + // test's VM by setting _runVm. The JS-side pump (worker.js) then + // drives RunTick repeatedly, exactly the same way it does for a + // regular Run — RunTick observes _testRunActive and handles the + // per-test transitions internally. Final result envelope shows + // up via RunTick's complete=true return, carrying the testFinal + // payload aggregated across all tests. + [JSExport] + public static string RunTestsStart(string source, string testName) + { + _runStopRequested = false; + _testRunActive = false; + _testQueue = null; + _testResults = null; + _runError = null; + + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + ok = false, + compileError = errors.ToDisplay(), + }, _jsonOpts); + } + + var selectAll = string.IsNullOrWhiteSpace(testName); + var queue = new List(); + foreach (var t in ctx.Compiler.TestManifest) + { + if (t.isAbstract) continue; + if (!selectAll && !string.Equals(t.name, testName, System.StringComparison.OrdinalIgnoreCase)) continue; + queue.Add(t); + if (!selectAll) break; + } + + _testCtx = ctx; + _testQueue = queue; + _testResults = new List(); + _testIndex = -1; + _testRunSw = System.Diagnostics.Stopwatch.StartNew(); + _testRunActive = true; + + // Boot the first test's VM. If the queue is empty (no matching + // tests), AdvanceTest returns false and _runVm stays null; + // the next RunTick reports complete with an empty testFinal. + AdvanceTest(); + return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + } + + // Advance to the next test in the queue. Returns true if a new + // test was started (so the pump should keep ticking), false if + // the queue is exhausted. On failure to start (queue empty), + // _runVm is set to null so the next RunTick observes the + // not-running state and emits the testFinal envelope. + private static bool AdvanceTest() + { + if (_testQueue == null || _testCtx == null) { _runVm = null; return false; } + _testIndex++; + if (_testIndex >= _testQueue.Count) { _runVm = null; return false; } + var entry = _testQueue[_testIndex]; + var vm = new VirtualMachine(_testCtx.Machine.program, entry.entryPointAddress) + { + hostMethods = _testCtx.Compiler.methodTable, + isTestExecution = true, + }; + _runVm = vm; + _runError = null; + _waitingForHostReply = false; + _pendingWaitMs = 0; + _currentTestSw = System.Diagnostics.Stopwatch.StartNew(); + _currentTestException = null; + return true; + } + + // Build the terminal envelope a test run emits via RunTick when + // either the queue is exhausted or StopRun was honored. `stopped` + // tags the surface error so callers can distinguish "ran to end" + // from "user cancelled" — partial results in either case. + // + // `lastProgress` carries the per-test event for the test that + // finalized in THIS same tick (when called from the + // "AdvanceTest returned false" path). The Playground's progress + // listener gets the last test the same way it gets every other — + // no special-case for "the run that just ended." + private static string BuildTestRunCompleteJson(bool stopped, object? lastProgress = null) + { + _testRunActive = false; + _testRunSw?.Stop(); + var results = _testResults ?? new List(); + var passed = 0; var failed = 0; + foreach (var r in results) { if (r.passed) passed++; else failed++; } + var duration = _testRunSw?.Elapsed.TotalMilliseconds ?? 0; + var payload = new + { + complete = true, + testProgress = lastProgress, + testFinal = new + { + passed, + failed, + duration, + results = ResultsToObjects(results), + }, + error = stopped ? "Stopped" : (string?)null, + }; + return JsonSerializer.Serialize(payload, _jsonOpts); + } + + // Shape a single test result for JSON wire transport. Extracted from + // ResultsToObjects so the test-progress stream (one event per + // finalized test) can reuse the same payload shape the terminal + // testFinal envelope uses — the Playground's tests panel handles + // both with one code path. + private static object TestResultToObject(FadeBasic.Sdk.FadeTestResult r) + { + var frames = new List(); + if (r.failureFrames != null) + { + foreach (var f in r.failureFrames) + { + frames.Add(new + { + functionName = f.functionName, + lineNumber = f.lineNumber, + charNumber = f.charNumber, + instructionIndex = f.instructionIndex, + }); + } + } + return new + { + name = r.testName, + passed = r.passed, + duration = r.duration.TotalMilliseconds, + failureMessage = r.failureMessage, + failureReason = r.failureReason, + failureSourceText = r.failureSourceText, + failureInstructionIndex = r.failureInstructionIndex, + failureFrames = frames, + }; + } + + private static List ResultsToObjects(List results) + { + var list = new List(results.Count); + foreach (var r in results) + { + var frames = new List(); + if (r.failureFrames != null) + { + foreach (var f in r.failureFrames) + { + frames.Add(new + { + functionName = f.functionName, + lineNumber = f.lineNumber, + charNumber = f.charNumber, + instructionIndex = f.instructionIndex, + }); + } + } + list.Add(new + { + name = r.testName, + passed = r.passed, + duration = r.duration.TotalMilliseconds, + failureMessage = r.failureMessage, + failureReason = r.failureReason, + failureSourceText = r.failureSourceText, + failureInstructionIndex = r.failureInstructionIndex, + failureFrames = frames, + }); + } + return list; + } + + // ─── Debug session (DAP) ──────────────────────────────────────────── + // One active session at a time. The worker calls DebugStart() to + // compile + boot a session, then DebugTick() in a loop to make + // forward progress, draining outbound messages between ticks. + + private static FadeRuntimeContext _debugContext; + private static WebDebugSession _debugSession; + private static int _debugMessageIdCounter; + // Tracks the pause state across ticks so we can emit a synthetic stop + // event on running→paused transitions (e.g. step landings, which the + // base DebugSession only signals via a PROTO_ACK on the step request). + private static bool _debugWasPaused; + // Set when DebugStartTest boots a session targeting a specific test. + // GetDebugTestResult uses this to know which test name to report, and + // (via _debugSession._vm.assertionFailure) whether it passed. Cleared + // by DebugStart (non-test) and DebugTerminate so subsequent debug + // queries return null instead of stale data. + private static FadeBasic.Virtual.TestManifestEntry _debugTestEntry; + + private static int NextDebugId() => ++_debugMessageIdCounter; + + // Compile + boot a debug session that targets a specific test entry + // point. Mirrors FadeTestExecutor.RunTest's setup — a fresh VM at the + // test's entry address with isTestExecution=true — but wraps it in a + // WebDebugSession so we can pause, step, and inspect normally. + [JSExport] + public static string DebugStartTest(string source, string testName) + { + try + { + DebugTerminate(); + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Compile failed:\n" + errors.ToDisplay(), + statementLines = Array.Empty(), + }, _jsonOpts); + } + FadeBasic.Virtual.TestManifestEntry foundEntry = null; + foreach (var t in ctx.Compiler.TestManifest) + { + if (string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)) + { + foundEntry = t; + break; + } + } + if (foundEntry == null || foundEntry.isAbstract) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = foundEntry == null + ? $"No test named '{testName}' found" + : $"Test '{testName}' is abstract and cannot be debugged", + }, _jsonOpts); + } + + // Fresh VM at the test's entry address (matches + // FadeTestExecutor.RunTest's bootstrap so the test runs the same + // way it would in normal test execution). + var vm = new FadeBasic.Virtual.VirtualMachine(ctx.Machine.program, foundEntry.entryPointAddress) + { + hostMethods = ctx.Compiler.methodTable, + isTestExecution = true, + }; + _debugContext = ctx; + _debugSession = new WebDebugSession(vm, ctx.Compiler.DebugData, commands); + _debugTestEntry = foundEntry; + _debugWasPaused = true; + EnqueueBasic(DebugMessageType.REQUEST_PAUSE); + + var lines = new SortedSet(); + foreach (var t in ctx.Compiler.DebugData.statementTokens) + if (t?.token != null) lines.Add(t.token.lineNumber); + return JsonSerializer.Serialize(new + { + ok = true, + statementLines = lines, + testName = foundEntry.name, + testLine = foundEntry.sourceLine, + }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Debug-test start failed: " + ex.Message, + }, _jsonOpts); + } + } + + // Compile + boot a debug session. Returns JSON with { ok, error?, + // statementTokens[] } so the page can render gutter glyphs at valid + // breakpoint lines. + [JSExport] + public static string DebugStart(string source) + { + try + { + DebugTerminate(); // reset any prior session. + var commands = _workspace.Commands; + if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Compile failed:\n" + errors.ToDisplay(), + statementLines = Array.Empty(), + }, _jsonOpts); + } + _debugContext = ctx; + _debugSession = new WebDebugSession(ctx.Machine, ctx.Compiler.DebugData, commands); + _debugTestEntry = null; // non-test debug; clear any prior test marker + // Pre-mark as paused so the first tick's running→paused detection + // doesn't fire a synthetic stop event for our internal start- + // pause. Real pauses (breakpoints, steps) flip from false→true + // and emit normally. + _debugWasPaused = true; + + // Start the session in a paused state. The page must set its + // breakpoints and then call DebugContinue() to begin running. + // Without this, the tick loop in worker.js would race the page + // and execute past any breakpoints before they're installed. + EnqueueBasic(DebugMessageType.REQUEST_PAUSE); + + // Surface valid statement lines so the editor can show breakpoint + // hints. statementTokens have 1-based lineNumber. + var lines = new SortedSet(); + foreach (var t in ctx.Compiler.DebugData.statementTokens) + if (t?.token != null) lines.Add(t.token.lineNumber); + return JsonSerializer.Serialize(new + { + ok = true, + statementLines = lines, + }, _jsonOpts); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new + { + ok = false, + error = "Debug start failed: " + ex.Message, + }, _jsonOpts); + } + } + + // Run a budget of VM instructions. Returns drained outbound messages + // (as a JSON array of DebugMessage) + a small status object. The + // worker loops over this until either a stop message arrives, a + // terminate request comes in, or the program completes. + [JSExport] + public static string DebugTick(int ops) + { + if (_debugSession == null) + return JsonSerializer.Serialize(new { running = false, complete = true, messages = Array.Empty() }, _jsonOpts); + + // Per-tick reset for the cooperative-wait hint. WaitImpl writes + // this when `wait ms` fires inside the debug session; the pump + // (worker.js pumpDebugTick) reads it from the response and uses + // it as the next setTimeout delay. Without the reset, a stale + // value from a previous tick would re-trigger the wait. + _pendingWaitMs = 0; + try { _debugSession.StartDebugging(ops); } + catch (Exception ex) { /* never fail the worker — surface as a message */ + _debugSession.Enqueue(new DebugMessage { id = NextDebugId(), type = DebugMessageType.NOOP }); + return JsonSerializer.Serialize(new + { + running = false, + complete = true, + error = "Runtime exception: " + ex.Message, + messages = Array.Empty(), + }, _jsonOpts); + } + // If WaitImpl flipped requestedExit to unwind early (kind=3 yield + // for breakpoint updates etc., or kind=2 terminate before the + // page's debug-terminate has landed), clear the flag now so the + // NEXT tick can resume normally. For genuine kind=2 terminate + // the debug-terminate message will null _debugSession on the + // next worker tick anyway, so the reset is harmless there. + _debugSession.ClearYieldRequest(); + + var drained = _debugSession.DrainOutbound(); + var msgs = new List(drained.Count); + foreach (var m in drained) + { + msgs.Add(new + { + id = m.id, + type = m.type.ToString(), + json = m.RawJson ?? m.Jsonify(), + }); + } + + // No synthetic events. The page acts as its own DAP adapter — it + // listens for PROTO_ACK with status=1 on its own step requests and + // treats those as "stopped after step", same way a real DAP adapter + // translates the ACK into a DAP Stopped event for VSCode. + + var printed = ""; + return JsonSerializer.Serialize(new + { + running = !_debugSession.IsPaused, + paused = _debugSession.IsPaused, + complete = _debugSession.ProgramComplete, + instructionPointer = _debugSession.InstructionPointer, + messages = msgs, + // Cooperative wait: when `wait ms` fired during this tick + // the JS pump should setTimeout for that duration before + // the next tick. Zero means "no wait pending" — pump uses + // its normal small interval. + waitMs = _pendingWaitMs, + printed, + }, _jsonOpts); + } + + // Replace the active breakpoint set. linesJson is a JSON array of + // { lineNumber, colNumber? } pairs in the source's coordinate space. + [JSExport] + public static string DebugSetBreakpoints(string linesJson) + { + if (_debugSession == null) return "false"; + var input = JsonSerializer.Deserialize>(linesJson, _jsonOpts) + ?? new List(); + var msg = new RequestBreakpointMessage + { + id = NextDebugId(), + type = DebugMessageType.REQUEST_BREAKPOINTS, + breakpoints = input.Select(b => new Breakpoint + { + lineNumber = b.Line, + colNumber = b.Column, + }).ToList(), + }; + // RawJson is what the session uses when re-parsing typed payloads. + msg.RawJson = msg.Jsonify(); + _debugSession.Enqueue(msg); + return "true"; + } + + [JSExport] + public static string DebugStep(string kind) + { + if (_debugSession == null) return "false"; + var type = kind switch + { + "over" => DebugMessageType.REQUEST_STEP_OVER, + "in" => DebugMessageType.REQUEST_STEP_IN, + "out" => DebugMessageType.REQUEST_STEP_OUT, + _ => DebugMessageType.NOOP, + }; + if (type == DebugMessageType.NOOP) return "false"; + EnqueueBasic(type); + return "true"; + } + + [JSExport] + public static string DebugContinue() + { + if (_debugSession == null) return "false"; + EnqueueBasic(DebugMessageType.REQUEST_PLAY); + return "true"; + } + + [JSExport] + public static string DebugPause() + { + if (_debugSession == null) return "false"; + EnqueueBasic(DebugMessageType.REQUEST_PAUSE); + return "true"; + } + + [JSExport] + public static string DebugTerminate() + { + // Do NOT enqueue REQUEST_TERMINATE — DebugSession's handler calls + // Environment.Exit(0) which would kill the entire WASM runtime. + // Just drop our references; the session is GC'd naturally and the + // tick loop sees `session == null` on its next call. + _debugSession = null; + _debugContext = null; + _debugTestEntry = null; + return "true"; + } + + // Extract a FadeTestResult from the currently-debugging test's VM. + // Returns "null" (JSON) when the session isn't a test debug or when + // there's no live session to inspect. + // + // Callable at any point during a debug-test session — the Playground + // typically calls it once the session emits 'complete' so it can + // flip the test row from 'running' to 'pass'/'fail'. Calling + // mid-execution returns a partial snapshot (assertionFailure may + // not be set yet); the result is most meaningful when the VM has + // run past program.Length, which is exactly the 'complete' signal + // the Playground listens for. + [JSExport] + public static string GetDebugTestResult() + { + if (_debugSession == null || _debugTestEntry == null) return "null"; + var vm = _debugSession._vm; + if (vm == null) return "null"; + var elapsed = System.TimeSpan.Zero; // debug sessions don't time tests + var result = FadeBasic.Sdk.FadeTestExecutor.BuildResultFromVm( + vm, + _debugTestEntry, + elapsed, + _debugContext?.Compiler.DebugData, + runtimeException: null); + return JsonSerializer.Serialize(TestResultToObject(result), _jsonOpts); + } + + [JSExport] + public static string DebugStackFrames() + { + if (_debugSession == null) return "[]"; + var frames = _debugSession.GetFrames2(); + return JsonSerializer.Serialize(frames, _jsonOpts); + } + + [JSExport] + public static string DebugScopes(int frameId) + { + if (_debugSession == null) return "{\"scopes\":[]}"; + var resp = _debugSession.GetScopes(new DebugScopeRequest { frameIndex = frameId }); + StripRuntimeRefs(resp); + return JsonSerializer.Serialize(resp, _jsonOpts); + } + + [JSExport] + public static string DebugVariableExpansion(int variableId) + { + if (_debugSession == null) return "{\"scopes\":[]}"; + var sub = _debugSession.variableDb.Expand(variableId); + var msg = new ScopesMessage { scopes = new List { sub } }; + StripRuntimeRefs(msg); + return JsonSerializer.Serialize(msg, _jsonOpts); + } + + // DebugVariable carries a `runtimeVariable` field that holds live VM + // internals (delegates, byref data) — System.Text.Json can't serialize + // them. The native LSP/DAP serializer skips this via IJsonable's + // ProcessJson, but our STJ-based path here doesn't honor that. Null + // the field before serializing so the response is clean. + private static void StripRuntimeRefs(ScopesMessage msg) + { + if (msg?.scopes == null) return; + foreach (var scope in msg.scopes) + { + if (scope?.variables == null) continue; + foreach (var v in scope.variables) v.runtimeVariable = null; + } + } + + [JSExport] + public static string DebugEval(int frameId, string expression) + { + if (_debugSession == null) return "null"; + var result = _debugSession.Eval(frameId, expression); + return JsonSerializer.Serialize(result, _jsonOpts); + } + + [JSExport] + public static string DebugRepl(int frameId, string code) + { + if (_debugSession == null) return "null"; + var result = _debugSession.ReplExec(frameId, code); + return JsonSerializer.Serialize(result, _jsonOpts); + } + + [JSExport] + public static string DebugSetVariable(int frameId, int variableId, string rhs) + { + if (_debugSession == null) return "null"; + var result = _debugSession.Eval(frameId, rhs, variableId); + // DebugVariableDatabase caches the local/global scope on first read + // and returns the cached object on subsequent calls. After a + // successful set the underlying VM memory is updated but the cached + // DebugVariable.value strings still show the old display value. + // Bust the cache so the next GetScopes call rebuilds with fresh + // values. (ClearLifetime resets variable IDs too — the page must + // re-request scopes; expandedVars by-id state on the client + // intentionally resets per pause anyway.) + if (result != null && result.id != -1) + { + try { _debugSession.variableDb.ClearLifetime(); } catch { /* best effort */ } + } + return JsonSerializer.Serialize(result, _jsonOpts); + } + + private static void EnqueueBasic(DebugMessageType type) + { + if (_debugSession == null) return; + var msg = new DebugMessage { id = NextDebugId(), type = type }; + msg.RawJson = msg.Jsonify(); + _debugSession.Enqueue(msg); + } + + private sealed class BreakpointRequestDto + { + public int Line { get; set; } + public int Column { get; set; } + } + + // Returns a JSON object with FadeBasic + .NET runtime version strings + // for display in the browser's Diagnostics panel. + [JSExport] + public static string GetVersionInfo() + { + var asm = typeof(FadeBasic.Virtual.VirtualMachine).Assembly; + var attrs = (System.Reflection.AssemblyInformationalVersionAttribute[]) + asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false); + var fadeVersion = attrs.Length > 0 ? attrs[0].InformationalVersion : asm.GetName().Version?.ToString() ?? "unknown"; + var dotnetVersion = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; + return JsonSerializer.Serialize(new { fadeBasic = fadeVersion, dotnet = dotnetVersion }); + } +} + diff --git a/WebRuntime/Program.cs b/FadeBasic/FadeBasic.Export.Web/Program.cs similarity index 100% rename from WebRuntime/Program.cs rename to FadeBasic/FadeBasic.Export.Web/Program.cs diff --git a/WebRuntime/Properties/launchSettings.json b/FadeBasic/FadeBasic.Export.Web/Properties/launchSettings.json similarity index 100% rename from WebRuntime/Properties/launchSettings.json rename to FadeBasic/FadeBasic.Export.Web/Properties/launchSettings.json diff --git a/WebRuntime/StandardCommandDocs.cs b/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs similarity index 88% rename from WebRuntime/StandardCommandDocs.cs rename to FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs index b93f0fc..21c06a9 100644 --- a/WebRuntime/StandardCommandDocs.cs +++ b/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs @@ -18,7 +18,7 @@ using FadeBasic.Lib.Standard; using FadeBasic.LSP.Core; -namespace WebRuntime; +namespace FadeBasic.Export.Web; internal static class StandardCommandDocs { @@ -37,11 +37,9 @@ internal static class StandardCommandDocs public static ICommandDocsProvider BuildWeb() => BuildFromMetadata(StandardCommandsMetaData.COMMANDS_JSON); - /// Docs for the 'monogame' command surface — FadeMonoGameCommands + StandardCommands. + /// Docs for the 'monogame' command surface — placeholder until dynamic command registration is wired up. public static ICommandDocsProvider BuildMonoGame() => - BuildFromMetadata( - global::Fade.MonoGame.Lib.FadeMonoGameCommandsMetaData.COMMANDS_JSON, - StandardCommandsMetaData.COMMANDS_JSON); + BuildFromMetadata(StandardCommandsMetaData.COMMANDS_JSON); private static ICommandDocsProvider BuildFromMetadata(params string[] commandsJsonBlobs) { diff --git a/WebRuntime/WebDebugSession.cs b/FadeBasic/FadeBasic.Export.Web/WebDebugSession.cs similarity index 98% rename from WebRuntime/WebDebugSession.cs rename to FadeBasic/FadeBasic.Export.Web/WebDebugSession.cs index 9fad3da..e878932 100644 --- a/WebRuntime/WebDebugSession.cs +++ b/FadeBasic/FadeBasic.Export.Web/WebDebugSession.cs @@ -3,7 +3,7 @@ // inbound / outbound message queues directly so the worker can drive // the session by method call. // -// Lifecycle in WebRuntime: +// Lifecycle in FadeBasic.Export.Web: // 1. Bridge compiles source → VirtualMachine + Compiler.DebugData. // 2. Bridge constructs WebDebugSession(vm, dbg, …) — no StartServer(). // 3. Worker tick loop calls `session.StartDebugging(ops=200)` in batches @@ -21,7 +21,7 @@ using FadeBasic.Launch; using FadeBasic.Virtual; -namespace WebRuntime; +namespace FadeBasic.Export.Web; internal sealed class WebDebugSession : DebugSession { 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/WebRuntime/wwwroot/css/app.css b/FadeBasic/FadeBasic.Export.Web/wwwroot/css/app.css similarity index 100% rename from WebRuntime/wwwroot/css/app.css rename to FadeBasic/FadeBasic.Export.Web/wwwroot/css/app.css diff --git a/FadeBasic/FadeBasic.Export.Web/wwwroot/index.html b/FadeBasic/FadeBasic.Export.Web/wwwroot/index.html new file mode 100644 index 0000000..a39c263 --- /dev/null +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/index.html @@ -0,0 +1,317 @@ + + + + + Fade Web Export + + + +
Loading runtime…
+
+ + + + diff --git a/WebRuntime/wwwroot/web-commands.js b/FadeBasic/FadeBasic.Export.Web/wwwroot/web-commands.js similarity index 100% rename from WebRuntime/wwwroot/web-commands.js rename to FadeBasic/FadeBasic.Export.Web/wwwroot/web-commands.js diff --git a/WebRuntime/wwwroot/worker.html b/FadeBasic/FadeBasic.Export.Web/wwwroot/worker.html similarity index 97% rename from WebRuntime/wwwroot/worker.html rename to FadeBasic/FadeBasic.Export.Web/wwwroot/worker.html index b04ed31..cbf43c1 100644 --- a/WebRuntime/wwwroot/worker.html +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/worker.html @@ -3,7 +3,7 @@ - Fade WebRuntime (worker mode) + Fade Web Runtime (worker mode) @@ -1580,6 +2084,7 @@

Fade Playground

Run (⌘R) Stop Debug (⌘D) + Export +
+
Game runtime not started
-
Hit Run on a project with type: "monogame" in fade.json to boot it.
+
Hit Run to boot the runtime for this project.
@@ -1829,6 +2345,45 @@

Fade Playground

+ +
+
+
+
+ + +
+ + No model loaded + + +
+ +
+
+ + + +
+
+
+ + +
+
+
Models
+
+
+
- $(DefineConstants);BLAZORGL - BlazorGL - true - - false - - - - - 4.2.9001.2 - - - - - - - - - - - - TargetFramework=net8.0 - - - - diff --git a/WebRuntime.MonoGame/_Imports.razor b/WebRuntime.MonoGame/_Imports.razor deleted file mode 100644 index 7f61dca..0000000 --- a/WebRuntime.MonoGame/_Imports.razor +++ /dev/null @@ -1,6 +0,0 @@ -@using System.Net.Http -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.JSInterop -@using WebRuntime.MonoGame -@using WebRuntime.MonoGame.Pages diff --git a/WebRuntime.MonoGame/global.json b/WebRuntime.MonoGame/global.json deleted file mode 100644 index 69b4148..0000000 --- a/WebRuntime.MonoGame/global.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "sdk": { - "version": "10.0.100", - "rollForward": "latestMajor" - } -} - diff --git a/WebRuntime.MonoGame/scripts/boot-check.mjs b/WebRuntime.MonoGame/scripts/boot-check.mjs deleted file mode 100644 index d2c30ac..0000000 --- a/WebRuntime.MonoGame/scripts/boot-check.mjs +++ /dev/null @@ -1,116 +0,0 @@ -// Headless boot check for WebRuntime.MonoGame standalone. -// -// Runs the dev server's index.html in a real Chromium tab, captures console -// messages and uncaught errors, and asserts the canvas has actually been -// drawn to (non-uniform pixels). The Phase 1 sample game cycles its clear -// color and slides a white square horizontally — if the canvas stays uniform -// for 3 seconds, the bootstrap didn't reach the render loop. -// -// Usage: -// 1. In another terminal: `cd WebRuntime.MonoGame && dotnet run -c Release --urls http://localhost:5298` -// 2. `node scripts/boot-check.mjs` (uses Playwright from ../Playground/node_modules) - -import { chromium } from 'playwright'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -// Reuse Playground's playwright install — it's already pinned there and the -// monogame runtime is dev-only tooling, no need for a separate node_modules. -process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', '..', 'Playground', 'node_modules', 'playwright', '.local-browsers'); - -const URL = process.env.URL || 'http://localhost:5298/'; -const TIMEOUT_MS = 30000; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 800, height: 600 } }); - -const logs = []; -const errors = []; -page.on('console', (msg) => logs.push(`[${msg.type()}] ${msg.text()}`)); -page.on('pageerror', (e) => errors.push(`[pageerror] ${e.message}\n${e.stack ?? ''}`)); -page.on('requestfailed', (req) => { - errors.push(`[requestfailed] ${req.url()} — ${req.failure()?.errorText}`); -}); - -console.log(`→ navigating to ${URL}`); -await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS }); - -// Wait for theCanvas to exist (Index.razor mounts after Blazor boots). -try { - await page.waitForSelector('#theCanvas', { timeout: TIMEOUT_MS }); -} catch (e) { - console.log('\n=== canvas never appeared, dumping logs/errors ==='); - for (const l of logs) console.log(l); - for (const er of errors) console.log(er); - await browser.close(); - process.exit(1); -} - -// Give the rAF loop a few seconds to draw something. -console.log('→ waiting 3s for render loop to produce frames…'); -await page.waitForTimeout(3000); - -// Sample the canvas via element screenshot rather than reading the -// drawing-buffer. KNI's WebGL context uses preserveDrawingBuffer:false (the -// default), so canvas.toDataURL / drawImage(canvas) post-frame returns a -// cleared framebuffer. page.screenshot captures the composited pixels that -// the user actually sees — works regardless of the preserveDrawingBuffer flag. -const canvasInfo = await page.evaluate(() => { - const canvas = document.getElementById('theCanvas'); - if (!canvas) return { ok: false, reason: 'no canvas element' }; - return { ok: true, w: canvas.width, h: canvas.height }; -}); - -if (!canvasInfo.ok) { - await browser.close(); - console.error(`\n✗ FAIL: ${canvasInfo.reason}`); - process.exit(1); -} - -const canvasHandle = await page.$('#theCanvas'); -const pngBytes = await canvasHandle.screenshot({ type: 'png' }); - -// Decode the PNG with a tiny in-process parser is overkill — use the page -// itself: blob it back through an Image+canvas to inspect pixel spread. -const sample = await page.evaluate(async (b64) => { - const bin = atob(b64); - const u8 = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); - const blob = new Blob([u8], { type: 'image/png' }); - const bmp = await createImageBitmap(blob); - const probe = document.createElement('canvas'); - probe.width = 32; probe.height = 32; - const ctx = probe.getContext('2d'); - ctx.drawImage(bmp, 0, 0, 32, 32); - const px = ctx.getImageData(0, 0, 32, 32).data; - let minR = 255, maxR = 0, minG = 255, maxG = 0, minB = 255, maxB = 0; - for (let i = 0; i < px.length; i += 4) { - if (px[i] < minR) minR = px[i]; if (px[i] > maxR) maxR = px[i]; - if (px[i+1] < minG) minG = px[i+1]; if (px[i+1] > maxG) maxG = px[i+1]; - if (px[i+2] < minB) minB = px[i+2]; if (px[i+2] > maxB) maxB = px[i+2]; - } - const spread = (maxR - minR) + (maxG - minG) + (maxB - minB); - return { ok: spread > 0, spread, sample: { minR, maxR, minG, maxG, minB, maxB } }; -}, Buffer.from(pngBytes).toString('base64')); - -sample.canvasSize = { w: canvasInfo.w, h: canvasInfo.h }; - -await browser.close(); - -console.log('\n=== console log ==='); -for (const l of logs.slice(-40)) console.log(l); - -if (errors.length) { - console.log('\n=== errors ==='); - for (const e of errors) console.log(e); -} - -console.log('\n=== render probe ==='); -console.log(JSON.stringify(sample, null, 2)); - -if (!sample.ok) { - console.error('\n✗ FAIL: canvas appears uniform — render loop never drew or readback returned transparent pixels.'); - process.exit(1); -} -console.log('\n✓ PASS: canvas has non-uniform pixels — KNI render pipeline is alive.'); diff --git a/WebRuntime.MonoGame/scripts/reload-check.mjs b/WebRuntime.MonoGame/scripts/reload-check.mjs deleted file mode 100644 index e007893..0000000 --- a/WebRuntime.MonoGame/scripts/reload-check.mjs +++ /dev/null @@ -1,88 +0,0 @@ -// Verifies hot-reload by calling LoadProgram from JS with a new fbasic -// source after the initial boot. The test: -// 1. Wait for the canvas to render the initial boot-stub program. -// 2. Compile + load a new fbasic program via DotNet.invokeMethod('LoadProgram', src). -// 3. Capture status text — it should flip to "reloaded" if the call landed -// and the existing _game was reused, "running" if Game1 was just -// constructed for the first time. -// 4. Bonus: load an intentionally-broken source and confirm we get -// "compile error" without nuking the game. - -import { chromium } from 'playwright'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', '..', 'Playground', 'node_modules', 'playwright', '.local-browsers'); - -const URL = process.env.URL || 'http://localhost:5298/'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 800, height: 600 } }); - -const errors = []; -page.on('console', msg => { - const t = msg.type(); - if (t === 'error') console.log('[console.error]', msg.text().slice(0, 300)); -}); -page.on('pageerror', e => { errors.push(e); console.log('[pageerror]', e.message.slice(0, 300)); }); - -console.log(`→ navigate ${URL}`); -await page.goto(URL, { waitUntil: 'domcontentloaded' }); -await page.waitForSelector('#theCanvas', { timeout: 30000 }); -await page.waitForTimeout(2500); - -// Find the Blazor circuit's component ID so we can call our JSInvokables. -// XnaFiddle's pattern uses window.theInstance — our index.html exposes it -// when initRenderJS fires. Use it directly here. -async function callLoadProgram(src) { - return await page.evaluate(async (source) => { - if (!window.theInstance) return { ok: false, err: 'window.theInstance missing — initRenderJS may not have fired yet' }; - try { - const ok = await window.theInstance.invokeMethodAsync('LoadProgram', source); - return { ok, status: document.getElementById('status')?.textContent }; - } catch (e) { - return { ok: false, err: String(e) }; - } - }, src); -} - -console.log('\n=== Test 1: good source, expect "reloaded" ==='); -const goodSrc = `print "hello from hot reload" -do - sync -loop -`; -const r1 = await callLoadProgram(goodSrc); -console.log(JSON.stringify(r1)); - -console.log('\n=== Test 2: bad source (syntax error), expect "compile error" ==='); -const badSrc = `THIS IS NOT VALID FADE BASIC ====`; -const r2 = await callLoadProgram(badSrc); -console.log(JSON.stringify(r2)); - -console.log('\n=== Test 3: another good source, recover ==='); -const r3 = await callLoadProgram(`print "second reload" -do - sync -loop -`); -console.log(JSON.stringify(r3)); - -const pass = - r1.ok === true && r1.status === 'reloaded' && - r2.ok === false && r2.status === 'compile error' && - r3.ok === true && r3.status === 'reloaded'; - -await browser.close(); - -if (errors.length) { - console.log('\nPAGE ERRORS:'); - for (const e of errors) console.log(e.message); -} - -if (!pass) { - console.error('\n✗ FAIL: one or more hot-reload assertions failed.'); - process.exit(1); -} -console.log('\n✓ PASS: hot reload + compile-error path both working.'); diff --git a/WebRuntime.MonoGame/scripts/test-bridge-check.mjs b/WebRuntime.MonoGame/scripts/test-bridge-check.mjs deleted file mode 100644 index 9cab265..0000000 --- a/WebRuntime.MonoGame/scripts/test-bridge-check.mjs +++ /dev/null @@ -1,81 +0,0 @@ -// Verifies the testing bridge: ListTests + RunTests JSInvokables on -// Pages/Index.razor.cs. The fbasic source declares a couple of tests -// (passing + failing) and we check the JSON the bridge returns. - -import { chromium } from 'playwright'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', '..', 'Playground', 'node_modules', 'playwright', '.local-browsers'); - -const URL = process.env.URL || 'http://localhost:5298/'; -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 800, height: 600 } }); - -const consoleErrors = []; -page.on('console', msg => { - if (msg.type() === 'error') consoleErrors.push(msg.text().slice(0, 300)); -}); -page.on('pageerror', e => consoleErrors.push('[pageerror] ' + e.message.slice(0, 300))); - -console.log(`→ navigate ${URL}`); -await page.goto(URL, { waitUntil: 'domcontentloaded' }); -await page.waitForSelector('#theCanvas', { timeout: 30000 }); -await page.waitForTimeout(2500); - -const testSrc = `test foo - assert 1 + 1 = 2 -endtest - -test bar_will_fail - assert 1 = 2 -endtest - -do - sync -loop -`; - -async function rpc(method, ...args) { - return await page.evaluate(async ({ method, args }) => { - if (!window.theInstance) return { _err: 'no instance' }; - try { - return await window.theInstance.invokeMethodAsync(method, ...args); - } catch (e) { - return { _err: String(e) }; - } - }, { method, args }); -} - -console.log('\n=== ListTests ==='); -const listed = JSON.parse(await rpc('ListTests', testSrc)); -console.log(JSON.stringify(listed, null, 2).slice(0, 600)); - -console.log('\n=== RunTests (all) ==='); -const all = JSON.parse(await rpc('RunTests', testSrc, '')); -console.log(JSON.stringify(all, null, 2)); - -console.log('\n=== RunTests (named: foo) ==='); -const one = JSON.parse(await rpc('RunTests', testSrc, 'foo')); -console.log(JSON.stringify(one, null, 2)); - -await browser.close(); - -if (consoleErrors.length) { - console.log('\nCONSOLE ERRORS:'); - for (const e of consoleErrors) console.log(' ', e); -} - -const ok = - Array.isArray(listed) && listed.length === 2 && - listed.some(t => t.name === 'foo') && - listed.some(t => t.name === 'bar_will_fail') && - all.passed === 1 && all.failed === 1 && - one.passed === 1 && one.failed === 0; - -if (!ok) { - console.error('\n✗ FAIL: testing bridge assertions failed.'); - process.exit(1); -} -console.log('\n✓ PASS: ListTests + RunTests (all and named) both work.'); diff --git a/WebRuntime.MonoGame/wwwroot/css/app.css b/WebRuntime.MonoGame/wwwroot/css/app.css deleted file mode 100644 index 42ebf26..0000000 --- a/WebRuntime.MonoGame/wwwroot/css/app.css +++ /dev/null @@ -1,87 +0,0 @@ -html, body { - margin: 0; - padding: 0; - height: 100%; - overflow: hidden; - background: #000; - color: #d4d4d4; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -} - -#app { - position: fixed; - inset: 0; - z-index: 5; -} - -/* Blazor mounts here; canvasHolder is inside Index.razor and fills its - container. In standalone mode this also covers the viewport via fixed - positioning. When loaded inline into the Playground, the parent panel - constrains it instead. */ -#mg-blazor-root { - position: fixed; - inset: 0; - z-index: 1; -} - -#canvasHolder { - position: absolute; - inset: 0; - background: #000; -} - -#theCanvas { - display: block; - width: 100%; - height: 100%; - outline: none; -} - -#status { - position: fixed; - top: 8px; - left: 8px; - padding: 4px 10px; - background: rgba(0, 0, 0, 0.6); - color: #ccc; - font: 12px ui-monospace, 'SF Mono', Menlo, monospace; - border-radius: 3px; - pointer-events: none; - z-index: 10; -} - -/* Splash visible only until Blazor renders the App component into #app. */ -.boot-splash { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: 100%; - gap: 0.5rem; - text-align: center; - padding: 1rem; -} -.boot-splash > div:first-child { - font-size: 1.4rem; - color: #fff; -} -.boot-splash-sub { - color: #888; - font-size: 0.85rem; - max-width: 32rem; -} - -#blazor-error-ui { - position: fixed; - bottom: 0; - left: 0; - right: 0; - background: lemonchiffon; - color: #444; - padding: 0.6rem 1rem; - box-shadow: 0 -1px 2px rgba(0,0,0,0.2); - display: none; - z-index: 1000; - font-size: 0.85rem; -} -#blazor-error-ui.show { display: block; } diff --git a/WebRuntime.MonoGame/wwwroot/index.html b/WebRuntime.MonoGame/wwwroot/index.html deleted file mode 100644 index 53b595e..0000000 --- a/WebRuntime.MonoGame/wwwroot/index.html +++ /dev/null @@ -1,160 +0,0 @@ - - - - - - - Fade WebRuntime.MonoGame — Standalone - - - - - - - -
-
-
loading WebRuntime.MonoGame…
-
Standalone test: a color-clear canvas means KNI + Game1 are alive. Use the browser console to call window.theInstance.invokeMethodAsync('LoadProgram', source) to run a Fade program.
-
-
- - -
- -
- An unhandled error has occurred. - Reload - x -
- - - - - - - - - - - - - - - - - - - - - - diff --git a/WebRuntime/FadeBridge.cs b/WebRuntime/FadeBridge.cs deleted file mode 100644 index 09fcb73..0000000 --- a/WebRuntime/FadeBridge.cs +++ /dev/null @@ -1,922 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices.JavaScript; -using System.Runtime.Versioning; -using System.Text; -using System.Text.Json; -using Microsoft.JSInterop; -using FadeBasic; -using FadeBasic.Json; -using FadeBasic.Launch; -using FadeBasic.Lib.Standard; -using FadeBasic.Sdk; -// FadeBasic.Sdk.Fade collides with the Fade.* MonoGame namespaces that -// arrive transitively via the Fade.MonoGame.Lib ProjectReference. Alias -// so `FadeSdk.TryCreateFromString(...)` is unambiguous. -using FadeSdk = FadeBasic.Sdk.Fade; -using FadeBasic.LSP.Core; -using FadeBasic.LSP.Core.Handlers; - -namespace WebRuntime; - -// FadeBridge is the browser-side adapter between the worker's postMessage -// surface and the cross-platform LSP logic in FadeBasic.LSP.Core. The native -// LSP server in FadeBasic/LSP/ will get the same Core handlers behind its -// OmniSharp transport once it's refactored. -[SupportedOSPlatform("browser")] -public static partial class FadeBridge -{ - // Active workspace — rebuilt by SetProjectType when the editor switches - // between fade.json types. Each project type has a different CommandCollection - // so the LSP knows which commands exist (sprite/texture/etc. for monogame, - // location/alert/etc. for web). - private static FadeWorkspace _workspace = CreateWorkspace("web"); - private static string _activeProjectType = "web"; - - private static FadeWorkspace CreateWorkspace(string projectType) - { - // Swap the standard `wait ms` to the interruptible JS path so - // pause/stop during a long wait isn't blocked by Thread.Sleep on - // the VM worker. This MUST run before any user code does — that's - // why it's pinned here in the static initializer, alongside the - // workspace itself. - StandardCommands.WaitImpl = ms => - { - if (ms <= 0) return; - int kind; - try { kind = WebInterop.WaitMsInterruptible(ms); } - catch (System.Exception) - { - System.Threading.Thread.Sleep(ms); - return; - } - // 0 = wait completed normally — nothing to do. - // 1 = page wants the VM to pause. Enqueue REQUEST_PAUSE - // synchronously so the very next instruction check - // pauses, instead of waiting up to a full DebugTick - // budget for the worker's debug-pause postMessage to - // drain into the session. - // 2 = page wants the VM to terminate. Throw cancellation so - // ctx.Run unwinds without running any more ops; the - // surrounding DebugTick swallows the exception and - // reports complete. - if (kind == 1) - { - EnqueueBasic(DebugMessageType.REQUEST_PAUSE); - // Also surface a synthetic stop event so the page's debug - // adapter flips into the paused UI state immediately. - _debugSession?.EmitStop(); - } - else if (kind == 2) - { - // Terminate. Throw OperationCanceledException to unwind - // mid-instruction so the very next instruction doesn't - // run. The VM's Execute3 wrapper catches it and surfaces - // a runtime-error message (the "angry JSON" the user - // sees in the console); the page's debug-terminate - // message then nulls _debugSession on the next worker - // tick and the session ends. Imperfect but at least the - // VM stops at the right place. - throw new System.OperationCanceledException("wait ms interrupted by terminate"); - } - else if (kind == 3) - { - // Wake-only yield for page→VM updates (breakpoints, etc.). - // Flip the session's requestedExit + enqueue a NOOP so - // both the inner Execute3 batch AND the outer loop break. - // DebugTick resets the flag after StartDebugging returns - // (see ClearYieldRequest) so the next worker tick - // resumes normally with the new state applied. - _debugSession?.RequestYield(); - } - }; - - // Pick the command set per project type. Web ships the console-style - // surface; monogame swaps in Fade.MonoGame.Lib's graphics commands. - // Both register StandardCommands at the bottom of the stack. - // Docs come from the source-generator's COMMANDS_JSON blobs and feed - // the same MarkdownDocParser pipeline; monogame includes both blobs. - CommandCollection commands; - ICommandDocsProvider docs; - switch (projectType) - { - case "monogame": - commands = new CommandCollection( - new global::Fade.MonoGame.Lib.FadeMonoGameCommands(), - new StandardCommands()); - docs = StandardCommandDocs.BuildMonoGame(); - break; - default: - commands = new CommandCollection(new WebCommands(), new StandardCommands()); - docs = StandardCommandDocs.BuildWeb(); - break; - } - - var ws = new FadeWorkspace(commands); - ws.Docs = docs; - return ws; - } - - // Called by the worker (main.ts → worker.js) when the active fade.json - // type changes. Rebuilds the workspace with the right CommandCollection - // so the LSP picks up the new command surface. Returns the new type so - // the page can log/confirm. Idempotent. - [JSExport] - public static string SetProjectType(string projectType) - { - var t = (projectType ?? "web").ToLowerInvariant(); - if (t == _activeProjectType) return t; - _activeProjectType = t; - _workspace = CreateWorkspace(t); - return t; - } - - // camelCase JSON to match LSP wire-protocol convention; TS interfaces in - // Playground use lowercase field names. IncludeFields is critical — Core - // DTOs use public FIELDS (not properties), which System.Text.Json ignores - // by default. Without this every diagnostic serializes as {} and the - // Playground throws "d.range is undefined". - private static readonly JsonSerializerOptions _jsonOpts = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, - IncludeFields = true, - }; - - // ─── Run ────────────────────────────────────────────────────────────── - [JSInvokable] - [JSExport] - // Returns a JSON envelope so the page can format different kinds of - // output (compile errors / runtime errors / printed stdout) with their - // own styling. Shape: { compileError, runtimeError, printed }. Any - // field may be null/empty. Print output also streams through `onPrint` - // during execution; we drain anything that wasn't flushed yet. - public static string CompileAndRun(string source) - { - var commands = _workspace.Commands; - if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) - { - return JsonSerializer.Serialize(new - { - compileError = errors.ToDisplay(), - runtimeError = (string)null, - printed = "", - }, _jsonOpts); - } - - string runtimeError = null; - try { ctx.Run(); } - catch (Exception ex) { runtimeError = ex.GetType().Name + ": " + ex.Message; } - - return JsonSerializer.Serialize(new - { - compileError = (string)null, - runtimeError, - printed = WebCommands.DrainPrintBuffer() ?? "", - }, _jsonOpts); - } - - // ─── LSP entry points — thin adapters over Core ─────────────────────── - - [JSExport] - public static string LspSetDocument(string uri, string text) - { - try - { - var doc = _workspace.SetDocument(uri, text); - return JsonSerializer.Serialize(DiagnosticsHandler.Compute(doc), _jsonOpts); - } - catch (Exception ex) - { - return JsonSerializer.Serialize(new LspDiagnostic[] - { - new LspDiagnostic - { - Severity = LspDiagnosticSeverity.Error, - Range = new LspRange - { - Start = new LspPosition { Line = 0, Character = 0 }, - End = new LspPosition { Line = 0, Character = 1 }, - }, - Message = $"LSP internal error: {ex.GetType().Name}: {ex.Message}", - Code = "INT-001", - Source = "fade", - }, - }, _jsonOpts); - } - } - - [JSExport] - public static string LspGetSemanticTokens(string uri) - { - var doc = _workspace.Get(uri); - return JsonSerializer.Serialize(SemanticTokensHandler.Compute(doc), _jsonOpts); - } - - [JSExport] - public static string LspHover(string uri, int line, int character) - { - var doc = _workspace.Get(uri); - var hover = HoverHandler.Compute(doc, line, character); - return hover == null ? "null" : JsonSerializer.Serialize(hover, _jsonOpts); - } - - [JSExport] - public static string LspCompletion(string uri, int line, int character) - { - var doc = _workspace.Get(uri); - var items = CompletionHandler.Compute(doc, line, character); - return JsonSerializer.Serialize(items, _jsonOpts); - } - - [JSExport] - public static string LspGetAllDiagnostics() - { - var all = new Dictionary>(); - foreach (var doc in _workspace.AllDocuments) - all[doc.Uri] = DiagnosticsHandler.Compute(doc); - return JsonSerializer.Serialize(all, _jsonOpts); - } - - [JSExport] - public static string LspSignatureHelp(string uri, int line, int character) - { - var doc = _workspace.Get(uri); - var sig = SignatureHelpHandler.Compute(doc, line, character); - return sig == null ? "null" : JsonSerializer.Serialize(sig, _jsonOpts); - } - - [JSExport] - public static string LspReferences(string uri, int line, int character) - { - var doc = _workspace.Get(uri); - var refs = ReferencesHandler.Compute(doc, line, character); - return JsonSerializer.Serialize(refs ?? new List(), _jsonOpts); - } - - [JSExport] - public static string LspDefinition(string uri, int line, int character) - { - var doc = _workspace.Get(uri); - var def = DefinitionHandler.Compute(doc, line, character); - return def == null ? "null" : JsonSerializer.Serialize(def, _jsonOpts); - } - - [JSExport] - public static string LspDocumentSymbols(string uri) - { - var doc = _workspace.Get(uri); - var syms = DocumentSymbolHandler.Compute(doc); - return JsonSerializer.Serialize(syms ?? new List(), _jsonOpts); - } - - [JSExport] - public static string LspFoldingRanges(string uri) - { - var doc = _workspace.Get(uri); - var ranges = FoldingRangeHandler.Compute(doc); - return JsonSerializer.Serialize(ranges ?? new List(), _jsonOpts); - } - - // optionsJson is an LspFormattingOptions in camelCase JSON. - [JSExport] - public static string LspFormat(string uri, string optionsJson) - { - var doc = _workspace.Get(uri); - var opts = string.IsNullOrEmpty(optionsJson) - ? new LspFormattingOptions() - : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); - var edits = FormattingHandler.Compute(doc, opts); - return JsonSerializer.Serialize(edits, _jsonOpts); - } - - [JSExport] - public static string LspFormatRange(string uri, string optionsJson, int startLine, int startCh, int endLine, int endCh) - { - var doc = _workspace.Get(uri); - var opts = string.IsNullOrEmpty(optionsJson) - ? new LspFormattingOptions() - : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); - var range = new LspRange - { - Start = new LspPosition { Line = startLine, Character = startCh }, - End = new LspPosition { Line = endLine, Character = endCh }, - }; - var edits = FormattingHandler.ComputeRange(doc, opts, range); - return JsonSerializer.Serialize(edits, _jsonOpts); - } - - [JSExport] - public static string LspFormatOnType(string uri, string optionsJson, int line, int character) - { - var doc = _workspace.Get(uri); - var opts = string.IsNullOrEmpty(optionsJson) - ? new LspFormattingOptions() - : JsonSerializer.Deserialize(optionsJson, _jsonOpts) ?? new LspFormattingOptions(); - var edits = FormattingHandler.ComputeOnType(doc, opts, new LspPosition { Line = line, Character = character }); - return JsonSerializer.Serialize(edits, _jsonOpts); - } - - [JSExport] - public static string LspRename(string uri, int line, int character, string newName) - { - var doc = _workspace.Get(uri); - var edit = RenameHandler.Compute(doc, line, character, newName); - return edit == null ? "null" : JsonSerializer.Serialize(edit, _jsonOpts); - } - - // ─── Help / command docs ────────────────────────────────────────────── - // Returns a JSON array of every command currently loaded in the - // workspace's CommandCollection, with the same markdown the hover - // provider renders. Used by the page's Help tab to build a TOC + - // per-command reader. One row per UNIQUE command name (overloads - // collapse — the first signature wins). Sorted alphabetically. - [JSExport] - public static string ListCommandDocs() - { - try - { - var commands = _workspace.Commands?.Commands; - if (commands == null) - { - return "[]"; - } - // Dedupe by command.name. Overloads (e.g. `rgb` with 3 vs 4 - // args) share a name; we surface one row per name and use the - // first CommandInfo we find — BuildCommandMarkdown already - // describes all parameter slots from that signature. - var seen = new HashSet(); - var rows = new List(); - foreach (var c in commands) - { - if (string.IsNullOrEmpty(c.name)) continue; - if (!seen.Add(c.name)) continue; - string markdown; - try - { - markdown = FadeBasic.LSP.Core.Handlers.HoverHandler.BuildCommandMarkdown( - c, _workspace.Docs); - } - catch (Exception ex) - { - markdown = $"### {c.name}\n\n_Failed to render docs: {ex.Message}_"; - } - rows.Add(new - { - name = c.name, - signature = c.sig, - // Best-effort: classify into a "group" based on the - // command name's first word for the TOC. The native - // command-doc generator keeps a category in metadata - // we don't propagate here yet; this is a useful - // approximation until that's wired through. - group = GuessGroup(c.name), - markdown, - }); - } - // Stable alphabetical order so the TOC is deterministic. - rows.Sort((a, b) => - string.Compare( - (string)a.GetType().GetProperty("name").GetValue(a), - (string)b.GetType().GetProperty("name").GetValue(b), - StringComparison.OrdinalIgnoreCase)); - return JsonSerializer.Serialize(rows, _jsonOpts); - } - catch (Exception ex) - { - return JsonSerializer.Serialize(new - { - error = "Failed to enumerate command docs: " + ex.Message, - }, _jsonOpts); - } - } - - // Cheap heuristic: cluster commands by their first word so the TOC - // gets meaningful section headings (e.g. "print", "string", "wait"). - // For multi-word commands ("wait ms", "wait key") this also yields a - // shared bucket. Single-word commands get their own bucket named - // after themselves only when no peers share the prefix — to avoid - // a 200-bucket TOC, single-words fall back to a generic "Core" group. - private static string GuessGroup(string name) - { - if (string.IsNullOrEmpty(name)) return "Core"; - var idx = name.IndexOf(' '); - return idx > 0 ? name.Substring(0, idx) : "Core"; - } - - // ─── Tests ──────────────────────────────────────────────────────────── - // Compile the source and list the test entry points. Returns a JSON - // array of { name, isAbstract, fromParent, sourceLine }. On compile - // failure returns an empty array (errors surface via LspSetDocument). - [JSExport] - public static string ListTests(string source) - { - try - { - var commands = _workspace.Commands; - if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out _)) - return "[]"; - var tests = new List(); - foreach (var t in ctx.Compiler.TestManifest) - { - tests.Add(new - { - name = t.name, - isAbstract = t.isAbstract, - fromParent = t.fromParent, - sourceLine = t.sourceLine, - sourceChar = t.sourceChar, - }); - } - return JsonSerializer.Serialize(tests, _jsonOpts); - } - catch - { - return "[]"; - } - } - - // Compile + run either all tests (testName empty / null) or a single - // named test. Returns JSON with { passed, failed, duration, results[], - // printed, error? }. `printed` is the captured stdout from any - // `print` statements run during testing. - [JSExport] - public static string RunTests(string source, string testName) - { - var sb = new StringBuilder(); - var commands = _workspace.Commands; - if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) - { - return JsonSerializer.Serialize(new - { - passed = 0, - failed = 0, - error = "Compile failed:\n" + errors.ToDisplay(), - results = Array.Empty(), - printed = "", - }, _jsonOpts); - } - - // Drain any output from previous runs so the captured output is - // attributable to this invocation only. - WebCommands.DrainPrintBuffer(); - - try - { - object payload; - if (string.IsNullOrWhiteSpace(testName)) - { - var run = ctx.RunAllTests(); - payload = new - { - passed = run.passedCount, - failed = run.failedCount, - duration = run.duration.TotalMilliseconds, - results = ResultsToObjects(run.tests), - }; - } - else - { - var r = ctx.RunTest(testName); - payload = new - { - passed = r.passed ? 1 : 0, - failed = r.passed ? 0 : 1, - duration = r.duration.TotalMilliseconds, - results = ResultsToObjects(new List { r }), - }; - } - var printed = WebCommands.DrainPrintBuffer(); - return JsonSerializer.Serialize(new - { - passed = ((dynamic)payload).passed, - failed = ((dynamic)payload).failed, - duration = ((dynamic)payload).duration, - results = ((dynamic)payload).results, - printed, - }, _jsonOpts); - } - catch (Exception ex) - { - return JsonSerializer.Serialize(new - { - passed = 0, - failed = 0, - error = "Runtime error: " + ex.GetType().Name + ": " + ex.Message, - results = Array.Empty(), - printed = WebCommands.DrainPrintBuffer(), - }, _jsonOpts); - } - } - - private static List ResultsToObjects(List results) - { - var list = new List(results.Count); - foreach (var r in results) - { - var frames = new List(); - if (r.failureFrames != null) - { - foreach (var f in r.failureFrames) - { - frames.Add(new - { - functionName = f.functionName, - lineNumber = f.lineNumber, - charNumber = f.charNumber, - instructionIndex = f.instructionIndex, - }); - } - } - list.Add(new - { - name = r.testName, - passed = r.passed, - duration = r.duration.TotalMilliseconds, - failureMessage = r.failureMessage, - failureReason = r.failureReason, - failureSourceText = r.failureSourceText, - failureInstructionIndex = r.failureInstructionIndex, - failureFrames = frames, - }); - } - return list; - } - - // ─── Debug session (DAP) ──────────────────────────────────────────── - // One active session at a time. The worker calls DebugStart() to - // compile + boot a session, then DebugTick() in a loop to make - // forward progress, draining outbound messages between ticks. - - private static FadeRuntimeContext _debugContext; - private static WebDebugSession _debugSession; - private static int _debugMessageIdCounter; - // Tracks the pause state across ticks so we can emit a synthetic stop - // event on running→paused transitions (e.g. step landings, which the - // base DebugSession only signals via a PROTO_ACK on the step request). - private static bool _debugWasPaused; - - private static int NextDebugId() => ++_debugMessageIdCounter; - - // Compile + boot a debug session that targets a specific test entry - // point. Mirrors FadeTestExecutor.RunTest's setup — a fresh VM at the - // test's entry address with isTestExecution=true — but wraps it in a - // WebDebugSession so we can pause, step, and inspect normally. - [JSExport] - public static string DebugStartTest(string source, string testName) - { - try - { - DebugTerminate(); - var commands = _workspace.Commands; - if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) - { - return JsonSerializer.Serialize(new - { - ok = false, - error = "Compile failed:\n" + errors.ToDisplay(), - statementLines = Array.Empty(), - }, _jsonOpts); - } - FadeBasic.Virtual.TestManifestEntry foundEntry = null; - foreach (var t in ctx.Compiler.TestManifest) - { - if (string.Equals(t.name, testName, StringComparison.OrdinalIgnoreCase)) - { - foundEntry = t; - break; - } - } - if (foundEntry == null || foundEntry.isAbstract) - { - return JsonSerializer.Serialize(new - { - ok = false, - error = foundEntry == null - ? $"No test named '{testName}' found" - : $"Test '{testName}' is abstract and cannot be debugged", - }, _jsonOpts); - } - - WebCommands.DrainPrintBuffer(); - // Fresh VM at the test's entry address (matches - // FadeTestExecutor.RunTest's bootstrap so the test runs the same - // way it would in normal test execution). - var vm = new FadeBasic.Virtual.VirtualMachine(ctx.Machine.program, foundEntry.entryPointAddress) - { - hostMethods = ctx.Compiler.methodTable, - isTestExecution = true, - }; - _debugContext = ctx; - _debugSession = new WebDebugSession(vm, ctx.Compiler.DebugData, commands); - _debugWasPaused = true; - EnqueueBasic(DebugMessageType.REQUEST_PAUSE); - - var lines = new SortedSet(); - foreach (var t in ctx.Compiler.DebugData.statementTokens) - if (t?.token != null) lines.Add(t.token.lineNumber); - return JsonSerializer.Serialize(new - { - ok = true, - statementLines = lines, - testName = foundEntry.name, - testLine = foundEntry.sourceLine, - }, _jsonOpts); - } - catch (Exception ex) - { - return JsonSerializer.Serialize(new - { - ok = false, - error = "Debug-test start failed: " + ex.Message, - }, _jsonOpts); - } - } - - // Compile + boot a debug session. Returns JSON with { ok, error?, - // statementTokens[] } so the page can render gutter glyphs at valid - // breakpoint lines. - [JSExport] - public static string DebugStart(string source) - { - try - { - DebugTerminate(); // reset any prior session. - var commands = _workspace.Commands; - if (!FadeSdk.TryCreateFromString(source, commands, out var ctx, out var errors)) - { - return JsonSerializer.Serialize(new - { - ok = false, - error = "Compile failed:\n" + errors.ToDisplay(), - statementLines = Array.Empty(), - }, _jsonOpts); - } - WebCommands.DrainPrintBuffer(); - _debugContext = ctx; - _debugSession = new WebDebugSession(ctx.Machine, ctx.Compiler.DebugData, commands); - // Pre-mark as paused so the first tick's running→paused detection - // doesn't fire a synthetic stop event for our internal start- - // pause. Real pauses (breakpoints, steps) flip from false→true - // and emit normally. - _debugWasPaused = true; - - // Start the session in a paused state. The page must set its - // breakpoints and then call DebugContinue() to begin running. - // Without this, the tick loop in worker.js would race the page - // and execute past any breakpoints before they're installed. - EnqueueBasic(DebugMessageType.REQUEST_PAUSE); - - // Surface valid statement lines so the editor can show breakpoint - // hints. statementTokens have 1-based lineNumber. - var lines = new SortedSet(); - foreach (var t in ctx.Compiler.DebugData.statementTokens) - if (t?.token != null) lines.Add(t.token.lineNumber); - return JsonSerializer.Serialize(new - { - ok = true, - statementLines = lines, - }, _jsonOpts); - } - catch (Exception ex) - { - return JsonSerializer.Serialize(new - { - ok = false, - error = "Debug start failed: " + ex.Message, - }, _jsonOpts); - } - } - - // Run a budget of VM instructions. Returns drained outbound messages - // (as a JSON array of DebugMessage) + a small status object. The - // worker loops over this until either a stop message arrives, a - // terminate request comes in, or the program completes. - [JSExport] - public static string DebugTick(int ops) - { - if (_debugSession == null) - return JsonSerializer.Serialize(new { running = false, complete = true, messages = Array.Empty() }, _jsonOpts); - - try { _debugSession.StartDebugging(ops); } - catch (Exception ex) { /* never fail the worker — surface as a message */ - _debugSession.Enqueue(new DebugMessage { id = NextDebugId(), type = DebugMessageType.NOOP }); - return JsonSerializer.Serialize(new - { - running = false, - complete = true, - error = "Runtime exception: " + ex.Message, - messages = Array.Empty(), - }, _jsonOpts); - } - // If WaitImpl flipped requestedExit to unwind early (kind=3 yield - // for breakpoint updates etc., or kind=2 terminate before the - // page's debug-terminate has landed), clear the flag now so the - // NEXT tick can resume normally. For genuine kind=2 terminate - // the debug-terminate message will null _debugSession on the - // next worker tick anyway, so the reset is harmless there. - _debugSession.ClearYieldRequest(); - - var drained = _debugSession.DrainOutbound(); - var msgs = new List(drained.Count); - foreach (var m in drained) - { - msgs.Add(new - { - id = m.id, - type = m.type.ToString(), - json = m.RawJson ?? m.Jsonify(), - }); - } - - // No synthetic events. The page acts as its own DAP adapter — it - // listens for PROTO_ACK with status=1 on its own step requests and - // treats those as "stopped after step", same way a real DAP adapter - // translates the ACK into a DAP Stopped event for VSCode. - - var printed = WebCommands.DrainPrintBuffer(); - return JsonSerializer.Serialize(new - { - running = !_debugSession.IsPaused, - paused = _debugSession.IsPaused, - complete = _debugSession.ProgramComplete, - instructionPointer = _debugSession.InstructionPointer, - messages = msgs, - printed, - }, _jsonOpts); - } - - // Replace the active breakpoint set. linesJson is a JSON array of - // { lineNumber, colNumber? } pairs in the source's coordinate space. - [JSExport] - public static string DebugSetBreakpoints(string linesJson) - { - if (_debugSession == null) return "false"; - var input = JsonSerializer.Deserialize>(linesJson, _jsonOpts) - ?? new List(); - var msg = new RequestBreakpointMessage - { - id = NextDebugId(), - type = DebugMessageType.REQUEST_BREAKPOINTS, - breakpoints = input.Select(b => new Breakpoint - { - lineNumber = b.Line, - colNumber = b.Column, - }).ToList(), - }; - // RawJson is what the session uses when re-parsing typed payloads. - msg.RawJson = msg.Jsonify(); - _debugSession.Enqueue(msg); - return "true"; - } - - [JSExport] - public static string DebugStep(string kind) - { - if (_debugSession == null) return "false"; - var type = kind switch - { - "over" => DebugMessageType.REQUEST_STEP_OVER, - "in" => DebugMessageType.REQUEST_STEP_IN, - "out" => DebugMessageType.REQUEST_STEP_OUT, - _ => DebugMessageType.NOOP, - }; - if (type == DebugMessageType.NOOP) return "false"; - EnqueueBasic(type); - return "true"; - } - - [JSExport] - public static string DebugContinue() - { - if (_debugSession == null) return "false"; - EnqueueBasic(DebugMessageType.REQUEST_PLAY); - return "true"; - } - - [JSExport] - public static string DebugPause() - { - if (_debugSession == null) return "false"; - EnqueueBasic(DebugMessageType.REQUEST_PAUSE); - return "true"; - } - - [JSExport] - public static string DebugTerminate() - { - // Do NOT enqueue REQUEST_TERMINATE — DebugSession's handler calls - // Environment.Exit(0) which would kill the entire WASM runtime. - // Just drop our references; the session is GC'd naturally and the - // tick loop sees `session == null` on its next call. - _debugSession = null; - _debugContext = null; - return "true"; - } - - [JSExport] - public static string DebugStackFrames() - { - if (_debugSession == null) return "[]"; - var frames = _debugSession.GetFrames2(); - return JsonSerializer.Serialize(frames, _jsonOpts); - } - - [JSExport] - public static string DebugScopes(int frameId) - { - if (_debugSession == null) return "{\"scopes\":[]}"; - var resp = _debugSession.GetScopes(new DebugScopeRequest { frameIndex = frameId }); - StripRuntimeRefs(resp); - return JsonSerializer.Serialize(resp, _jsonOpts); - } - - [JSExport] - public static string DebugVariableExpansion(int variableId) - { - if (_debugSession == null) return "{\"scopes\":[]}"; - var sub = _debugSession.variableDb.Expand(variableId); - var msg = new ScopesMessage { scopes = new List { sub } }; - StripRuntimeRefs(msg); - return JsonSerializer.Serialize(msg, _jsonOpts); - } - - // DebugVariable carries a `runtimeVariable` field that holds live VM - // internals (delegates, byref data) — System.Text.Json can't serialize - // them. The native LSP/DAP serializer skips this via IJsonable's - // ProcessJson, but our STJ-based path here doesn't honor that. Null - // the field before serializing so the response is clean. - private static void StripRuntimeRefs(ScopesMessage msg) - { - if (msg?.scopes == null) return; - foreach (var scope in msg.scopes) - { - if (scope?.variables == null) continue; - foreach (var v in scope.variables) v.runtimeVariable = null; - } - } - - [JSExport] - public static string DebugEval(int frameId, string expression) - { - if (_debugSession == null) return "null"; - var result = _debugSession.Eval(frameId, expression); - return JsonSerializer.Serialize(result, _jsonOpts); - } - - [JSExport] - public static string DebugRepl(int frameId, string code) - { - if (_debugSession == null) return "null"; - var result = _debugSession.ReplExec(frameId, code); - return JsonSerializer.Serialize(result, _jsonOpts); - } - - [JSExport] - public static string DebugSetVariable(int frameId, int variableId, string rhs) - { - if (_debugSession == null) return "null"; - var result = _debugSession.Eval(frameId, rhs, variableId); - // DebugVariableDatabase caches the local/global scope on first read - // and returns the cached object on subsequent calls. After a - // successful set the underlying VM memory is updated but the cached - // DebugVariable.value strings still show the old display value. - // Bust the cache so the next GetScopes call rebuilds with fresh - // values. (ClearLifetime resets variable IDs too — the page must - // re-request scopes; expandedVars by-id state on the client - // intentionally resets per pause anyway.) - if (result != null && result.id != -1) - { - try { _debugSession.variableDb.ClearLifetime(); } catch { /* best effort */ } - } - return JsonSerializer.Serialize(result, _jsonOpts); - } - - private static void EnqueueBasic(DebugMessageType type) - { - if (_debugSession == null) return; - var msg = new DebugMessage { id = NextDebugId(), type = type }; - msg.RawJson = msg.Jsonify(); - _debugSession.Enqueue(msg); - } - - private sealed class BreakpointRequestDto - { - public int Line { get; set; } - public int Column { get; set; } - } - - // Returns a JSON object with FadeBasic + .NET runtime version strings - // for display in the browser's Diagnostics panel. - [JSExport] - public static string GetVersionInfo() - { - var asm = typeof(FadeBasic.Virtual.VirtualMachine).Assembly; - var attrs = (System.Reflection.AssemblyInformationalVersionAttribute[]) - asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false); - var fadeVersion = attrs.Length > 0 ? attrs[0].InformationalVersion : asm.GetName().Version?.ToString() ?? "unknown"; - var dotnetVersion = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; - return JsonSerializer.Serialize(new { fadeBasic = fadeVersion, dotnet = dotnetVersion }); - } -} - diff --git a/WebRuntime/WebCommands.cs b/WebRuntime/WebCommands.cs deleted file mode 100644 index cdbdd66..0000000 --- a/WebRuntime/WebCommands.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using System.Runtime.InteropServices.JavaScript; -using System.Runtime.Versioning; -using System.Text; -using FadeBasic.SourceGenerators; - -namespace WebRuntime; - -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); - // Live stream — main thread's web-commands.js exports a no-op; - // worker's setModuleImports overrides it to postMessage back to the page. - 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 web based alert to the user - /// - /// the message - [FadeBasicCommand("alert")] - public static void Alert(string msg) => WebInterop.Alert(msg); - - // Synchronous user-input prompt for scripting and tests. Returns the - // entered string (or an empty string if the user cancels). The host JS - // bridge implements the actual prompt — main-thread mode uses window.prompt, - // worker mode posts a request and blocks on the response. - [FadeBasicCommand("prompt$")] - public static string Prompt(string message) => WebInterop.Prompt(message); -} - -[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); - - [JSImport("prompt", "web-commands")] - internal static partial string Prompt(string msg); - - // Cooperative `wait ms` for WASM. The JS-side impl blocks on - // Atomics.wait(timeout=ms) over a shared buffer; the main thread can - // Atomics.notify the buffer to wake the wait early (e.g. when the - // user clicks Pause / Stop). Returns the milliseconds actually waited - // — call sites can ignore it. - [JSImport("waitMsInterruptible", "web-commands")] - internal static partial int WaitMsInterruptible(int milliseconds); -} diff --git a/WebRuntime/WebRuntime.csproj b/WebRuntime/WebRuntime.csproj deleted file mode 100644 index 7918efa..0000000 --- a/WebRuntime/WebRuntime.csproj +++ /dev/null @@ -1,41 +0,0 @@ - - - - net10.0 - enable - enable - true - - - - - - - - - - - - - - - - - TargetFramework=net8.0 - - - - diff --git a/WebRuntime/global.json b/WebRuntime/global.json deleted file mode 100644 index 512142d..0000000 --- a/WebRuntime/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "10.0.100", - "rollForward": "latestFeature" - } -} diff --git a/WebRuntime/wwwroot/index.html b/WebRuntime/wwwroot/index.html deleted file mode 100644 index 43f712f..0000000 --- a/WebRuntime/wwwroot/index.html +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - Fade WebRuntime PoC - - - - - - -

Fade WebRuntime

-

Loading .NET runtime...

- - -
- - -

Output

-
(not yet run)
- - - - - - diff --git a/WebRuntime/wwwroot/worker.js b/WebRuntime/wwwroot/worker.js deleted file mode 100644 index c009a54..0000000 --- a/WebRuntime/wwwroot/worker.js +++ /dev/null @@ -1,481 +0,0 @@ -// Dedicated module worker: hosts the .NET runtime + Fade compiler/VM. -// Bootstraps the runtime once, then handles run requests from the page -// via postMessage. - -// Relative import (not '/_framework/...') so the runner is portable: hosts can -// mount this worker at any subpath (e.g. /runtime/worker.js) and the import -// still resolves correctly relative to the worker's own URL. -import { dotnet } from './_framework/dotnet.js'; - -let exports = null; -const queue = []; -// Each worker hosts a .NET runtime + bridge. The page boots TWO of these: -// -// role='lsp' — handles LSP traffic (set-document, hover, completion, -// semantic tokens, …) plus the lightweight `list-tests` -// compile step. Stays responsive at all times. -// role='vm' — owns the live VM. Handles `run`, `run-tests`, and the -// whole `debug-*` family. May get sync-blocked by user -// code calling Thread.Sleep (e.g. `wait ms`) — that's -// expected; the lsp worker keeps the page responsive. -// -// Both workers post heartbeats so the UI can distinguish "page is alive" -// from "VM is alive". The role flips behavior at message-dispatch time; -// the .NET runtime + JS module bindings are identical on both. -let role = 'lsp'; - -function log(message) { - self.postMessage({ type: 'log', message, role }); -} - -// ─── Heartbeat ────────────────────────────────────────────────────────────── -// Posts a beat to the main thread every 500ms so the UI can show a -// "worker alive" indicator. A synchronous Thread.Sleep inside the VM -// (e.g. `wait ms`) blocks this worker thread entirely, which means the -// heartbeats stop until the sleep returns — that's exactly what we want -// to surface to the user as a busy state. -let heartbeatTick = 0; -setInterval(() => { - heartbeatTick = (heartbeatTick + 1) | 0; - self.postMessage({ type: 'heartbeat', tick: heartbeatTick, t: Date.now(), role }); -}, 500); - -// ─── Synchronous prompt$ handshake ────────────────────────────────────────── -// `prompt$` is a JSImport that must return synchronously from C#'s -// perspective, even though the actual UI prompt happens on the main thread. -// We do this with a SharedArrayBuffer + Atomics.wait. The main thread sends -// a SharedArrayBuffer at boot time; on prompt(), we postMessage the request -// and Atomics.wait on the sync slot. The main thread fills bytes in the -// buffer and notifies us. We decode and return. -// -// Layout: Int32Array[0] = sync state (0 = waiting, 1 = ready) -// Int32Array[1] = response length in bytes -// bytes[8..8+length] = UTF-8 response payload -let promptSab = null; -let promptSync = null; -let promptBytes = null; - -// ─── Interruptible `wait ms` ─────────────────────────────────────────────── -// Atomics.wait blocks the worker thread up to `ms` milliseconds. Pause / -// stop on the page side writes a non-zero "kind" into the SAB and calls -// Atomics.notify, which wakes the wait early and tells C# what to do -// next. Return value is the kind C# should react to: -// 0 = wait completed normally (timed out, no interrupt) -// 1 = page wants the VM to PAUSE -// 2 = page wants the VM to TERMINATE -let waitSab = null; -let waitView = null; -function waitMsInterruptible(ms) { - if (!waitView) { - // SAB not wired — fall back to busy polling so at least the call - // doesn't crash. No interrupt capability in this mode. - const end = performance.now() + ms; - while (performance.now() < end) { /* spin */ } - return 0; - } - Atomics.store(waitView, 0, 0); - Atomics.wait(waitView, 0, 0, ms); - // Read + clear in one shot so the next wait starts from a clean slot. - const kind = Atomics.exchange(waitView, 0, 0); - return kind | 0; -} - -// ─── Debug tick loop ──────────────────────────────────────────────────────── -// While a debug session is active, we yield to the worker's message pump -// between batches of VM instructions so inbound messages (step/continue/ -// breakpoint changes) can land. Tick budget is small enough that pause/step -// feels responsive; we use setTimeout(0) so messages get processed. -let debugTicking = false; -function startDebugTickLoop() { - debugTicking = true; - pumpDebugTick(); -} -function pumpDebugTick() { - if (!debugTicking) return; - let result; - try { - const json = exports.WebRuntime.FadeBridge.DebugTick(500); - result = JSON.parse(json); - } catch (e) { - debugTicking = false; - self.postMessage({ type: 'debug-event', event: { type: 'error', message: String(e?.message ?? e) } }); - return; - } - // Forward any outbound events back to the main thread. - if (result.messages && result.messages.length) { - for (const m of result.messages) { - self.postMessage({ type: 'debug-event', event: m }); - } - } - // NOTE: do NOT re-emit `result.printed` here. The Print command in - // WebCommands.cs streams every line live via the `web-commands.onPrint` - // JSImport (handled below in setModuleImports), which means every - // line already reached the page as a `print` message during execution. - // Re-emitting the drained buffer would duplicate each line — and the - // duplicate is especially visible at end-of-session when DebugTick - // returns with `complete: true` and a fully-drained buffer. - if (result.complete) { - debugTicking = false; - self.postMessage({ type: 'debug-event', event: { type: 'complete' } }); - return; - } - // Schedule next batch — when paused, slow down so we're not burning CPU. - const delay = result.paused ? 50 : 0; - setTimeout(pumpDebugTick, delay); -} - -function syncPromptFromMain(msg) { - if (!promptSab) { - // No shared buffer — main thread didn't isolate; return empty string. - return ''; - } - Atomics.store(promptSync, 0, 0); - Atomics.store(promptSync, 1, 0); - self.postMessage({ type: 'prompt-request', msg }); - // Block this worker thread until the main thread writes the response. - Atomics.wait(promptSync, 0, 0); - const len = Atomics.load(promptSync, 1); - if (len <= 0) return ''; - // Firefox refuses to decode SharedArrayBuffer-backed views directly. - // Copy into a plain ArrayBuffer first. - const copy = new Uint8Array(len); - copy.set(new Uint8Array(promptSab, 8, len)); - return new TextDecoder().decode(copy); -} - -async function init() { - log('creating .NET runtime...'); - // Do NOT call runMain() — Program.cs ends with host.RunAsync() which never - // returns, hanging the worker forever. Skip Main; bootstrap manually. - const runtime = await dotnet.create(); - log('runtime created, registering JS imports...'); - - // Worker-side implementation of the "web-commands" module. The C# side - // declares [JSImport(..., "web-commands")] for each of these; main-thread - // mode satisfies them by loading web-commands.js, worker mode satisfies - // them here so we never hit "module not registered" errors. - runtime.setModuleImports('web-commands', { - onPrint: (line) => self.postMessage({ type: 'print', line }), - getLocation: () => '(unavailable in worker context)', - getUserAgent: () => self.navigator?.userAgent ?? '(unavailable)', - alert: (msg) => self.postMessage({ type: 'alert', msg }), - prompt: (msg) => syncPromptFromMain(msg), - waitMsInterruptible: (ms) => waitMsInterruptible(ms), - }); - - log('registering assembly exports...'); - const config = runtime.getConfig(); - exports = await runtime.getAssemblyExports(config.mainAssemblyName); - log('exports loaded'); - - while (queue.length) handle(queue.shift()); - self.postMessage({ type: 'ready', role }); -} - -// Op → required role. Anything not listed is treated as either-side. -const VM_OPS = new Set([ - 'run', 'run-tests', 'prompt-sab', - 'debug-start', 'debug-start-test', 'debug-terminate', - 'debug-set-breakpoints', 'debug-step', 'debug-continue', 'debug-pause', - 'debug-stack-frames', 'debug-scopes', 'debug-variable-expansion', - 'debug-eval', 'debug-repl', 'debug-set-variable', -]); - -function handle(msg) { - // Configuration is always accepted — it's what makes us either role. - if (msg.type === 'configure') { - role = msg.role === 'vm' ? 'vm' : 'lsp'; - return; - } - // Cheap roundtrip for the heartbeat probes. - if (msg.type === 'ping') { - self.postMessage({ type: 'pong', id: msg.id, t: Date.now() }); - return; - } - // Sanity guard: if the page accidentally sends a VM op to the LSP - // worker (or vice-versa), surface a clear error instead of silently - // dropping the message. - const isVmOp = VM_OPS.has(msg.type); - if (isVmOp && role !== 'vm') { - self.postMessage({ - type: 'worker-misroute', - requested: msg.type, - actualRole: role, - id: msg.id, - }); - return; - } - if (!isVmOp && role === 'vm' && /^(lsp-|list-tests|list-command-docs)/.test(msg.type)) { - self.postMessage({ - type: 'worker-misroute', - requested: msg.type, - actualRole: role, - id: msg.id, - }); - return; - } - - if (msg.type === 'run') { - let result; - try { - result = exports.WebRuntime.FadeBridge.CompileAndRun(msg.source); - } catch (e) { - result = 'Worker error: ' + (e?.message ?? e); - } - self.postMessage({ type: 'result', id: msg.id, result }); - } else if (msg.type === 'lsp-set') { - log('lsp-set: calling LspSetDocument'); - let diagnosticsJson = '[]'; - try { - diagnosticsJson = exports.WebRuntime.FadeBridge.LspSetDocument(msg.uri, msg.text); - log('lsp-set: returned, length=' + diagnosticsJson.length); - } catch (e) { - log('lsp-set failed: ' + (e?.message ?? e)); - } - self.postMessage({ - type: 'lsp-diagnostics', - uri: msg.uri, - version: msg.version, - diagnostics: diagnosticsJson, - }); - } else if (msg.type === 'lsp-tokens') { - let tokensJson = '[]'; - try { - tokensJson = exports.WebRuntime.FadeBridge.LspGetSemanticTokens(msg.uri); - } catch (e) { - log('lsp-tokens failed: ' + (e?.message ?? e)); - } - self.postMessage({ - type: 'lsp-tokens-result', - id: msg.id, - uri: msg.uri, - tokens: tokensJson, - }); - } else if (msg.type === 'lsp-hover') { - let hoverJson = 'null'; - try { - hoverJson = exports.WebRuntime.FadeBridge.LspHover(msg.uri, msg.line, msg.character); - } catch (e) { - log('lsp-hover failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-hover-result', id: msg.id, hover: hoverJson }); - } else if (msg.type === 'lsp-completion') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.LspCompletion(msg.uri, msg.line, msg.character); - } catch (e) { - log('lsp-completion failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-completion-result', id: msg.id, items: json }); - } else if (msg.type === 'lsp-all-diagnostics') { - let json = '{}'; - try { - json = exports.WebRuntime.FadeBridge.LspGetAllDiagnostics(); - } catch (e) { - log('lsp-all-diagnostics failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-all-diagnostics-result', id: msg.id, all: json }); - } else if (msg.type === 'lsp-signature-help') { - let json = 'null'; - try { - json = exports.WebRuntime.FadeBridge.LspSignatureHelp(msg.uri, msg.line, msg.character); - } catch (e) { - log('lsp-signature-help failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-signature-help-result', id: msg.id, sig: json }); - } else if (msg.type === 'lsp-references') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.LspReferences(msg.uri, msg.line, msg.character); - } catch (e) { - log('lsp-references failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-references-result', id: msg.id, refs: json }); - } else if (msg.type === 'lsp-definition') { - let json = 'null'; - try { - json = exports.WebRuntime.FadeBridge.LspDefinition(msg.uri, msg.line, msg.character); - } catch (e) { - log('lsp-definition failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-definition-result', id: msg.id, def: json }); - } else if (msg.type === 'lsp-document-symbols') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.LspDocumentSymbols(msg.uri); - } catch (e) { - log('lsp-document-symbols failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-document-symbols-result', id: msg.id, symbols: json }); - } else if (msg.type === 'lsp-folding-ranges') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.LspFoldingRanges(msg.uri); - } catch (e) { - log('lsp-folding-ranges failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-folding-ranges-result', id: msg.id, ranges: json }); - } else if (msg.type === 'lsp-format') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.LspFormat(msg.uri, msg.options || ''); - } catch (e) { - log('lsp-format failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-format-result', id: msg.id, edits: json }); - } else if (msg.type === 'lsp-format-range') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.LspFormatRange( - msg.uri, msg.options || '', - msg.startLine, msg.startCh, msg.endLine, msg.endCh, - ); - } catch (e) { - log('lsp-format-range failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-format-range-result', id: msg.id, edits: json }); - } else if (msg.type === 'lsp-format-on-type') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.LspFormatOnType(msg.uri, msg.options || '', msg.line, msg.character); - } catch (e) { - log('lsp-format-on-type failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-format-on-type-result', id: msg.id, edits: json }); - } else if (msg.type === 'lsp-rename') { - let json = 'null'; - try { - json = exports.WebRuntime.FadeBridge.LspRename(msg.uri, msg.line, msg.character, msg.newName); - } catch (e) { - log('lsp-rename failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'lsp-rename-result', id: msg.id, edit: json }); - } else if (msg.type === 'set-project-type') { - // Page sends this when the active fade.json switches between 'web' - // and 'monogame' so the LSP swaps its CommandCollection. The page - // should re-set every open document after this resolves so tokens - // and diagnostics recompute against the new command set. - let resolved = msg.projectType; - try { resolved = exports.WebRuntime.FadeBridge.SetProjectType(msg.projectType); } - catch (e) { log('set-project-type failed: ' + (e?.message ?? e)); } - self.postMessage({ type: 'set-project-type-result', id: msg.id, projectType: resolved }); - } else if (msg.type === 'prompt-sab') { - // Main thread is handing us the SharedArrayBuffer used by syncPromptFromMain. - promptSab = msg.buffer; - promptSync = new Int32Array(promptSab, 0, 2); - promptBytes = new Uint8Array(promptSab, 8); - } else if (msg.type === 'wait-interrupt-sab') { - // SAB used by waitMsInterruptible() — main thread Atomics.notifies - // it to wake an in-flight wait early when the user pauses/stops. - waitSab = msg.buffer; - waitView = new Int32Array(waitSab, 0, 1); - } else if (msg.type === 'debug-start' || msg.type === 'debug-start-test') { - let json = '{}'; - try { - json = msg.type === 'debug-start-test' - ? exports.WebRuntime.FadeBridge.DebugStartTest(msg.source, msg.testName || '') - : exports.WebRuntime.FadeBridge.DebugStart(msg.source); - } catch (e) { - log(msg.type + ' failed: ' + (e?.message ?? e)); - } - self.postMessage({ 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 === 'debug-terminate') { - debugTicking = false; - try { exports.WebRuntime.FadeBridge.DebugTerminate(); } catch (e) { log('terminate failed: ' + e); } - self.postMessage({ type: 'debug-terminate-result', id: msg.id }); - } else if (msg.type === 'debug-set-breakpoints') { - try { exports.WebRuntime.FadeBridge.DebugSetBreakpoints(msg.linesJson); } - catch (e) { log('set-bp failed: ' + e); } - self.postMessage({ type: 'debug-set-breakpoints-result', id: msg.id }); - } else if (msg.type === 'debug-step') { - try { exports.WebRuntime.FadeBridge.DebugStep(msg.kind); } - catch (e) { log('step failed: ' + e); } - self.postMessage({ type: 'debug-step-result', id: msg.id }); - } else if (msg.type === 'debug-continue') { - try { exports.WebRuntime.FadeBridge.DebugContinue(); } - catch (e) { log('continue failed: ' + e); } - self.postMessage({ type: 'debug-continue-result', id: msg.id }); - } else if (msg.type === 'debug-pause') { - try { exports.WebRuntime.FadeBridge.DebugPause(); } - catch (e) { log('pause failed: ' + e); } - self.postMessage({ type: 'debug-pause-result', id: msg.id }); - } else if (msg.type === 'debug-stack-frames') { - let json = '[]'; - try { json = exports.WebRuntime.FadeBridge.DebugStackFrames(); } - catch (e) { log('stack-frames failed: ' + e); } - self.postMessage({ type: 'debug-stack-frames-result', id: msg.id, frames: json }); - } else if (msg.type === 'debug-scopes') { - let json = '{}'; - try { json = exports.WebRuntime.FadeBridge.DebugScopes(msg.frameId); } - catch (e) { log('scopes failed: ' + e); } - self.postMessage({ type: 'debug-scopes-result', id: msg.id, scopes: json }); - } else if (msg.type === 'debug-variable-expansion') { - let json = '{}'; - try { json = exports.WebRuntime.FadeBridge.DebugVariableExpansion(msg.variableId); } - catch (e) { log('var-expand failed: ' + e); } - self.postMessage({ type: 'debug-variable-expansion-result', id: msg.id, scopes: json }); - } else if (msg.type === 'debug-eval') { - let json = 'null'; - try { json = exports.WebRuntime.FadeBridge.DebugEval(msg.frameId, msg.expression); } - catch (e) { log('eval failed: ' + e); } - self.postMessage({ type: 'debug-eval-result', id: msg.id, result: json }); - } else if (msg.type === 'debug-repl') { - let json = 'null'; - try { json = exports.WebRuntime.FadeBridge.DebugRepl(msg.frameId, msg.code); } - catch (e) { log('repl failed: ' + e); } - self.postMessage({ type: 'debug-repl-result', id: msg.id, result: json }); - } else if (msg.type === 'debug-set-variable') { - let json = 'null'; - try { json = exports.WebRuntime.FadeBridge.DebugSetVariable(msg.frameId, msg.variableId, msg.rhs); } - catch (e) { log('set-var failed: ' + e); } - self.postMessage({ type: 'debug-set-variable-result', id: msg.id, result: json }); - } else if (msg.type === 'list-tests') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.ListTests(msg.source); - } catch (e) { - log('list-tests failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'list-tests-result', id: msg.id, tests: json }); - } else if (msg.type === 'list-command-docs') { - let json = '[]'; - try { - json = exports.WebRuntime.FadeBridge.ListCommandDocs(); - } catch (e) { - log('list-command-docs failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'list-command-docs-result', id: msg.id, docs: json }); - } else if (msg.type === 'get-version-info') { - let json = '{}'; - try { - json = exports.WebRuntime.FadeBridge.GetVersionInfo(); - } catch (e) { - log('get-version-info failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'get-version-info-result', id: msg.id, info: json }); - } else if (msg.type === 'run-tests') { - let json = '{}'; - try { - json = exports.WebRuntime.FadeBridge.RunTests(msg.source, msg.testName || ''); - } catch (e) { - log('run-tests failed: ' + (e?.message ?? e)); - } - self.postMessage({ type: 'run-tests-result', id: msg.id, result: json }); - } -} - -self.onmessage = (e) => { - if (exports) { - handle(e.data); - } else { - queue.push(e.data); - } -}; - -init().catch((e) => { - self.postMessage({ type: 'boot-error', message: String(e?.stack ?? e) }); -}); From fced2896afc5fdf33ebbaac7746d7f280296d77d Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Sat, 23 May 2026 15:30:39 -0400 Subject: [PATCH 15/30] another checkpoint --- FadeBasic/Benchmarks/Benchmarks.csproj | 5 +- FadeBasic/Benchmarks/LexerBenchmarks.cs | 195 +++++ FadeBasic/Benchmarks/Program.cs | 8 +- FadeBasic/Benchmarks/Vms.cs | 95 +-- .../FadeBasic.Export.Web.csproj | 18 +- FadeBasic/FadeBasic.Export.Web/FadeBridge.cs | 677 ++------------- .../FadeBasic.Export.Web/wwwroot/index.html | 189 ++--- .../FadeBasic.Export.Web/wwwroot/runtime.js | 524 ++++++++++++ .../FadeBasic.Export.Web/wwwroot/worker.js | 753 +---------------- FadeBasic/FadeBasic/Json/IJsonable.cs | 18 + FadeBasic/FadeBasic/Sdk/CooperativePump.cs | 592 +++++++++++++ Playground/index.html | 253 +++++- Playground/scripts/build-monogame-runtime.mjs | 90 +- Playground/scripts/build-runtime.mjs | 42 +- Playground/scripts/test-monogame-assets.mjs | 4 +- .../scripts/test-monogame-integration.mjs | 71 +- Playground/src/main.ts | 781 +++++++++++++----- Playground/src/monogame-host.ts | 566 ++++++++----- Playground/src/xnb/mgfx.ts | 435 ++++++++++ Playground/src/xnb/xnb-previews.ts | 12 + Playground/tsconfig.tsbuildinfo | 2 +- Playground/vite.config.ts | 33 +- 22 files changed, 3346 insertions(+), 2017 deletions(-) create mode 100644 FadeBasic/Benchmarks/LexerBenchmarks.cs create mode 100644 FadeBasic/FadeBasic.Export.Web/wwwroot/runtime.js create mode 100644 FadeBasic/FadeBasic/Sdk/CooperativePump.cs create mode 100644 Playground/src/xnb/mgfx.ts diff --git a/FadeBasic/Benchmarks/Benchmarks.csproj b/FadeBasic/Benchmarks/Benchmarks.csproj index 1c77130..8845704 100644 --- a/FadeBasic/Benchmarks/Benchmarks.csproj +++ b/FadeBasic/Benchmarks/Benchmarks.csproj @@ -2,15 +2,14 @@ Exe - net6.0 + net8.0 enable enable true - - + diff --git a/FadeBasic/Benchmarks/LexerBenchmarks.cs b/FadeBasic/Benchmarks/LexerBenchmarks.cs new file mode 100644 index 0000000..8263824 --- /dev/null +++ b/FadeBasic/Benchmarks/LexerBenchmarks.cs @@ -0,0 +1,195 @@ +using BenchmarkDotNet.Attributes; +using FadeBasic; + +namespace Benchmarks; + +[MemoryDiagnoser] +public class LexerBenchmarks +{ + // ~10 lines: basic arithmetic and variable types + private const string ShortSource = @" +a = 10 +b = 20 +c = a + b +d# = 3.14 +e# = d# * 2.0 +s$ = ""hello world"" +result = a * b - c +flag = result > 100 +"; + + // ~40 lines: for loops, while, if/else, arrays + private const string MediumSource = @" +dim scores(10) +total = 0 +for i = 1 to 10 + scores(i) = i * i + total = total + scores(i) +next i +average = total / 10 +if average > 30 + big = 1 +else + big = 0 +endif +x# = 1.0 +for j = 1 to 20 + x# = x# * 1.05 +next j +name$ = ""FadeBasic"" +result$ = name$ + "" benchmark"" +n = 0 +while n < 8 + n = n + 1 +endwhile +a = 255 +b = a && 15 +c = a || 1 +d = a XOR b +e = NOT a +for k = 1 to 5 + if k = 3 + acc = acc + k + endif +next k +"; + + // ~130 lines: types, functions, nested loops, strings, mixed expressions + private const string LargeSource = @" +type point + x + y +endtype + +type rect + left + top + right + bottom +endtype + +p as point +p.x = 42 +p.y = 17 + +r as rect +r.left = 0 +r.top = 0 +r.right = 800 +r.bottom = 600 + +dim pts(20) as point +for i = 0 to 19 + pts(i).x = i * 5 + pts(i).y = i * 3 +next i + +sumX = 0 +sumY = 0 +for i = 0 to 19 + sumX = sumX + pts(i).x + sumY = sumY + pts(i).y +next i + +function clamp(val, lo, hi) + if val < lo then val = lo + if val > hi then val = hi +endfunction val + +function sign(n) + if n > 0 then endfunction 1 + if n < 0 then endfunction -1 +endfunction 0 + +a = clamp(150, 0, 100) +b = clamp(-5, 0, 100) +s1 = sign(42) +s2 = sign(-7) +s3 = sign(0) + +acc# = 0.0 +for i = 1 to 50 + v# = i * 0.1 + acc# = acc# + v# +next i + +outer = 0 +for row = 1 to 10 + for col = 1 to 10 + if row = col + outer = outer + 1 + endif + next col +next row + +n = 200 +while n > 0 + n = n - 7 +endwhile + +name$ = ""FadeBasic"" +version$ = ""1.0.0"" +tag$ = name$ + "" v"" + version$ +a$ = ""alpha"" +b$ = ""beta"" +c$ = a$ + b$ + +x# = 1.0 +y# = 1.0 +for step = 1 to 30 + temp# = x# + x# = y# + y# = temp# + y# +next step + +dim vals(100) +for i = 0 to 99 + vals(i) = i * i - i + 1 +next i +total = 0 +for i = 0 to 99 + total = total + vals(i) +next i + +flag1 = total > 1000 +flag2 = total < 500000 +flag3 = flag1 && flag2 +combined = flag1 || flag2 + +base = 2 +power = 1 +for exp = 1 to 16 + power = power * base +next exp +"; + + private Lexer _lexer; + private CommandCollection _commands; + + [GlobalSetup] + public void Setup() + { + _lexer = new Lexer(); + _commands = new CommandCollection(); + ValidateCorpus(ShortSource, nameof(ShortSource)); + ValidateCorpus(MediumSource, nameof(MediumSource)); + ValidateCorpus(LargeSource, nameof(LargeSource)); + } + + private void ValidateCorpus(string source, string name) + { + var result = _lexer.TokenizeWithErrors(source, _commands); + if (result.tokenErrors is { Count: > 0 }) + throw new InvalidOperationException( + $"Corpus '{name}' produced lex errors: {result.tokenErrors[0]}"); + } + + [Benchmark(Baseline = true)] + public LexerResults LexShort() => _lexer.TokenizeWithErrors(ShortSource, _commands); + + [Benchmark] + public LexerResults LexMedium() => _lexer.TokenizeWithErrors(MediumSource, _commands); + + [Benchmark] + public LexerResults LexLarge() => _lexer.TokenizeWithErrors(LargeSource, _commands); +} diff --git a/FadeBasic/Benchmarks/Program.cs b/FadeBasic/Benchmarks/Program.cs index 1e7d8b0..c9a0467 100644 --- a/FadeBasic/Benchmarks/Program.cs +++ b/FadeBasic/Benchmarks/Program.cs @@ -1,9 +1,3 @@ -// See https://aka.ms/new-console-template for more information - using BenchmarkDotNet.Running; -using Benchmarks; -using MoonSharp.Interpreter; - -// Script.RunString("a = 3 + 2"); -var summary = BenchmarkRunner.Run(); \ No newline at end of file +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/FadeBasic/Benchmarks/Vms.cs b/FadeBasic/Benchmarks/Vms.cs index 2d080db..52a87c4 100644 --- a/FadeBasic/Benchmarks/Vms.cs +++ b/FadeBasic/Benchmarks/Vms.cs @@ -1,94 +1,33 @@ using BenchmarkDotNet.Attributes; using FadeBasic; using FadeBasic.Virtual; -using MoonSharp.Interpreter; namespace Benchmarks; [MemoryDiagnoser] public class Vms { - private List _compilerProgram; - private VirtualMachine _vm; + public string Source { get; set; } = + "dim x(4):x(0) = 2:x(1) = x(0) * 2:x(2) = x(1) * x(0):x(3) = x(2) * x(1) * x(0):y = x(3)"; - private Script _lua; + private List _program; + private VirtualMachine _vm; + private CommandCollection _commands; - // [Params( - // // "3 + 2", - // // "(1 + 2 * 4) * (5+2+1) * 2", - // "" - // )] - public string Source { get; set; } = - "dim x(4);x(0) = 2;x(1) = x(0) * 2;x(2) = x(1) * x(0);x(3) = x(2) * x(1) * x(0);y = x(3)"; - [GlobalSetup] public void Setup() { - // var src = Source; - // var lexer = new Lexer(); - // var tokens = lexer.Tokenize(src); - // var parser = new Parser(new TokenStream(tokens), StandardCommands.LimitedCommands); - // var exprAst = parser.ParseProgram(); - // - // var compiler = new Compiler(StandardCommands.LimitedCommands); - // compiler.Compile(exprAst); - // _compilerProgram = compiler.Program; - // _vm = new VirtualMachine(_compilerProgram); - // - Script.WarmUp(); - _lua = new Script(); + _commands = new CommandCollection(); + var lexer = new Lexer(); + var tokens = lexer.TokenizeWithErrors(Source, _commands); + var parser = new Parser(tokens.stream, _commands); + var ast = parser.ParseProgram(); + var compiler = new Compiler(_commands); + compiler.Compile(ast); + _program = compiler.Program; + _vm = new VirtualMachine(_program); } -// -// [Benchmark()] -// public void Dbp() -// { -// var src = Source; -// var lexer = new Lexer(); -// var tokens = lexer.Tokenize(src); -// var parser = new Parser(new TokenStream(tokens), StandardCommands.LimitedCommands); -// var exprAst = parser.ParseProgram(); -// -// var compiler = new Compiler(StandardCommands.LimitedCommands); -// compiler.Compile(exprAst); -// var _compilerProgram = compiler.Program; -// var _vm = new VirtualMachine(_compilerProgram); -// _vm.Execute2(); -// -// } -// - // [Benchmark()] - // public void Dbp_Cached() - // { - // _vm.Execute2(); - // } - // - // [Benchmark()] - // public void Csharp() - // { - // int[] x = new int[] { 2, 4, 6, 8 }; - // int y = x[0] * x[1]; - // } - -// -// [Benchmark()] -// public void Lua() -// { -// Script.RunString(@" -// x = 1 -// y = 2 -// z = 3 -// "); -// } - - [Benchmark()] - public void Lua_Cached() - { - _lua.DoString(@" -x = 1 -y = 2 -z = 3 -"); - } -// -} \ No newline at end of file + // [Benchmark] + // public void Execute() => _vm.Execute3(); +} diff --git a/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj b/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj index 676f5e8..31a7cfd 100644 --- a/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj +++ b/FadeBasic/FadeBasic.Export.Web/FadeBasic.Export.Web.csproj @@ -8,15 +8,15 @@ FadeBasic.Export.Web FadeBasic.Export.Web - - false - + + true + copy + + true +
+ +
Loading Fade Playground…
+
+ + diff --git a/Playground/scripts/build-monogame-runtime.mjs b/Playground/scripts/build-monogame-runtime.mjs index 8c03284..d8b5723 100644 --- a/Playground/scripts/build-monogame-runtime.mjs +++ b/Playground/scripts/build-monogame-runtime.mjs @@ -1,17 +1,19 @@ // Mirrors build-runtime.mjs but publishes WebRuntime.MonoGame (KNI BlazorGL) -// into Playground/public/monogame-runtime/ so Vite can serve it from the same +// into Playground/public/runtime/monogame/ so Vite can serve it from the same // origin as the Playground page when a 'monogame' fade.json project is open. // -// Two runtimes live side-by-side under public/: -// - public/runtime/ ← WebRuntime (net10 plain WASM, LSP/compile/tests/debug, runs in a worker) -// - public/monogame-runtime/ ← WebRuntime.MonoGame (net8 Blazor WASM + KNI, runs the game on a canvas) -// The two boot styles are different on purpose; see Playground/mg.md. +// Layout under public/runtime/ (mg-export-3.md phase 3): +// public/runtime/web/ ← Export.Web template (build-runtime.mjs) +// public/runtime/monogame/ ← this script's output (WebRuntime.MonoGame template) +// public/runtime/fade-libs/ ← shared command DLLs (build-runtime.mjs) +// +// The two templates' boot styles differ on purpose; see Playground/mg.md. import { execSync } from 'node:child_process'; -import { rm, mkdir, cp } from 'node:fs/promises'; +import { rm, mkdir, cp, copyFile, readdir, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; +import { dirname, resolve, relative } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const playgroundDir = resolve(__dirname, '..'); @@ -20,7 +22,11 @@ const publishOut = resolve( playgroundDir, '..', '..', 'Fade.MonoGame', 'Fade.MonoGame', 'WebRuntime.MonoGame', 'bin', 'Release', 'net8.0', 'publish', 'wwwroot', ); -const targetDir = resolve(playgroundDir, 'public', 'monogame-runtime'); +const targetDir = resolve(playgroundDir, 'public', 'runtime', 'monogame'); +// Old (pre-restructure) location. We wipe it once on next build so +// stale leftover assets don't keep getting served. Safe to remove this +// guard a few weeks after the rename has shipped. +const legacyTargetDir = resolve(playgroundDir, 'public', 'monogame-runtime'); console.log('[build:monogame-runtime] dotnet publish', runtimeProject); execSync(`dotnet publish "${runtimeProject}" -c Release`, { @@ -32,6 +38,13 @@ if (!existsSync(publishOut)) { process.exit(1); } +// One-time cleanup of pre-restructure layout. Safe to remove this block +// once everyone's rebuilt past the rename. +if (existsSync(legacyTargetDir)) { + console.log('[build:monogame-runtime] removing legacy', legacyTargetDir); + await rm(legacyTargetDir, { recursive: true, force: true }); +} + console.log('[build:monogame-runtime] clearing', targetDir); await rm(targetDir, { recursive: true, force: true }); await mkdir(targetDir, { recursive: true }); @@ -39,4 +52,65 @@ await mkdir(targetDir, { recursive: true }); console.log('[build:monogame-runtime] copying', publishOut, '→', targetDir); await cp(publishOut, targetDir, { recursive: true }); +// ── Command libs for the LSP ──────────────────────────────────────── +// The LSP worker (FadeBasic.Export.Web in /runtime/web/) needs to know +// the MonoGame command surface for hover, completion, and parse — even +// though it never *executes* monogame commands (the iframe's Game1 owns +// execution). Stage Lib + its required project-deps as real .dll files +// (not the renamed-to-.wasm Blazor variants — those are real WASM modules +// in .NET 8, not loadable via Assembly.Load) so main.ts's LSP-sync can +// fetch + load them when fade.json declares type='monogame'. +// +// Why Game + Contracts: Fade.MonoGame.Lib's csproj has +// ProjectReference → Fade.MonoGame.Game, which transitively pulls in +// Contracts. Activator.CreateInstance(FadeMonoGameCommands) shouldn't +// touch their types eagerly (class-level only references IMethodSource +// from FadeBasic), but pre-loading is cheap insurance. KNI BlazorGL + +// MonoGame.Framework are NOT staged — they're huge and the LSP never +// needs them: method bodies aren't JITed during metadata enumeration. +const monoLibsSrc = resolve( + playgroundDir, '..', '..', 'Fade.MonoGame', 'Fade.MonoGame', 'WebRuntime.MonoGame', + 'bin', 'Release', 'net8.0', +); +const fadeLibsDir = resolve(playgroundDir, 'public', 'runtime', 'fade-libs'); +await mkdir(fadeLibsDir, { recursive: true }); +const monoCommandLibs = [ + 'Fade.MonoGame.Contracts.dll', + 'Fade.MonoGame.Game.dll', + 'Fade.MonoGame.Lib.dll', +]; +for (const name of monoCommandLibs) { + const src = resolve(monoLibsSrc, name); + if (!existsSync(src)) { + console.error(`[build:monogame-runtime] expected ${src} but it does not exist.`); + process.exit(1); + } + await copyFile(src, resolve(fadeLibsDir, name)); + console.log(`[build:monogame-runtime] staged ${name} → public/runtime/fade-libs/`); +} + +// ── Runtime manifest ────────────────────────────────────────────────────────── +// Enumerate every file under public/runtime/monogame/ so the Playground's +// export bundler knows what to include in the static-host zip. Same shape as +// build-runtime.mjs's web manifest — the Playground reads either at zip time +// based on the active project's type. Paths are POSIX-style relative to the +// monogame/ subtree. +async function walk(dir) { + const out = []; + for (const ent of await readdir(dir, { withFileTypes: true })) { + const full = resolve(dir, ent.name); + if (ent.isDirectory()) out.push(...await walk(full)); + else if (ent.isFile()) out.push(full); + } + return out; +} +const allFiles = await walk(targetDir); +const relPaths = allFiles + .map((f) => relative(targetDir, f).split('\\').join('/')) + .filter((p) => p !== 'runtime-manifest.json') + .sort(); +const manifestPath = resolve(targetDir, 'runtime-manifest.json'); +await writeFile(manifestPath, JSON.stringify({ files: relPaths }, null, 2)); +console.log(`[build:monogame-runtime] wrote runtime-manifest.json (${relPaths.length} entries)`); + console.log('[build:monogame-runtime] done.'); diff --git a/Playground/scripts/build-runtime.mjs b/Playground/scripts/build-runtime.mjs index e1730fd..de921b3 100644 --- a/Playground/scripts/build-runtime.mjs +++ b/Playground/scripts/build-runtime.mjs @@ -1,6 +1,11 @@ // Publishes FadeBasic.Export.Web in Release and copies the resulting wwwroot/* -// into Playground/public/runtime/ so Vite can serve the runner from the same +// into Playground/public/runtime/web/ so Vite can serve the runner from the same // origin as the Playground page (workers require same-origin). +// +// Layout under public/runtime/ (mg-export-3.md phase 3): +// public/runtime/web/ ← this script's output (Export.Web template) +// public/runtime/monogame/ ← build-monogame-runtime.mjs's output +// public/runtime/fade-libs/ ← shared command DLLs (this script writes these) import { execSync } from 'node:child_process'; import { rm, mkdir, cp, copyFile, writeFile, readdir, stat } from 'node:fs/promises'; @@ -12,7 +17,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const playgroundDir = resolve(__dirname, '..'); const runtimeProject = resolve(playgroundDir, '..', 'FadeBasic', 'FadeBasic.Export.Web', 'FadeBasic.Export.Web.csproj'); const publishOut = resolve(playgroundDir, '..', 'FadeBasic', 'FadeBasic.Export.Web', 'bin', 'Release', 'net8.0', 'publish', 'wwwroot'); -const targetDir = resolve(playgroundDir, 'public', 'runtime'); +const runtimeRoot = resolve(playgroundDir, 'public', 'runtime'); +const targetDir = resolve(runtimeRoot, 'web'); console.log('[build:runtime] dotnet publish', runtimeProject); execSync(`dotnet publish "${runtimeProject}" -c Release`, { @@ -24,6 +30,22 @@ if (!existsSync(publishOut)) { process.exit(1); } +// One-time cleanup of pre-restructure layout: the old flat layout dropped +// every Export.Web file directly into public/runtime/. Now everything goes +// under public/runtime/web/. Wipe any leftover files at the top level so +// stale `_framework/` / `index.html` / etc. don't shadow the new web/ tree. +// Preserves the sibling subdirs (web/, monogame/, fade-libs/) which are +// managed by this script and build-monogame-runtime.mjs. +const keepAtRoot = new Set(['web', 'monogame', 'fade-libs']); +if (existsSync(runtimeRoot)) { + for (const ent of await readdir(runtimeRoot, { withFileTypes: true })) { + if (keepAtRoot.has(ent.name)) continue; + const full = resolve(runtimeRoot, ent.name); + console.log('[build:runtime] cleaning stale', full); + await rm(full, { recursive: true, force: true }); + } +} + console.log('[build:runtime] clearing', targetDir); await rm(targetDir, { recursive: true, force: true }); await mkdir(targetDir, { recursive: true }); @@ -37,7 +59,15 @@ console.log('[build:runtime] done.'); // Build each preloaded command library and stage its DLL under // public/runtime/fade-libs/ so the Playground can fetch and dynamically load // it at runtime without FadeBasic.Export.Web needing a compile-time reference. -const fadeLibsDir = resolve(targetDir, 'fade-libs'); +// fade-libs lives at the runtime root (not under web/) because both +// templates may need to load DLLs from it. +// Don't wipe the whole fade-libs dir — build-monogame-runtime.mjs stages +// its own DLLs there (Fade.MonoGame.{Contracts,Game,Lib}.dll). Wiping the +// directory means running `npm run dev` (predev → build-runtime) after a +// prior `build:monogame-runtime` deletes the monogame DLLs, which breaks +// the LSP's command highlighting for monogame projects. Just ensure the +// dir exists and overwrite our own DLLs below. +const fadeLibsDir = resolve(runtimeRoot, 'fade-libs'); await mkdir(fadeLibsDir, { recursive: true }); const commandLibs = [ @@ -60,10 +90,12 @@ for (const lib of commandLibs) { } // ── Runtime manifest ────────────────────────────────────────────────────────── -// Enumerate every file under public/runtime/ so the Playground's export +// Enumerate every file under public/runtime/web/ so the Playground's export // download knows what to bundle. We can't list files via fetch on a static // host, so emit a JSON index at build time. Paths are POSIX-style relative -// to the runtime root (e.g. "_framework/dotnet.js", "fade-libs/MyLib.dll"). +// to the web/ subtree (e.g. "_framework/dotnet.js", "index.html"). The +// manifest is consumed by main.ts's web-export bundler; monogame export +// has its own (future) manifest under public/runtime/monogame/. async function walk(dir) { const out = []; for (const ent of await readdir(dir, { withFileTypes: true })) { diff --git a/Playground/scripts/test-monogame-assets.mjs b/Playground/scripts/test-monogame-assets.mjs index e2fe6fb..ae9dfe4 100644 --- a/Playground/scripts/test-monogame-assets.mjs +++ b/Playground/scripts/test-monogame-assets.mjs @@ -7,8 +7,8 @@ // landed in the console (which is the symptom of TextureSystem.GetSourceRect // dereferencing a null watchedTexture — the bug this whole change fixes). // -// Requires a vite server with monogame-runtime already published. Match -// test-monogame-integration.mjs's setup. +// Requires a vite server with the monogame template already published into +// public/runtime/monogame/. Match test-monogame-integration.mjs's setup. import { chromium } from 'playwright'; import { dirname, resolve } from 'node:path'; diff --git a/Playground/scripts/test-monogame-integration.mjs b/Playground/scripts/test-monogame-integration.mjs index 586d8c6..8770f8d 100644 --- a/Playground/scripts/test-monogame-integration.mjs +++ b/Playground/scripts/test-monogame-integration.mjs @@ -1,7 +1,7 @@ // End-to-end Playground × WebRuntime.MonoGame integration check. // -// Setup: vite preview (or dev) running on $URL, with monogame-runtime -// already published into Playground/public/monogame-runtime/. +// Setup: vite preview (or dev) running on $URL, with the monogame +// template already published into Playground/public/runtime/monogame/. // // Test flow: // 1. Open Playground page. @@ -21,17 +21,25 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); process.env.PLAYWRIGHT_BROWSERS_PATH ??= resolve(__dirname, '..', 'node_modules', 'playwright', '.local-browsers'); -const URL = process.env.URL || 'http://localhost:5312/'; +const URL = process.env.URL || 'http://localhost:5311/'; const BOOT_BUDGET_MS = 60_000; // generous: ~8 MB WASM download + warmup +// Iframe selector & path — phase 3 moved the MonoGame canvas inside an +// iframe (id #mg-preview-frame, src /runtime/monogame/index.html?preview=1) +// hosted in the Game panel. #theCanvas now lives in that iframe's document. +const MG_FRAME_SELECTOR = '#mg-preview-frame'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); const errors = []; page.on('console', msg => { - if (msg.type() === 'error') { - const t = msg.text(); - if (t.length < 1000) console.log('[console.error]', t.slice(0, 300)); + const t = msg.text(); + if (msg.type() === 'error' && t.length < 1000) { + console.log('[console.error]', t.slice(0, 300)); + } else if (/\[fade\]|registerCommandAssembly|loadAssembly|preload/.test(t)) { + // Surface fade-prefixed messages regardless of level so the probe + // sees DLL load failures, etc. + console.log(`[console.${msg.type()}]`, t.slice(0, 400)); } }); page.on('pageerror', e => { errors.push(e); console.log('[pageerror]', e.message.slice(0, 300)); }); @@ -70,7 +78,18 @@ const written = await page.evaluate(async () => { const src = await dir.getFileHandle('main.fbasic', { create: true }); const srcW = await src.createWritable(); - await srcW.write('do\n sync\nloop\n'); + // Use a real Fade.MonoGame.Lib command — `set background color` — + // so we can verify the LSP has loaded FadeMonoGameCommands. If the + // LSP doesn't know the command, the parser fails with [0107] + // "ambiguous between declaration or assignment" or similar. + // Paint a recognizable color so the pixel probe below has + // something to detect — pure black would be the default backbuffer + // clear and wouldn't distinguish "Game1 is rendering" from + // "canvas exists but never drew." + // `set background color` takes a packed-int colorCode; `rgb` builds + // one. Both are monogame-only — if either is missing, the parse + // breaks with a clear error. + await srcW.write('set background color rgb(0, 0, 200)\ndo\n sync\nloop\n'); await srcW.close(); // Remember the active project — the bootstrap reads this at load. @@ -103,20 +122,46 @@ const runBtn = await page.$('#run'); if (!runBtn) throw new Error('#run button not in DOM'); await runBtn.click(); -// Wait for the canvas to appear and render. +// Wait for the iframe to appear (monoGameHost.bootInternal lazily +// creates it on first ensureBooted → first Run click), then drill into +// it to find #theCanvas. +let mgFrame; try { - await page.waitForSelector('#theCanvas', { timeout: BOOT_BUDGET_MS }); + await page.waitForSelector(MG_FRAME_SELECTOR, { timeout: 10_000 }); + const frameElHandle = await page.$(MG_FRAME_SELECTOR); + mgFrame = await frameElHandle.contentFrame(); + if (!mgFrame) throw new Error('contentFrame() returned null'); + await mgFrame.waitForSelector('#theCanvas', { timeout: BOOT_BUDGET_MS }); } catch (e) { - console.log('canvas never appeared. recent console errors:'); + console.log(`MonoGame iframe + canvas never appeared (${e?.message ?? e}).`); + if (mgFrame) { + const bodyHTML = await mgFrame.evaluate(() => document.body.outerHTML.slice(0, 1200)); + console.log('--- iframe body (first 1.2k) ---'); + console.log(bodyHTML); + const mgInner = await mgFrame.evaluate(() => { + const root = document.getElementById('mg-blazor-root'); + return { + rootChildren: root ? root.children.length : -1, + rootInner: root ? root.innerHTML.slice(0, 800) : 'no root', + hasCanvas: !!document.getElementById('theCanvas'), + hasNotFound: document.body.textContent?.includes("Sorry, there's nothing") ?? false, + docReadyState: document.readyState, + url: location.href, + }; + }); + console.log('--- iframe inner state ---'); + console.log(JSON.stringify(mgInner, null, 2)); + } + console.log('Recent errors:'); for (const er of errors.slice(-10)) console.log(' ', er.message.slice(0, 400)); await browser.close(); process.exit(1); } -console.log('→ canvas appeared, waiting for render…'); +console.log('→ canvas appeared inside iframe, waiting for render…'); await page.waitForTimeout(3000); -// Pixel-spread probe (preserveDrawingBuffer:false safe — uses element screenshot). -const canvasHandle = await page.$('#theCanvas'); +// Pixel-spread probe — screenshot the canvas from inside the iframe. +const canvasHandle = await mgFrame.$('#theCanvas'); const pngBytes = await canvasHandle.screenshot({ type: 'png' }); const result = await page.evaluate(async (b64) => { const bin = atob(b64); diff --git a/Playground/src/main.ts b/Playground/src/main.ts index 1f415c4..4e6ddd8 100644 --- a/Playground/src/main.ts +++ b/Playground/src/main.ts @@ -74,7 +74,7 @@ import { LEGACY_BINARY_PREVIEW_ID_PREFIX, isBinaryFileName, } from './binary-preview'; -import { patchEffectMgfxVersionForKni, patchSoundEffectForKni } from './xnb/xnb-previews'; +import { patchXnbForKni } from './xnb/xnb-previews'; import { mountHelpPanel } from './help'; import { monoGameHost } from './monogame-host'; import { mountAiChat, mountAiModels } from './ai-chat'; @@ -694,24 +694,17 @@ interface Diagnostic { // now: one worker, both responsibilities. Run blocks LSP for the duration of a // program execution, which is acceptable for v1. class FadeRunner { - // Two workers boot in parallel: - // • lspWorker — set-document, hover, completion, semantic tokens, - // symbols, format, rename, references, definition, folding, - // list-tests. Never executes user code — stays responsive even - // while the VM is sync-blocked. - // • vmWorker — run, run-tests, and the entire debug-* surface. - // May get blocked by `wait ms` (Thread.Sleep) or other blocking - // commands; that's by design and isolated from the page. + // One worker for LSP traffic — never executes user code, stays + // responsive while the VM does its thing. The VM itself lives in + // the active template's iframe (on the iframe's main thread); the + // Runner posts VM-side messages to iframe.contentWindow via the + // postVm helper. attachVmIframe() points the runner at an iframe; + // before that, VM-side calls that need a target are skipped (only + // LSP is meaningful pre-iframe). public lspWorker: Worker; - public vmWorker: Worker; /** Back-compat alias — old code referenced runner.worker for raw access. */ public get worker(): Worker { return this.lspWorker; } - // The current VM-side message target. Defaults to vmWorker so things - // work before any iframe is attached. attachVmIframe() switches this - // to the iframe's contentWindow so debug / tests / run all flow - // through the visible Game tab. The Playground only ever uses one - // VM execution surface at a time — there's no "fallback" routing. - private vmTarget: Worker | Window; + private vmTarget: Window | null = null; private vmIframe: HTMLIFrameElement | null = null; private opts: RunnerOpts; private nextId = 0; @@ -739,19 +732,18 @@ class FadeRunner { constructor(opts: RunnerOpts) { this.opts = opts; - this.lspWorker = new Worker('/runtime/worker.js', { type: 'module' }); - this.vmWorker = new Worker('/runtime/worker.js', { type: 'module' }); - this.vmTarget = this.vmWorker; - // First message each worker receives. The role flips behavior at - // dispatch time (LSP ops are rejected on the vm worker and - // vice-versa, surfacing as a `worker-misroute` event). + this.lspWorker = new Worker('/runtime/web/worker.js', { type: 'module' }); + // worker.js's first-message contract: configure role so heartbeat + // / log events carry the right tag. The VM-side runtime lives in + // the iframe (no Worker), so there's no second configure call. this.lspWorker.postMessage({ type: 'configure', role: 'lsp' }); - this.vmWorker.postMessage({ type: 'configure', role: 'vm' }); - // prompt$ default handler: window.prompt, bridged through the - // cooperative pump's host-message protocol. Pages can override - // by setting runner.onPromptRequest, or register additional - // channels via runner.registerHostHandler(channel, fn). + // prompt$ default handler — bridged through the cooperative + // pump's host-message protocol. The iframe itself owns the + // prompt UI (window.prompt in the iframe's window), so this + // hostHandler runs only when the parent receives a host-message + // event the iframe DIDN'T consume (e.g., a future plugin + // channel registered on the parent). this.hostHandlers['fade-web/prompt'] = async (payload) => { let answer = ''; try { @@ -762,58 +754,43 @@ class FadeRunner { }; this.ready = new Promise((resolve, reject) => { - // Resolve `ready` only after BOTH workers report ready. Each - // hosts its own .NET runtime, so booting in parallel halves - // the wall-clock startup vs. sequential. - let lspReady = false, vmReady = false; - const dispatch = (e: MessageEvent) => { + // Resolve `ready` as soon as the LSP worker boots. The VM + // iframe boots lazily (on first ensureWebPreviewArmed) and + // gates its own ready via `preview-armed`; the runner's + // ready promise just means LSP is alive for the editor. + this.lspWorker.onmessage = (e) => { const msg = e.data; - if (msg.type === 'ready') { - if (msg.role === 'vm') vmReady = true; else lspReady = true; - if (lspReady && vmReady) resolve(); - return; - } + if (msg.type === 'ready') { resolve(); return; } this.handleWorkerMessage(msg, reject); }; - this.lspWorker.onmessage = dispatch; - this.vmWorker.onmessage = dispatch; - const handleErr = (label: string) => (e: ErrorEvent) => - reject(new Error(`${label} worker error: ${e.message}`)); - this.lspWorker.onerror = handleErr('lsp'); - this.vmWorker.onerror = handleErr('vm'); + this.lspWorker.onerror = (e: ErrorEvent) => + reject(new Error('lsp worker error: ' + e.message)); }); } - // Post to the current VM target. When the VM lives in a worker - // (vmWorker, pre-iframe), this is just worker.postMessage. When the - // VM lives in an iframe (attachVmIframe has been called), this goes - // to the iframe's window — the iframe's index.html relays it to its - // own worker. Either way, replies come back via the same dispatch - // pipe (handleWorkerMessage) so callers don't care. - private postVm(msg: any, transfer: Transferable[] = []): void { - if (this.vmTarget instanceof Worker) { - // Worker.postMessage doesn't accept a 'targetOrigin' arg. - this.vmTarget.postMessage(msg, transfer); - } else { - // Window.postMessage(msg, targetOrigin, transfer). - this.vmTarget.postMessage(msg, '*', transfer); - } + // Post a VM-side message to the active iframe. No-op when no iframe + // is attached (lifecycle setup happens lspWorker-side; VM ops never + // fire before ensureWebPreviewArmed). Returns true if the message + // was posted, false if it was dropped — the callers that need to + // await a reply check this before registering a pending entry. + private postVm(msg: any, transfer: Transferable[] = []): boolean { + if (!this.vmTarget) return false; + this.vmTarget.postMessage(msg, '*', transfer); + return true; } - // Switch VM-side traffic to flow through the given iframe instead of - // the vmWorker. The iframe must already be loaded and have posted - // 'preview-ready'; the caller bootstraps it (with command DLLs) and - // waits for 'preview-armed' before relying on it for run/debug/tests. - // After this point the vmWorker still exists but receives no traffic; - // we keep it alive to avoid a tear-down race during project type - // switches. + // Switch VM-side traffic to flow through the given iframe. The + // iframe must already be loaded and have posted 'preview-armed'; + // the caller is responsible for the bootstrap handshake. After + // this, postVm targets iframe.contentWindow; future VM-side runs / + // tests / debug all flow through the visible template iframe. attachVmIframe(iframe: HTMLIFrameElement): void { this.vmIframe = iframe; - if (iframe.contentWindow) this.vmTarget = iframe.contentWindow; + this.vmTarget = iframe.contentWindow; // Listen for messages from the iframe's window so the dispatcher - // sees them like worker messages. We filter to messages whose - // source is exactly the iframe's contentWindow to avoid mixing - // up postMessages from other windows on the page. + // sees them like LSP-worker messages. We filter to messages + // whose source is exactly the iframe's contentWindow to avoid + // mixing up postMessages from other windows on the page. window.addEventListener('message', (e) => { if (!this.vmIframe) return; if (e.source !== this.vmIframe.contentWindow) return; @@ -822,15 +799,16 @@ class FadeRunner { // 'preview-ready' / 'preview-armed' are iframe lifecycle // signals consumed by the bootstrap code, not VM events. if (msg.type === 'preview-ready' || msg.type === 'preview-armed') return; - this.handleWorkerMessage(msg, () => { /* iframe errors are surfaced separately */ }); + this.handleWorkerMessage(msg, () => { /* iframe errors surfaced via UI separately */ }); }); } - // Switch VM-side traffic back to the worker (used when leaving a - // web project for a non-iframe-driven mode, e.g. monogame). + // Detach the iframe. After this, postVm is a no-op until another + // attachVmIframe call. Used when leaving a web project for a non- + // iframe-driven mode (e.g. monogame today, until phase 2 unifies). detachVmIframe(): void { this.vmIframe = null; - this.vmTarget = this.vmWorker; + this.vmTarget = null; } // Dispatches a single message from either worker. The `ready` event @@ -865,6 +843,7 @@ class FadeRunner { if (msg.type === 'lsp-rename-result') { this.resolvePending(msg.id, msg.edit); return; } if (msg.type === 'set-project-type-result') { this.resolvePending(msg.id, msg.projectType); return; } if (msg.type === 'register-command-assembly-result') { this.resolvePending(msg.id, msg.result); return; } + if (msg.type === 'load-assembly-result') { this.resolvePending(msg.id, msg.result); return; } if (msg.type === 'clear-command-assemblies-result') { this.resolvePending(msg.id, undefined); return; } if (msg.type === 'list-tests-result') { this.resolvePending(msg.id, msg.tests); return; } if (msg.type === 'list-command-docs-result') { this.resolvePending(msg.id, msg.docs); return; } @@ -975,54 +954,86 @@ class FadeRunner { // directly, so the vm-worker call is a no-op for monogame but // harmless and keeps the two workers in sync). async setProjectType(projectType: string): Promise { - // Two separate sends: one to the LSP worker, one to the VM target - // (worker today, iframe after attachVmIframe). They share an - // ID slot pattern because both pendings are independent. - const postLsp = new Promise((resolve) => { + // LSP worker always gets the update. The VM iframe gets it only + // if attached — pre-attach, the iframe receives the project's + // command DLL set via the bootstrap message instead, and the + // type isn't surfaced separately to the VM runtime. + const awaits: Promise[] = []; + awaits.push(new Promise((resolve) => { const id = ++this.nextId; this.pending.set(id, () => resolve()); this.lspWorker.postMessage({ type: 'set-project-type', id, projectType }); + })); + if (this.vmTarget) { + awaits.push(new Promise((resolve) => { + const id = ++this.nextId; + this.pending.set(id, () => resolve()); + this.postVm({ type: 'set-project-type', id, projectType }); + })); + } + await Promise.all(awaits); + } + + // Load a sibling assembly into the LSP runtime (only) — used to + // pre-register dependencies of a command DLL so that, when the + // actual command-source class is Activator.CreateInstance'd, the + // AppDomain can resolve its referenced types. Mirrors the + // load-assembly op the static-host bootstrap uses to pre-load dep + // DLLs before the entry assembly. Not posted to the VM iframe — the + // iframe's runtime owns its own static references. + async loadAssembly(dllBytes: ArrayBuffer): Promise<{ ok: boolean; error?: string }> { + const id = ++this.nextId; + const postLsp = new Promise((resolve) => { + this.pending.set(id, (result: string) => resolve(result)); + this.lspWorker.postMessage({ type: 'load-assembly', id, dllBytes }); }); - const postVmTarget = new Promise((resolve) => { - const id = ++this.nextId; - this.pending.set(id, () => resolve()); - this.postVm({ type: 'set-project-type', id, projectType }); - }); - await Promise.all([postLsp, postVmTarget]); + const result = await postLsp; + try { return JSON.parse(result); } catch { return { ok: false, error: 'parse failed' }; } } - // Load a command DLL from bytes into both the LSP runtime and the - // VM target (worker today, iframe after attachVmIframe). dllBytes - // is the raw assembly content fetched from /runtime/fade-libs/.dll - // (or from OPFS for user-uploaded plugins). + // Load a command DLL into both the LSP runtime and (if attached) + // the VM iframe. dllBytes is the raw assembly content fetched from + // /runtime/fade-libs/.dll (or from OPFS for user-uploaded + // plugins). Pre-iframe, the VM side will pick up the DLL via the + // bootstrap commandDlls list. async registerCommandAssembly(dllBytes: ArrayBuffer, className: string): Promise<{ ok: boolean; error?: string }> { const postLsp = new Promise((resolve) => { const id = ++this.nextId; this.pending.set(id, (result: string) => resolve(result)); this.lspWorker.postMessage({ type: 'register-command-assembly', id, dllBytes, className }); }); - const postVmTarget = new Promise((resolve) => { - const id = ++this.nextId; - this.pending.set(id, (result: string) => resolve(result)); - this.postVm({ type: 'register-command-assembly', id, dllBytes, className }); - }); - const [, vmResult] = await Promise.all([postLsp, postVmTarget]); - try { return JSON.parse(vmResult); } catch { return { ok: false, error: 'parse failed' }; } + const awaits: Promise[] = [postLsp]; + if (this.vmTarget) { + awaits.push(new Promise((resolve) => { + const id = ++this.nextId; + this.pending.set(id, (result: string) => resolve(result)); + this.postVm({ type: 'register-command-assembly', id, dllBytes, className }); + })); + } + const results = await Promise.all(awaits); + // Prefer the VM-side result if available (matches the previous + // behavior of returning the VM target's parse). Fall back to + // the LSP-side result when no iframe is attached. + const primary = results[results.length - 1]; + try { return JSON.parse(primary); } catch { return { ok: false, error: 'parse failed' }; } } // Drop all dynamically-loaded command sources from both runtimes. async clearCommandAssemblies(): Promise { - const postLsp = new Promise((resolve) => { + const awaits: Promise[] = []; + awaits.push(new Promise((resolve) => { const id = ++this.nextId; this.pending.set(id, () => resolve()); this.lspWorker.postMessage({ type: 'clear-command-assemblies', id }); - }); - const postVmTarget = new Promise((resolve) => { - const id = ++this.nextId; - this.pending.set(id, () => resolve()); - this.postVm({ type: 'clear-command-assemblies', id }); - }); - await Promise.all([postLsp, postVmTarget]); + })); + if (this.vmTarget) { + awaits.push(new Promise((resolve) => { + const id = ++this.nextId; + this.pending.set(id, () => resolve()); + this.postVm({ type: 'clear-command-assemblies', id }); + })); + } + await Promise.all(awaits); } async getTokens(uri: string): Promise { @@ -1580,7 +1591,10 @@ interface CompletionItem { // ─── bootstrap ────────────────────────────────────────────────────────────── async function bootstrap() { + const pgSplash = (window as any).__pgSplash as + { setStatus(t: string, e?: boolean): void; hide(): void } | undefined; statusEl.textContent = 'Initializing services…'; + pgSplash?.setStatus('Initializing editor…'); await initServices({ ...getModelServiceOverride(), ...getEditorServiceOverride(async () => undefined), @@ -1639,36 +1653,30 @@ async function bootstrap() { monaco.editor.setTheme('fade-dark'); statusEl.textContent = 'Booting Fade runtime worker…'; - - // Heartbeat indicators — displayed in the Diagnostics panel. - // Each worker (lsp + vm) posts a beat every 500ms; the dot pulses - // while alive and turns red when we haven't heard from it in >1.2s. + pgSplash?.setStatus('Loading language server…'); + + // Heartbeat indicator — displayed in the Diagnostics panel. + // The LSP worker posts a beat every 500ms; the dot pulses while alive + // and turns red when we haven't heard from it in >1.2s. + // Note: there is no separate VM worker beat. The web-template VM runs + // inside a same-origin iframe (runtime.js sends 'vm' heartbeats that + // flow through attachVmIframe → handleWorkerMessage), but the MonoGame + // runtime uses Blazor and sends no heartbeats. The VM row was removed + // from the Diagnostics panel to avoid a permanently-stalled indicator. type BeatState = { lastAt: number; tick: number }; - const beats: { lsp: BeatState; vm: BeatState } = { - lsp: { lastAt: Date.now(), tick: 0 }, - vm: { lastAt: Date.now(), tick: 0 }, - }; + const lspBeat: BeatState = { lastAt: Date.now(), tick: 0 }; const dotLsp = document.getElementById('diag-dot-lsp') as HTMLElement; - const dotVm = document.getElementById('diag-dot-vm') as HTMLElement; const detailLsp = document.getElementById('diag-lsp-detail') as HTMLElement; - const detailVm = document.getElementById('diag-vm-detail') as HTMLElement; function paintHeartbeat() { - for (const [role, dot, detail] of [ - ['lsp', dotLsp, detailLsp], - ['vm', dotVm, detailVm], - ] as const) { - const b = beats[role]; - const dt = Date.now() - b.lastAt; - const busy = dt > 1200; - dot.dataset.state = busy ? 'busy' : (b.tick % 2 === 0 ? 'on' : 'off'); - if (busy) { - const hint = role === 'vm' ? ' (Thread.Sleep / wait ms?)' : ''; - dot.title = `${role} worker busy — last beat ${(dt / 1000).toFixed(1)}s ago${hint}`; - detail.textContent = `stalled ${(dt / 1000).toFixed(1)}s ago${hint}`; - } else { - dot.title = `${role} worker alive — beat ${b.tick}`; - detail.textContent = `alive — beat #${b.tick}`; - } + const dt = Date.now() - lspBeat.lastAt; + const busy = dt > 1200; + dotLsp.dataset.state = busy ? 'busy' : (lspBeat.tick % 2 === 0 ? 'on' : 'off'); + if (busy) { + dotLsp.title = `LSP worker busy — last beat ${(dt / 1000).toFixed(1)}s ago`; + detailLsp.textContent = `stalled ${(dt / 1000).toFixed(1)}s ago`; + } else { + dotLsp.title = `LSP worker alive — beat ${lspBeat.tick}`; + detailLsp.textContent = `alive — beat #${lspBeat.tick}`; } } setInterval(paintHeartbeat, 250); @@ -1677,12 +1685,14 @@ async function bootstrap() { onPrint: (line) => appendOutputLine(line), onAlert: (msg) => window.alert(msg), onHeartbeat: (role, tick, t) => { - beats[role].tick = tick; - beats[role].lastAt = t; + if (role !== 'lsp') return; + lspBeat.tick = tick; + lspBeat.lastAt = t; paintHeartbeat(); }, }); await runner.ready; + pgSplash?.setStatus('Configuring language features…'); // Single worker handles both run and LSP duties. const lsp = runner; @@ -2053,6 +2063,21 @@ async function bootstrap() { // Per-URI diagnostics cache for the Problems panel. const diagnosticsByUri = new Map(); + // Tracks whether the last diagnostics round disabled Run/Debug, so the + // diagnostics handler can re-trigger test discovery on the + // has-errors → clean transition (otherwise the test panel would stay + // stale until the user typed another keystroke). + let lastBlockedByErrors = false; + // Activity flags read by refreshRunButtons + refreshStopButton. + // Declared up here (instead of next to the run/debug bindings further + // down) so refreshFadeProject — awaited mid-bootstrap before the rest + // of the run/debug UI is wired — can safely call refreshRunButtons. + // Without this, the early call hits TDZ on the let-bindings. + let debugSessionActive = false; + let debugPaused = false; + let runActive = false; + let testsBusy = false; + let exportBusy = false; lsp.setDiagnosticsHandler((uri, diagnostics) => { // Find ALL models with this URI — codingame may create duplicate @@ -2086,6 +2111,12 @@ async function bootstrap() { diagnosticsByUri.set(uri, diagnostics); renderProblems(); + // Compile-error gate for Run / Debug / Export. Also re-trigger + // test discovery when errors clear so the panel un-stalls without + // the user having to type another keystroke. + const wasBlocked = lastBlockedByErrors; + refreshRunButtons(); + if (wasBlocked && !lastBlockedByErrors) refreshDebounce(); }); // ─── Bottom panel (vscode-tabs handles tab switching internally) ─────── @@ -2335,23 +2366,17 @@ async function bootstrap() { lastWorkerProjectType = wantedType; try { await runner.setProjectType(wantedType); - // Re-push every open fbasic model so tokens + diagnostics - // recompute against the new command set. The next edit - // would do this anyway, but we'd rather not leave stale - // highlights/squiggles sitting until then. - // - // FILTER by language: fade.json + other non-fade files must - // NOT go through the Fade LSP — the parser would treat the - // JSON's `$schema` as a substitution and flag [0158] errors. - // Also evict any stale owner='fade' markers from non-fade - // models that an earlier (buggy) push left behind, so the - // squiggles disappear on the next paint instead of waiting - // for the user to re-edit. Self-healing if a future code - // path mis-pushes a non-fade model again. + // Evict any stale owner='fade' markers from non-fade models + // (e.g. fade.json) so squiggles left by an earlier mis-push + // don't linger. Fade models are NOT re-pushed here — the + // wantedDllsKey branch below always fires on a type change + // (because the type is part of the key) and does the + // authoritative push after command DLLs are registered. + // Pushing fade models here, before DLLs load, causes a + // transient flash of "unknown command" errors that clears + // once the DLLs arrive. for (const model of monaco.editor.getModels()) { - if (model.getLanguageId() === 'fade') { - runner.setDocument(model.uri.toString(), model.getValue()); - } else { + if (model.getLanguageId() !== 'fade') { monaco.editor.setModelMarkers(model, 'fade', []); diagnosticsByUri.delete(model.uri.toString()); } @@ -2376,9 +2401,42 @@ async function bootstrap() { lastCommandDllsKey = wantedDllsKey; try { await runner.clearCommandAssemblies(); + // Per-type LSP setup. `preloadAssemblies` are loaded into + // the LSP's AppDomain *without* being registered as command + // sources — they exist to satisfy referenced-assembly + // resolution when the actual command class is + // Activator.CreateInstance'd. typeDefaults are the command + // classes themselves. + // + // Monogame: Fade.MonoGame.Lib has ProjectReferences to + // Fade.MonoGame.Game + Fade.MonoGame.Contracts. Pre-loading + // them is cheap insurance against AppDomain resolution + // hiccups during type init. MonoGame.Framework + KNI are + // intentionally NOT preloaded — they're huge and the LSP + // only enumerates command metadata (method bodies aren't + // JITed until called, which never happens in the LSP). const typeDefaults: CommandDllEntry[] = wantedType === 'web' ? [{ assembly: 'FadeBasic.Lib.Web', class: 'FadeBasic.Lib.Web.WebCommands' }] + : wantedType === 'monogame' + ? [{ assembly: 'Fade.MonoGame.Lib', class: 'Fade.MonoGame.Lib.FadeMonoGameCommands' }] : []; + const preloadAssemblies: string[] = wantedType === 'monogame' + ? ['Fade.MonoGame.Contracts', 'Fade.MonoGame.Game'] + : []; + for (const name of preloadAssemblies) { + try { + const resp = await fetch(`/runtime/fade-libs/${name}.dll`); + if (!resp.ok) { + console.warn(`[fade] preload DLL not found: /runtime/fade-libs/${name}.dll (${resp.status})`); + continue; + } + const bytes = await resp.arrayBuffer(); + const result = await runner.loadAssembly(bytes); + if (!result.ok) console.warn(`[fade] preload ${name} failed: ${result.error}`); + } catch (e) { + console.warn(`[fade] failed to preload ${name}`, e); + } + } const allEntries = [...typeDefaults, ...(currentProject?.commandDlls ?? [])]; for (const entry of allEntries) { try { @@ -2454,6 +2512,9 @@ async function bootstrap() { // it from re-binding listeners. protectFadeJsonSchemaLine(); renderProblems(); + // fade.json schema errors count toward projectHasCompileErrors so + // Run/Debug stay locked on a broken manifest too. + refreshRunButtons(); // Republish for module-scope renderers (file list badges) and // re-render so the source-order indicators update immediately. currentProjectRef = currentProject; @@ -2784,6 +2845,16 @@ async function bootstrap() { renderTests(); return; } + // Skip discovery when the project has compile errors. For monogame + // this prevents the Blazor iframe from throwing on unparseable source + // (and force-booting the 8MB WASM before the user hits Run). For web + // projects it avoids spamming the console with mid-keystroke compile + // errors from the worker's listTests call. The existing test list stays + // visible until errors clear; the error-clear path in the diagnostics + // handler re-triggers the debounce so discovery resumes automatically. + if (projectHasCompileErrors()) { + return; + } // monogame compiles against FadeMonoGameCommands+StandardCommands; // the worker's command surface may or may not match, depending on // whether the LSP has swapped command sets. Route through the @@ -2796,6 +2867,22 @@ async function bootstrap() { renderTests(); } + // Any error-severity diagnostic in any current-project source file (or + // a schema error in fade.json) gates the Run / Debug / Export buttons + // and the monogame background test-discovery round-trip. Source-file + // diagnostics come from the LSP push poll into diagnosticsByUri; + // fade.json schema errors live in currentProjectErrors. + function projectHasCompileErrors(): boolean { + if (currentProjectErrors.some((e) => e.severity === 'error')) return true; + if (!currentProject) return false; + for (const name of currentProject.sources) { + const uri = monaco.Uri.file(`/workspace/${name}`).toString(); + const diags = diagnosticsByUri.get(uri); + if (diags?.some((d) => d.severity === 1)) return true; + } + return false; + } + function renderTests() { testsListEl.innerHTML = ''; const runnable = testEntries.filter((t) => !t.isAbstract).length; @@ -3164,7 +3251,12 @@ async function bootstrap() { let timer: number | undefined; return () => { if (timer != null) clearTimeout(timer); - timer = window.setTimeout(refreshTests, 400); + // 1500ms: long enough that LSP diagnostics (which gate the + // compile-error check inside refreshTests) have time to arrive + // from the worker before we attempt a listTests compile. The + // 250ms doc-push poll + 400ms was too short — listTests fired + // mid-keystroke before the LSP had a chance to report errors. + timer = window.setTimeout(refreshTests, 1500); }; })(); @@ -3208,6 +3300,7 @@ async function bootstrap() { }; statusEl.textContent = 'Loading workspace…'; + pgSplash?.setStatus('Loading workspace…'); const workspace = new OpfsWorkspace(); await workspace.init(); @@ -3559,6 +3652,7 @@ async function bootstrap() { // in the DOM by the time create() runs (Monaco's automaticLayout // measures the container at construction). statusEl.textContent = 'Mounting layout…'; + pgSplash?.setStatus('Mounting layout…'); const dockApi = setupDockview(); // Expose for tests + future "Reset layout" command. (window as any).__fadeDockview = dockApi; @@ -3605,18 +3699,18 @@ async function bootstrap() { if (wDot) wDot.textContent = info.dotnet; }).catch(() => { /* diagnostics are best-effort */ }); - // MonoGame runtime versions — polled until theInstance is available. - // Blazor boots lazily (Game panel must open first), so we wait up to - // 5 minutes without forcing a boot ourselves. + // MonoGame runtime versions — polled until the iframe is ready. The + // iframe boots lazily on first run, so we keep polling without + // forcing a boot ourselves. { let mgVersionFetched = false; const mgPollHandle = setInterval(async () => { - if (mgVersionFetched || !window.theInstance?.invokeMethodAsync) return; + if (mgVersionFetched || !monoGameHost.isReady()) return; mgVersionFetched = true; clearInterval(mgPollHandle); try { - const json = await window.theInstance.invokeMethodAsync('GetVersionInfo') as string; - const info = JSON.parse(json) as { fadeBasic: string; kni: string; dotnet: string }; + const info = await monoGameHost.getVersionInfo(); + if (!info) return; const el = (id: string) => document.getElementById(id); const short = (v: string) => v.split('+')[0]; const mgFade = el('diag-mg-fade'); @@ -3992,6 +4086,7 @@ async function bootstrap() { projectNewInput.addEventListener('input', clearProjectError); statusEl.textContent = 'Mounting editor…'; + pgSplash?.setStatus('Mounting editor…'); editor = monaco.editor.create(editorContainer, { value: '', language: 'fade', @@ -4008,6 +4103,83 @@ async function bootstrap() { fixedOverflowWidgets: true, } as monaco.editor.IStandaloneEditorConstructionOptions); + // ─── Game panel toolbar ────────────────────────────────────────────── + // The in-panel toolbar (status dot + text, mute, fullscreen) drives all + // game-state communication to the user. Status lives here rather than in + // the dockview tab title so it survives tab switches and has room for + // the control buttons alongside it. + + type GameStatus = 'idle' | 'booting' | 'running' | 'paused' | 'stopped'; + const mgStatusDot = document.getElementById('mg-status-dot') as HTMLElement; + const mgGameStatus = document.getElementById('mg-game-status') as HTMLElement; + const STATUS_LABELS: Record = { + idle: 'Not started', + booting: 'Booting…', + running: 'Running', + paused: 'Paused', + stopped: 'Stopped', + }; + function updateGameStatus(state: GameStatus) { + if (mgStatusDot) mgStatusDot.dataset.state = (state === 'idle' || state === 'stopped') ? '' : state; + if (mgGameStatus) mgGameStatus.textContent = STATUS_LABELS[state]; + } + + // Mute + let mgMuted = false; + const mgMuteBtn = document.getElementById('mg-mute-btn') as HTMLButtonElement | null; + const mgMuteIcon = document.getElementById('mg-mute-icon') as HTMLElement | null; + mgMuteBtn?.addEventListener('click', () => { + mgMuted = !mgMuted; + monoGameHost.setMuted(mgMuted); + mgMuteIcon?.classList.toggle('codicon-mute', mgMuted); + mgMuteIcon?.classList.toggle('codicon-unmute', !mgMuted); + if (mgMuteBtn) { + mgMuteBtn.classList.toggle('is-active', mgMuted); + mgMuteBtn.title = mgMuted ? 'Unmute' : 'Mute / Unmute'; + } + }); + + // Fullscreen + const mgFullscreenBtn = document.getElementById('mg-fullscreen-btn') as HTMLButtonElement | null; + const mgFullscreenIcon = document.getElementById('mg-fullscreen-icon') as HTMLElement | null; + mgFullscreenBtn?.addEventListener('click', () => { + const container = document.getElementById('mg-blazor-root'); + if (!container) return; + if (!document.fullscreenElement) { + container.requestFullscreen?.().catch(() => {/* denied */}); + } else { + document.exitFullscreen?.(); + } + }); + document.addEventListener('fullscreenchange', () => { + const inFs = !!document.fullscreenElement; + mgFullscreenIcon?.classList.toggle('codicon-screen-full', !inFs); + mgFullscreenIcon?.classList.toggle('codicon-screen-normal', inFs); + if (mgFullscreenBtn) mgFullscreenBtn.title = inFs ? 'Exit fullscreen' : 'Toggle fullscreen'; + }); + + // Pause the MonoGame rAF loop while the editor has focus so MonoGame's + // TickDotNet() stops competing with Monaco's own rAF work (cursor blink, + // decorations, hover). The in-panel status label shows "Paused". Resume + // fires on blur (when focus moves back to the game canvas or anywhere else). + let mgTickPaused = false; + function pauseMgTick() { + if (currentProject?.type !== 'monogame') return; + if (!runActive && !debugSessionActive) return; + if (mgTickPaused) return; + mgTickPaused = true; + monoGameHost.pauseTick(); + updateGameStatus('paused'); + } + function resumeMgTick() { + if (!mgTickPaused) return; + mgTickPaused = false; + monoGameHost.resumeTick(); + updateGameStatus('running'); + } + editor.onDidFocusEditorWidget(pauseMgTick); + editor.onDidBlurEditorWidget(resumeMgTick); + // (Earlier attempt to reparent the context-menu container lived here // — turned out vscode-vscode-api creates the menu inside a shadow root // attached to the editor, so a MutationObserver on document.body @@ -4057,6 +4229,13 @@ async function bootstrap() { monaco.editor.createModel(text, languageFor(name), uri); } } + // Stamp every model as "already seen" so the polling interval's first + // tick doesn't push documents to the LSP before command DLLs are + // registered. The DLL-registration branch inside refreshFadeProject is + // always the authoritative first push (it runs after DLLs are loaded). + for (const m of monaco.editor.getModels()) { + lastPushedByUri.set(m.uri.toString(), m.getValue()); + } // Now that the fade.json model exists, resolve currentProject. We // *must* await this before picking the default-opened file — otherwise @@ -4082,9 +4261,12 @@ async function bootstrap() { } statusEl.textContent = 'Ready.'; - runBtn.disabled = false; - debugBtn.disabled = false; - exportBtn.disabled = false; + pgSplash?.hide(); + monoGameHost.notifyPgSplashHidden(); + // Enable Run / Debug / Export through refreshRunButtons so a project + // with diagnostics already in (e.g. fade.json errors loaded at boot) + // boots with the buttons in the correct disabled state. + refreshRunButtons(); // Walk the active project's OPFS folder for `.xnb` files and push their // bytes into the MonoGame runtime's BrowserContentManager. Called before @@ -4096,11 +4278,9 @@ async function bootstrap() { // `texture 1, "Catfish"`. Pre-clears the runtime dict so deletions in // OPFS take effect on the next Run. // - // SoundEffect XNBs get a loopLength patch on the way through — see - // patchSoundEffectForKni for the KNI Blazor bug it works around. Effect - // XNBs from modern MGCB (MGFX v11) get the version byte downgraded to v10 - // so KNI 4.2.9001's Effect ctor doesn't reject them; see - // patchEffectMgfxVersionForKni. + // Each XNB is routed through patchXnbForKni — SoundEffects get a loopLength + // fix for a KNI Blazor bug; Effects get their MGFX version downgraded from + // v11 to v10 so KNI 4.2.9001 accepts them. See xnb-previews.ts for details. async function syncAssetsToRuntime(): Promise { await monoGameHost.clearAssets(); const names = await workspace.list(); @@ -4108,7 +4288,7 @@ async function bootstrap() { if (!/\.xnb$/i.test(name)) continue; try { const raw = await workspace.readBytes(name); - const bytes = patchEffectMgfxVersionForKni(patchSoundEffectForKni(raw)); + const bytes = patchXnbForKni(raw); const assetName = name.replace(/\.xnb$/i, ''); await monoGameHost.registerAsset(assetName, bytes); } catch (e) { @@ -4125,16 +4305,27 @@ async function bootstrap() { // resolve identically. const collectCommandDllEntries = (): CommandDllEntry[] => { const type = currentProject?.type ?? 'web'; + // Type-defaults must mirror the LSP-sync site above so that + // command IDs resolve consistently between the LSP's compile + // pass and any iframe that also re-compiles from source. + // Monogame's iframe bakes FadeMonoGameCommands in statically + // (and ignores its bootstrap commandDlls list), so this entry + // is here purely for symmetry + so user-uploaded plugins layer + // on top of the same base. const defaults: CommandDllEntry[] = type === 'web' ? [{ assembly: 'FadeBasic.Lib.Web', class: 'FadeBasic.Lib.Web.WebCommands' }] + : type === 'monogame' + ? [{ assembly: 'Fade.MonoGame.Lib', class: 'Fade.MonoGame.Lib.FadeMonoGameCommands' }] : []; return [...defaults, ...(currentProject?.commandDlls ?? [])]; }; - // Swap the Game panel sub-surface. The panel hosts both an iframe - // (web preview) and a Blazor canvas (monogame); only one is visible - // at a time. Splash element inside #mg-blazor-root is shown when - // monogame surface is active and the canvas hasn't booted. + // Swap the Game panel sub-surface. The panel hosts two iframes — + // one for the web template, one for the monogame template — and + // only one is visible at a time. Splash element inside + // #mg-blazor-root is shown when monogame surface is active and the + // iframe hasn't booted (monoGameHost lazily creates the iframe on + // first ensureBooted). const showGameSurface = (which: 'web' | 'monogame' | 'splash'): void => { const webHost = document.getElementById('web-preview-host'); const mgRoot = document.getElementById('mg-blazor-root'); @@ -4144,7 +4335,7 @@ async function bootstrap() { mgRoot.style.display = 'none'; } else { webHost.style.display = 'none'; - mgRoot.style.display = 'block'; + mgRoot.style.display = 'flex'; // preserve CSS flex-direction: column } }; @@ -4176,7 +4367,7 @@ async function bootstrap() { }; window.addEventListener('message', onReady); }); - frame.src = '/runtime/index.html?preview=1'; + frame.src = '/runtime/web/index.html?preview=1'; await readyPromise; // Phase 2: bootstrap with the project's command DLLs. The @@ -4227,7 +4418,18 @@ async function bootstrap() { appendOutputLine('No file open.', 'dim'); return; } - runBtn.disabled = true; + // Defensive: refreshRunButtons disables runBtn while errors exist, + // but a stale click that races the keyboard shortcut could still + // arrive here. Bail loudly rather than calling LoadProgram with a + // broken source (which surfaces as Blazor's error overlay). + if (projectHasCompileErrors()) { + clearOutput(); + appendOutputLine('Fix compile errors before running. See Problems panel.', 'error'); + revealPanel('problems'); + return; + } + runActive = true; + refreshRunButtons(); clearOutput(); // ─── 'monogame' branch ────────────────────────────────────────── @@ -4239,22 +4441,32 @@ async function bootstrap() { try { revealPanel('game'); appendOutputLine('Booting MonoGame runtime…', 'dim'); + updateGameStatus('booting'); await syncAssetsToRuntime(); const ok = await monoGameHost.loadProgram(source); if (ok) { - appendOutputLine('Running on canvas.', 'info'); - // Game is alive until stopAll() is called; runActive - // stays true so Stop remains enabled. - runActive = true; + // No "Running…" message — user `print` output now + // streams into the Output panel directly. The first + // few lines (or the game canvas itself) tell them + // the program is alive. Game stays running until + // stopAll(); runActive remains true so Reset is + // available. + updateGameStatus('running'); refreshStopButton(); } else { appendOutputLine('Compile failed. See Problems panel.', 'error'); revealPanel('problems'); + runActive = false; + updateGameStatus('stopped'); + refreshRunButtons(); + refreshStopButton(); } } catch (e: any) { appendOutputLine('MonoGame runtime error: ' + (e?.message ?? String(e)), 'error'); - } finally { - runBtn.disabled = false; + runActive = false; + updateGameStatus('stopped'); + refreshRunButtons(); + refreshStopButton(); } return; } @@ -4271,10 +4483,7 @@ async function bootstrap() { showGameSurface('web'); revealPanel('game'); await ensureWebPreviewArmed(); - // Enable Stop while the run is in flight — the cooperative - // pump might be waiting on a prompt or sleeping in a wait ms; - // the user needs a way out either way. - runActive = true; + // Stop is already enabled by runActive (set at runOnce entry). refreshStopButton(); const result = await runner.run(source); let env: { ok?: boolean; error?: string | null; compileError?: string | null } | null = null; @@ -4290,14 +4499,24 @@ async function bootstrap() { } catch (e: any) { appendOutputLine(e?.message ?? String(e), 'error'); } finally { - runBtn.disabled = false; runActive = false; + refreshRunButtons(); refreshStopButton(); } }; - runBtn.addEventListener('click', runOnce); - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyR, runOnce); + // Click handler that respects the Run / Reset duality. When a run + // is in flight (and no debug session blocks us), tear it down + // before starting a fresh one — single click instead of Stop-then- + // Run. ⌘R does the same. + const runOrReset = async () => { + if (runActive && !debugSessionActive) { + try { await stopAll(); } catch { /* best effort */ } + } + await runOnce(); + }; + runBtn.addEventListener('click', runOrReset); + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyR, runOrReset); // Export: build a static zip containing the runtime + the user's // compiled bytecode + the project's command DLLs + a synthesized @@ -4307,7 +4526,7 @@ async function bootstrap() { // Inputs: // - runner.compileToBytecode(source) → game/program.fbytecode // - collectCommandDllEntries() → fetch each → game/.dll - // - /runtime/runtime-manifest.json → list of static files + // - /runtime/web/runtime-manifest.json → list of static files // - synthesized fade-manifest.json → tells index.html // to take the bytecode // branch instead of @@ -4315,13 +4534,107 @@ async function bootstrap() { const exportOnce = async (): Promise => { const source = await getProjectSource(); if (!source) { appendOutputLine('No file open.', 'dim'); return; } - // For monogame, the export pipeline is different; not implemented - // here. Surface the limitation rather than producing a broken zip. + if (projectHasCompileErrors()) { + appendOutputLine('Export aborted — fix compile errors first.', 'error'); + revealPanel('problems'); + return; + } + // ─── 'monogame' branch ────────────────────────────────────────── + // Bundles the WebRuntime.MonoGame WASM runtime + the user's + // .fbasic source + every .xnb asset into a static-host zip. + // The exported index.html (standalone, no ?preview) self-boots + // and calls LoadProgram(source) — same JSInvokable the Playground + // already drives via postMessage. + // + // Source-bundling (vs bytecode) lets the runtime's existing + // LoadProgram(string) path do the work. No C# changes; the same + // export shape works for both the Playground download here and + // a future dotnet-publish MSBuild target. if (currentProject?.type === 'monogame') { - appendOutputLine('Export for monogame projects is not implemented yet.', 'error'); + exportBusy = true; + refreshRunButtons(); + exportBtn.classList.add('is-exporting'); + appendOutputLine('Building monogame export…', 'dim'); + try { + const manifest = await fetch('/runtime/monogame/runtime-manifest.json') + .then((r) => { + if (!r.ok) throw new Error('runtime-manifest.json missing — rebuild monogame runtime'); + return r.json() as Promise<{ files: string[] }>; + }); + + const runtimeFiles = manifest.files.filter((rel) => rel !== 'runtime-manifest.json'); + const assetFetches = runtimeFiles.map(async (rel) => { + try { + const buf = await fetch(`/runtime/monogame/${rel}`).then((r) => r.arrayBuffer()); + return [rel, new Uint8Array(buf)] as const; + } catch (err: any) { + appendOutputLine(`[warn] runtime asset missing: ${rel}`, 'dim'); + return null; + } + }); + const assetResults = await Promise.all(assetFetches); + + // Pull the user's XNB assets from OPFS, applying the same + // KNI patches we apply at runtime so the deployed bundle + // doesn't re-patch on every boot. + const wsNames = await workspace.list(); + const xnbNames = wsNames.filter((n) => /\.xnb$/i.test(n)); + const xnbEntries: { name: string; bytes: Uint8Array }[] = []; + for (const name of xnbNames) { + try { + const raw = await workspace.readBytes(name); + const bytes = patchXnbForKni(raw); + xnbEntries.push({ name: name.replace(/\.xnb$/i, ''), bytes }); + } catch (e) { + appendOutputLine(`[warn] asset read failed: ${name}`, 'dim'); + } + } + + const { zip, strToU8 } = await import('fflate'); + const files: Record = {}; + for (const r of assetResults) if (r) files[r[0]] = r[1]; + + files['game/program.fbasic'] = strToU8(source); + for (const a of xnbEntries) { + files[`game/${a.name}.xnb`] = a.bytes; + } + + const fadeManifest = { + fadeBasic: 'playground-export', + exportFormat: '1', + type: 'monogame', + source: 'program.fbasic', + assets: xnbEntries.map((a) => a.name), + }; + files['fade-manifest.json'] = strToU8(JSON.stringify(fadeManifest, null, 2)); + + const zipBytes = await new Promise((resolve, reject) => { + zip(files, { level: 6 }, (err, data) => { + if (err) reject(err); else resolve(data); + }); + }); + + const blob = new Blob([zipBytes.slice().buffer], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + const name = (currentProject?.name ?? 'fade-monogame-export') + '.zip'; + const a = document.createElement('a'); + a.href = url; a.download = name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + appendOutputLine(`Exported: ${name} (${(zipBytes.length / 1024).toFixed(0)} KB)`, 'info'); + } catch (e: any) { + appendOutputLine('Export failed: ' + (e?.message ?? String(e)), 'error'); + } finally { + exportBusy = false; + refreshRunButtons(); + exportBtn.classList.remove('is-exporting'); + } return; } - exportBtn.disabled = true; + exportBusy = true; + refreshRunButtons(); exportBtn.classList.add('is-exporting'); appendOutputLine('Building export…', 'dim'); try { @@ -4333,7 +4646,7 @@ async function bootstrap() { } // Manifest first; everything else can race against each other. - const manifest = await fetch('/runtime/runtime-manifest.json') + const manifest = await fetch('/runtime/web/runtime-manifest.json') .then((r) => r.json() as Promise<{ files: string[] }>); // Parallel I/O: kick off every fetch at once instead of an @@ -4357,7 +4670,7 @@ async function bootstrap() { const runtimeFiles = manifest.files.filter((rel) => rel !== 'runtime-manifest.json'); const assetFetches = runtimeFiles.map(async (rel) => { try { - const buf = await fetch(`/runtime/${rel}`).then((r) => r.arrayBuffer()); + const buf = await fetch(`/runtime/web/${rel}`).then((r) => r.arrayBuffer()); return [rel, new Uint8Array(buf)] as const; } catch (err: any) { appendOutputLine(`[warn] runtime asset missing: ${rel}`, 'dim'); @@ -4423,7 +4736,8 @@ async function bootstrap() { } catch (e: any) { appendOutputLine('Export failed: ' + (e?.message ?? String(e)), 'error'); } finally { - exportBtn.disabled = false; + exportBusy = false; + refreshRunButtons(); exportBtn.classList.remove('is-exporting'); } }; @@ -4568,8 +4882,6 @@ async function bootstrap() { // Breakpoints are keyed per URI → set of 1-based line numbers. const breakpointsByUri = new Map>(); - let debugSessionActive = false; - let debugPaused = false; // When the active debug session targets a specific test (started via // dbg.startTest), remember the name so the test panel can update // that row's status when the session ends. Null for plain Debug @@ -4577,15 +4889,40 @@ async function bootstrap() { // cleared in stopAll, the 'complete' handler, and the explosion // handler — anywhere the session ends. let currentDebugTestName: string | null = null; - // Two more activity flags so the header Stop button reflects ANY - // in-flight VM work — not just debug. runActive is set by runOnce - // (web/monogame); testsBusy by setTestsBusy. refreshStopButton - // collapses all three into the single stopBtn.disabled state. - let runActive = false; - let testsBusy = false; + // Activity flags (debugSessionActive, debugPaused, runActive, + // testsBusy, exportBusy) are declared near diagnosticsByUri so the + // mid-bootstrap refreshFadeProject call can safely use them. The + // refreshStopButton + refreshRunButtons below read them. function refreshStopButton() { stopBtn.disabled = !(runActive || testsBusy || debugSessionActive); } + // Single source of truth for Run / Debug / Export enablement. Gates + // on (a) compile errors in any current-project source — clicking Run + // with a broken source surfaces as Blazor's error overlay in the + // monogame iframe — and (b) any VM work already in flight. setDebug- + // Buttons() and runOnce/exportOnce/startDebug call this instead of + // toggling the buttons directly so the four signals don't fight. + function refreshRunButtons() { + const hasErr = projectHasCompileErrors(); + // Run button morphs into "Reset" while a run is in flight and + // nothing else (debug, compile errors) is blocking — one click + // tears down the live run and starts a fresh one. Stop stays + // available separately so users can still halt without + // re-running. + const isReset = runActive && !debugSessionActive && !hasErr; + if (isReset) { + runBtn.disabled = false; + runBtn.textContent = 'Reset'; + runBtn.setAttribute('icon', 'refresh'); + } else { + runBtn.disabled = hasErr || debugSessionActive || runActive; + runBtn.textContent = 'Run (⌘R)'; + runBtn.setAttribute('icon', 'play'); + } + debugBtn.disabled = hasErr || debugSessionActive || runActive; + exportBtn.disabled = exportBusy || hasErr; + lastBlockedByErrors = hasErr; + } let activeFrameId: number | null = null; // Decoration IDs the editor uses to draw breakpoint glyphs + the // "current line" highlight when paused. @@ -4602,8 +4939,11 @@ async function bootstrap() { debugStepOutBtn.disabled = !paused; debugStopBtn.disabled = !hasSession; debugReplInput.disabled = !paused; - debugBtn.disabled = hasSession; - runBtn.disabled = hasSession; + // Header Run/Debug enablement now lives in refreshRunButtons — + // it folds together hasSession, in-flight run, and compile errors + // so a broken source disables the buttons even when nothing else + // is happening. + refreshRunButtons(); // Header Stop mirrors the floating debug toolbar's Stop while // a session is active. refreshStopButton folds debug, run, and // test activity together so the three sources don't stomp on @@ -5121,6 +5461,30 @@ async function bootstrap() { monoGameHost.onDebugEvent = (event) => { void onAnyDebugEvent(event); }; runner.onDebugEvent = (event) => { void onAnyDebugEvent(event); }; + // Fatal monogame tick errors. The iframe's rAF loop has already + // halted before this fires — we log the full message to the Output + // panel (revealing it so the user sees it) and drop runActive so + // the Run button flips back from Reset → Run. + monoGameHost.onGameError = (message) => { + appendOutputLine('Runtime error: ' + message, 'error'); + revealPanel('output'); + if (runActive) { + runActive = false; + refreshRunButtons(); + refreshStopButton(); + } + // Iframe rAF is already dead (tickHalted); clear our pause state too. + mgTickPaused = false; + updateGameStatus('stopped'); + }; + + // Pipe iframe-side Console.WriteLine output into the Output panel. + // User `print` lines, runtime status messages, and asset-load + // warnings all land here so the user doesn't have to crack the + // browser dev console to see what their game is doing. + monoGameHost.onStdout = (line) => appendOutputLine(line); + monoGameHost.onStderr = (line) => appendOutputLine(line, 'error'); + async function onAnyDebugEvent(event: any) { // [DEBUG-LOGGING — remove once scope-after-step issue is resolved] // Dump every event with its parsed json so we can see (a) ordering of @@ -5358,6 +5722,12 @@ async function bootstrap() { appendOutputLine('No file open.', 'dim'); return; } + if (projectHasCompileErrors()) { + clearOutput(); + appendOutputLine('Fix compile errors before debugging. See Problems panel.', 'error'); + revealPanel('problems'); + return; + } await beginDebugSession(() => dbg.start(source)); }; @@ -5368,14 +5738,20 @@ async function bootstrap() { clearOutput(); debugReplOutput.textContent = ''; setDebugStatus('starting', 'paused'); - debugBtn.disabled = true; + // Mark a session as starting so refreshRunButtons disables Run + + // Debug. Cleared on the failure path below; on success the + // debugSessionActive flag below takes over. + runActive = true; + refreshRunButtons(); const result = await starter(); if (!result.ok) { setDebugStatus('failed to start', 'error'); appendReplLine(result.error ?? 'Failed to start', 'err'); - debugBtn.disabled = false; + runActive = false; + refreshRunButtons(); return false; } + runActive = false; debugSessionActive = true; debugPaused = true; setDebugStatus('starting', 'paused'); @@ -5552,6 +5928,8 @@ async function bootstrap() { // keeping Stop enabled. runActive = false; testsBusy = false; + mgTickPaused = false; + updateGameStatus('stopped'); setDebugButtons(); // also calls refreshStopButton } debugStopBtn.addEventListener('click', stopAll); @@ -5896,5 +6274,6 @@ if ((window as any).__fadeBootstrapStarted) { bootstrap().catch((e) => { console.error('bootstrap failed', e); statusEl.textContent = 'Bootstrap failed: ' + (e?.message ?? e); + (window as any).__pgSplash?.setStatus('Failed to start — see browser console.', true); }); } diff --git a/Playground/src/monogame-host.ts b/Playground/src/monogame-host.ts index 68896e7..4b97fc2 100644 --- a/Playground/src/monogame-host.ts +++ b/Playground/src/monogame-host.ts @@ -1,67 +1,65 @@ -// Lazy bridge that boots WebRuntime.MonoGame inline on the Playground page. +// Bridge that drives WebRuntime.MonoGame from an iframe inside #mg-blazor-root. // -// Why inline (not iframe): we want direct function calls between the editor -// and the game runtime. Two WASM runtimes co-exist on the page — -// 1. WebRuntime (Worker) — LSP + compile + tests + debug for 'web' projects -// 2. WebRuntime.MonoGame — KNI-backed Game1 for 'monogame' projects, on the -// main thread (canvas + Game.Run() require it) -// They publish to public/runtime/ and public/monogame-runtime/ respectively. +// History: this file used to inline-mount Blazor into the Playground page, +// injecting nkast.Wasm.* shims and calling Blazor.start() directly. After +// mg-export-3.md phase 3 the monogame template runs in its own iframe at +// /runtime/monogame/index.html?preview=1 — same model the web template +// already uses — and this file is a thin postMessage bridge. // -// Boot sequence on first call to ensureBooted(): -// 1. Inject the nkast.Wasm.* static-web-asset + + + diff --git a/Playground/src/log-bus.test.ts b/Playground/src/log-bus.test.ts new file mode 100644 index 0000000..637eb06 --- /dev/null +++ b/Playground/src/log-bus.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createLogBus, makeLogger, type LogEntry } from './log-bus'; + +describe('createLogBus', () => { + it('emit + snapshot round-trips entries in order', () => { + const bus = createLogBus(); + bus.emit({ level: 'info', channel: 'a', message: 'one' }); + bus.emit({ level: 'warn', channel: 'a', message: 'two' }); + const out = bus.snapshot(); + expect(out.map((e) => e.message)).toEqual(['one', 'two']); + expect(out.every((e) => typeof e.time === 'number' && e.time > 0)).toBe(true); + }); + + it('subscribe receives live entries', () => { + const bus = createLogBus(); + const seen: LogEntry[] = []; + const unsub = bus.subscribe((e) => seen.push(e)); + bus.emit({ level: 'info', channel: 'a', message: 'live' }); + unsub(); + bus.emit({ level: 'info', channel: 'a', message: 'after-unsub' }); + expect(seen.map((e) => e.message)).toEqual(['live']); + }); + + it('snapshot filters by channel', () => { + const bus = createLogBus(); + bus.emit({ level: 'info', channel: 'a', message: '1' }); + bus.emit({ level: 'info', channel: 'b', message: '2' }); + bus.emit({ level: 'info', channel: 'a', message: '3' }); + const out = bus.snapshot({ channels: ['a'] }); + expect(out.map((e) => e.message)).toEqual(['1', '3']); + }); + + it('snapshot filters by level', () => { + const bus = createLogBus(); + bus.emit({ level: 'debug', channel: 'a', message: 'dbg' }); + bus.emit({ level: 'error', channel: 'a', message: 'err' }); + bus.emit({ level: 'info', channel: 'a', message: 'inf' }); + const out = bus.snapshot({ levels: ['info', 'error'] }); + expect(out.map((e) => e.level)).toEqual(['error', 'info']); + }); + + it('snapshot honors limit (newest entries kept)', () => { + const bus = createLogBus(); + for (let i = 0; i < 10; i++) bus.emit({ level: 'info', channel: 'a', message: `m${i}` }); + const out = bus.snapshot({ limit: 3 }); + expect(out.map((e) => e.message)).toEqual(['m7', 'm8', 'm9']); + }); + + it('capacity trims oldest entries', () => { + const bus = createLogBus({ capacity: 50 }); // floor is 50 + for (let i = 0; i < 80; i++) bus.emit({ level: 'info', channel: 'a', message: `m${i}` }); + const out = bus.snapshot(); + expect(out.length).toBe(50); + expect(out[0].message).toBe('m30'); + expect(out[out.length - 1].message).toBe('m79'); + }); + + it('channels() returns unique sorted channel names', () => { + const bus = createLogBus(); + bus.emit({ level: 'info', channel: 'editor', message: '' }); + bus.emit({ level: 'info', channel: 'sharing', message: '' }); + bus.emit({ level: 'info', channel: 'editor', message: '' }); + expect(bus.channels()).toEqual(['editor', 'sharing']); + }); + + it('clear() with no channel wipes the whole buffer', () => { + const bus = createLogBus(); + bus.emit({ level: 'info', channel: 'a', message: 'x' }); + bus.clear(); + expect(bus.snapshot()).toEqual([]); + }); + + it('clear(channel) only drops entries on that channel', () => { + const bus = createLogBus(); + bus.emit({ level: 'info', channel: 'a', message: 'A1' }); + bus.emit({ level: 'info', channel: 'b', message: 'B1' }); + bus.emit({ level: 'info', channel: 'a', message: 'A2' }); + bus.clear('a'); + expect(bus.snapshot().map((e) => e.message)).toEqual(['B1']); + }); + + it('listener exceptions don\'t crash other listeners or producers', () => { + const bus = createLogBus(); + const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {}); + bus.subscribe(() => { throw new Error('bad listener'); }); + const seen: string[] = []; + bus.subscribe((e) => seen.push(e.message)); + bus.emit({ level: 'info', channel: 'a', message: 'ok' }); + expect(seen).toEqual(['ok']); + consoleErr.mockRestore(); + }); +}); + +describe('makeLogger', () => { + it('routes each level method through the bus with the bound channel', () => { + const bus = createLogBus(); + const log = makeLogger(bus, 'sharing'); + log.info('hi'); + log.warn('careful'); + log.error('oops'); + log.debug('dbg'); + const out = bus.snapshot(); + expect(out.map((e) => [e.level, e.channel, e.message])).toEqual([ + ['info', 'sharing', 'hi'], + ['warn', 'sharing', 'careful'], + ['error', 'sharing', 'oops'], + ['debug', 'sharing', 'dbg'], + ]); + }); + + it('passes through progress info on info/debug calls', () => { + const bus = createLogBus(); + const log = makeLogger(bus, 'sharing'); + log.info('uploading', { current: 3, total: 12 }); + const out = bus.snapshot(); + expect(out[0].progress).toEqual({ current: 3, total: 12 }); + }); +}); diff --git a/Playground/src/log-bus.ts b/Playground/src/log-bus.ts new file mode 100644 index 0000000..590f410 --- /dev/null +++ b/Playground/src/log-bus.ts @@ -0,0 +1,120 @@ +// Tiny channel-aware log bus. App-wide singleton (`appLog`) plus +// `createLogBus()` for tests. Producers call `getLogger(channel).info(...)`; +// consumers (the Logs panel, primarily) `subscribe()` for live updates or +// `snapshot()` for a backfill on mount. +// +// Why a bus instead of just `console.log`? Two reasons: +// 1. The Logs panel can filter by channel and level, persist a history, +// and survive panel re-renders without losing earlier entries. +// 2. We can route certain channels to the existing Output panel or to +// Playwright probes without each producer caring where its output goes. +// +// Capacity is bounded so a long session doesn't grow memory unboundedly. +// Default 2000 entries — a few minutes of busy sharing activity. + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogEntry { + /** Date.now() at emission. */ + time: number; + level: LogLevel; + /** Producer channel name — e.g. 'sharing', 'editor', 'lsp'. */ + channel: string; + message: string; + /** Optional step-of-N progress info for long operations. */ + progress?: { current: number; total: number }; +} + +export type LogListener = (entry: LogEntry) => void; + +export interface LogBus { + /** Emit a log entry. Time is stamped by the bus. */ + emit(entry: Omit): void; + /** Subscribe for live entries. Returns an unsubscribe fn. */ + subscribe(listener: LogListener): () => void; + /** Return a snapshot of the entries currently in the buffer, optionally + * filtered. Newest entry last. */ + snapshot(opts?: { channels?: string[]; levels?: LogLevel[]; limit?: number }): LogEntry[]; + /** Channels seen so far this session (for UI filter rendering). */ + channels(): string[]; + /** Clear the buffer. If `channel` is specified, only entries in that + * channel are removed. */ + clear(channel?: string): void; +} + +export interface Logger { + debug(message: string, progress?: { current: number; total: number }): void; + info(message: string, progress?: { current: number; total: number }): void; + warn(message: string, progress?: { current: number; total: number }): void; + error(message: string): void; +} + +export function createLogBus(opts: { capacity?: number } = {}): LogBus { + const capacity = Math.max(50, opts.capacity ?? 2000); + const buf: LogEntry[] = []; + const listeners = new Set(); + const seenChannels = new Set(); + + return { + emit(partial) { + const entry: LogEntry = { time: Date.now(), ...partial }; + buf.push(entry); + seenChannels.add(entry.channel); + // Trim head when over capacity; cheap because we only drop a + // batch when we exceed the high-water mark. + if (buf.length > capacity) buf.splice(0, buf.length - capacity); + for (const l of listeners) { + try { l(entry); } catch (e) { /* listener errors must not crash producers */ console.error('[log-bus] listener threw', e); } + } + }, + subscribe(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + snapshot(opts = {}) { + let out: LogEntry[] = buf; + if (opts.channels && opts.channels.length > 0) { + const wanted = new Set(opts.channels); + out = out.filter((e) => wanted.has(e.channel)); + } + if (opts.levels && opts.levels.length > 0) { + const wanted = new Set(opts.levels); + out = out.filter((e) => wanted.has(e.level)); + } + if (opts.limit !== undefined && opts.limit >= 0 && out.length > opts.limit) { + out = out.slice(out.length - opts.limit); + } + return [...out]; + }, + channels() { + return [...seenChannels].sort(); + }, + clear(channel) { + if (channel) { + for (let i = buf.length - 1; i >= 0; i--) { + if (buf[i].channel === channel) buf.splice(i, 1); + } + } else { + buf.length = 0; + } + }, + }; +} + +export function makeLogger(bus: LogBus, channel: string): Logger { + return { + debug(message, progress) { bus.emit({ level: 'debug', channel, message, progress }); }, + info(message, progress) { bus.emit({ level: 'info', channel, message, progress }); }, + warn(message, progress) { bus.emit({ level: 'warn', channel, message, progress }); }, + error(message) { bus.emit({ level: 'error', channel, message }); }, + }; +} + +/** App-wide default bus. The Logs dockview panel subscribes here at mount; + * producers (sharing, future editor/LSP integrations) emit through + * `getLogger(channel)`. */ +export const appLog: LogBus = createLogBus({ capacity: 2000 }); + +export function getLogger(channel: string): Logger { + return makeLogger(appLog, channel); +} diff --git a/Playground/src/logs-panel.ts b/Playground/src/logs-panel.ts new file mode 100644 index 0000000..c785b15 --- /dev/null +++ b/Playground/src/logs-panel.ts @@ -0,0 +1,304 @@ +// Logs dockview panel. Subscribes to the app-wide LogBus and renders a +// scrolling, filterable terminal-style log view. Channel chips toggle +// which producers are shown; level chips do the same for severity. New +// entries auto-scroll to the bottom unless the user has scrolled up +// (terminal-style "live tail" UX). +// +// One panel per playground window; mounts into a host div placed by +// index.html. Styles live here under the `fade-log-*` prefix and don't +// leak. + +import { appLog, type LogBus, type LogEntry, type LogLevel } from './log-bus'; + +const CSS_PREFIX = 'fade-log'; +const STYLE_ID = `${CSS_PREFIX}-styles`; +const ALL_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']; + +export interface LogsPanelOptions { + container: HTMLElement; + bus?: LogBus; // defaults to the app singleton + /** Initial level filter; entries below the configured min level are + * hidden. Default: include everything. */ + minLevel?: LogLevel; +} + +export interface LogsPanelHandle { + dispose(): void; +} + +export function mountLogsPanel(opts: LogsPanelOptions): LogsPanelHandle { + injectStylesOnce(); + const bus = opts.bus ?? appLog; + const root = opts.container; + root.classList.add(`${CSS_PREFIX}-root`); + root.replaceChildren(); + + // ─── filter state ────────────────────────────────────────────────────── + const enabledChannels = new Set(); // empty = all + const enabledLevels = new Set(ALL_LEVELS); + if (opts.minLevel) { + const i = ALL_LEVELS.indexOf(opts.minLevel); + if (i >= 0) { + enabledLevels.clear(); + for (let k = i; k < ALL_LEVELS.length; k++) enabledLevels.add(ALL_LEVELS[k]); + } + } + + // ─── DOM ─────────────────────────────────────────────────────────────── + const toolbar = el('div', `${CSS_PREFIX}-toolbar`); + const channelChips = el('div', `${CSS_PREFIX}-chips`); + const levelChips = el('div', `${CSS_PREFIX}-chips ${CSS_PREFIX}-chips-levels`); + const clearBtn = button('Clear', () => { bus.clear(); list.replaceChildren(); }); + const copyBtn = button('Copy', () => { + const text = bus.snapshot() + .filter(passesFilter) + .map(formatPlain) + .join('\n'); + void navigator.clipboard?.writeText(text); + }); + toolbar.append(channelChips, sep(), levelChips, sep(), copyBtn, clearBtn); + + const list = el('div', `${CSS_PREFIX}-list`); + list.setAttribute('role', 'log'); + list.setAttribute('aria-live', 'polite'); + + root.append(toolbar, list); + + // Track scroll-pinning so live-tail doesn't fight the user. + let pinnedToBottom = true; + list.addEventListener('scroll', () => { + const slack = 8; + pinnedToBottom = list.scrollTop + list.clientHeight >= list.scrollHeight - slack; + }); + + function passesFilter(e: LogEntry): boolean { + if (enabledChannels.size > 0 && !enabledChannels.has(e.channel)) return false; + if (!enabledLevels.has(e.level)) return false; + return true; + } + + // ─── rendering ───────────────────────────────────────────────────────── + function renderChannelChips() { + channelChips.replaceChildren(); + const channels = bus.channels(); + if (channels.length === 0) { + const empty = el('span', `${CSS_PREFIX}-empty`); + empty.textContent = 'no channels yet'; + channelChips.append(empty); + return; + } + for (const c of channels) { + const active = enabledChannels.size === 0 || enabledChannels.has(c); + channelChips.append(chip(c, active, () => { + // Empty-set means "all on." Clicking from there narrows to + // just this channel. Clicking an active chip removes it. + // Clicking an inactive chip while others are selected adds it. + if (enabledChannels.has(c)) enabledChannels.delete(c); + else enabledChannels.add(c); + refilter(); + renderChannelChips(); + })); + } + // "All" chip — clears the filter. + const allActive = enabledChannels.size === 0; + channelChips.append(chip('all', allActive, () => { + enabledChannels.clear(); + refilter(); + renderChannelChips(); + })); + } + + function renderLevelChips() { + levelChips.replaceChildren(); + for (const lvl of ALL_LEVELS) { + const active = enabledLevels.has(lvl); + levelChips.append(chip(lvl, active, () => { + if (active) enabledLevels.delete(lvl); + else enabledLevels.add(lvl); + refilter(); + renderLevelChips(); + }, `${CSS_PREFIX}-chip-${lvl}`)); + } + } + + function refilter() { + list.replaceChildren(); + const entries = bus.snapshot(); + for (const e of entries) if (passesFilter(e)) list.append(renderEntry(e)); + if (pinnedToBottom) scrollToBottom(); + } + + function renderEntry(e: LogEntry): HTMLElement { + const row = el('div', `${CSS_PREFIX}-row ${CSS_PREFIX}-row-${e.level}`); + const t = el('span', `${CSS_PREFIX}-time`); + t.textContent = formatTime(e.time); + const ch = el('span', `${CSS_PREFIX}-channel`); + ch.textContent = e.channel; + const lvl = el('span', `${CSS_PREFIX}-level ${CSS_PREFIX}-level-${e.level}`); + lvl.textContent = e.level.toUpperCase().slice(0, 4); + const msg = el('span', `${CSS_PREFIX}-msg`); + if (e.progress) { + msg.textContent = `${e.message} [${e.progress.current}/${e.progress.total}]`; + } else { + msg.textContent = e.message; + } + row.append(t, ch, lvl, msg); + return row; + } + + function scrollToBottom() { + list.scrollTop = list.scrollHeight; + } + + // Initial paint. + renderChannelChips(); + renderLevelChips(); + refilter(); + + // ─── live subscription ───────────────────────────────────────────────── + const unsub = bus.subscribe((entry) => { + // Refresh chip set if we hit a previously-unseen channel. + if (!bus.channels().every((c) => channelChips.querySelector(`[data-chip="${c}"]`))) { + renderChannelChips(); + } + if (!passesFilter(entry)) return; + list.append(renderEntry(entry)); + if (pinnedToBottom) scrollToBottom(); + }); + + return { + dispose() { + unsub(); + root.replaceChildren(); + root.classList.remove(`${CSS_PREFIX}-root`); + }, + }; +} + +// ─── DOM helpers ──────────────────────────────────────────────────────────── + +function el(tag: K, cls: string): HTMLElementTagNameMap[K] { + const n = document.createElement(tag); + n.className = cls; + return n; +} + +function button(label: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement('button'); + b.className = `${CSS_PREFIX}-btn`; + b.type = 'button'; + b.textContent = label; + b.onclick = onClick; + return b; +} + +function chip(label: string, active: boolean, onClick: () => void, extraClass = ''): HTMLButtonElement { + const c = document.createElement('button'); + c.className = `${CSS_PREFIX}-chip ${active ? `${CSS_PREFIX}-chip-active` : ''} ${extraClass}`.trim(); + c.type = 'button'; + c.textContent = label; + c.dataset.chip = label; + c.onclick = onClick; + return c; +} + +function sep(): HTMLElement { + return el('span', `${CSS_PREFIX}-sep`); +} + +function formatTime(ms: number): string { + const d = new Date(ms); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + const sub = String(d.getMilliseconds()).padStart(3, '0'); + return `${hh}:${mm}:${ss}.${sub}`; +} + +function formatPlain(e: LogEntry): string { + const t = new Date(e.time).toISOString(); + const prog = e.progress ? ` [${e.progress.current}/${e.progress.total}]` : ''; + return `${t} ${e.channel.padEnd(10)} ${e.level.toUpperCase().padEnd(5)} ${e.message}${prog}`; +} + +// ─── styles ──────────────────────────────────────────────────────────────── + +function injectStylesOnce(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` +.${CSS_PREFIX}-root { + display: flex; flex-direction: column; + height: 100%; box-sizing: border-box; + overflow: hidden; + color: var(--vscode-foreground, #ddd); + font: 12px/1.4 ui-sans-serif, system-ui, sans-serif; +} +.${CSS_PREFIX}-toolbar { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + padding: 6px 8px; + border-bottom: 1px solid var(--vscode-panel-border, #333); + flex-shrink: 0; +} +.${CSS_PREFIX}-chips { + display: inline-flex; gap: 4px; flex-wrap: wrap; +} +.${CSS_PREFIX}-empty { opacity: 0.5; font-style: italic; font-size: 11px; } +.${CSS_PREFIX}-chip { + appearance: none; border: 1px solid var(--vscode-panel-border, #555); + background: transparent; color: inherit; + padding: 1px 8px; border-radius: 999px; + font: inherit; font-size: 11px; cursor: pointer; + transition: filter 0.1s; + opacity: 0.55; +} +.${CSS_PREFIX}-chip:hover { filter: brightness(1.2); } +.${CSS_PREFIX}-chip-active { + opacity: 1; + background: rgba(255,255,255,0.08); + border-color: var(--vscode-focusBorder, #0e639c); +} +.${CSS_PREFIX}-chip-debug.${CSS_PREFIX}-chip-active { color: #8cf; } +.${CSS_PREFIX}-chip-info.${CSS_PREFIX}-chip-active { color: #ddd; } +.${CSS_PREFIX}-chip-warn.${CSS_PREFIX}-chip-active { color: #fc6; } +.${CSS_PREFIX}-chip-error.${CSS_PREFIX}-chip-active { color: #f88; } +.${CSS_PREFIX}-sep { + width: 1px; align-self: stretch; + background: var(--vscode-panel-border, #333); + margin: 0 2px; +} +.${CSS_PREFIX}-btn { + appearance: none; border: 1px solid var(--vscode-panel-border, #555); + background: transparent; color: inherit; + padding: 2px 10px; border-radius: 4px; + font: inherit; font-size: 11px; cursor: pointer; +} +.${CSS_PREFIX}-btn:hover { filter: brightness(1.2); } +.${CSS_PREFIX}-list { + flex: 1 1 auto; overflow-y: auto; + padding: 4px 0; + font-family: ui-monospace, monospace; font-size: 11px; + background: var(--vscode-editor-background, #0d0d0d); +} +.${CSS_PREFIX}-row { + display: grid; + grid-template-columns: 90px 80px 44px 1fr; + gap: 8px; + padding: 1px 8px; + white-space: pre-wrap; word-break: break-word; +} +.${CSS_PREFIX}-row:hover { background: rgba(255,255,255,0.03); } +.${CSS_PREFIX}-time { opacity: 0.55; } +.${CSS_PREFIX}-channel { opacity: 0.75; } +.${CSS_PREFIX}-level { font-weight: 700; opacity: 0.85; } +.${CSS_PREFIX}-level-debug { color: #8cf; } +.${CSS_PREFIX}-level-info { color: #ddd; } +.${CSS_PREFIX}-level-warn { color: #fc6; } +.${CSS_PREFIX}-level-error { color: #f88; } +.${CSS_PREFIX}-row-error { background: rgba(255,80,80,0.06); } +.${CSS_PREFIX}-row-warn { background: rgba(255,200,90,0.05); } +.${CSS_PREFIX}-msg { } +`; + document.head.appendChild(style); +} diff --git a/Playground/src/main.ts b/Playground/src/main.ts index 4e6ddd8..ff53952 100644 --- a/Playground/src/main.ts +++ b/Playground/src/main.ts @@ -79,6 +79,20 @@ import { mountHelpPanel } from './help'; import { monoGameHost } from './monogame-host'; import { mountAiChat, mountAiModels } from './ai-chat'; import type { CommandDocEntry as HelpCommandDocEntry } from './help'; +import { + mountCollaboration, + statusGlyph, + attachGutter, + mountConflictEditor, + mountHistoryPanel, + createDiffViewer, + type DiffViewerParams, + type FileStatus, + type CollaborationController, + type GutterHandle, +} from './sharing'; +import { mountLogsPanel } from './logs-panel'; +import { getLogger } from './log-bus'; import { FADE_JSON_NAME, defaultFadeProject, @@ -107,7 +121,6 @@ const DEFAULT_SOURCE = [ ].join('\n'); // ─── DOM refs ─────────────────────────────────────────────────────────────── -const statusEl = document.getElementById('status')!; // runBtn is a custom element; it accepts `disabled` as an // attribute just like a native button, but it isn't an HTMLButtonElement. const runBtn = document.getElementById('run') as HTMLElement & { disabled: boolean }; @@ -120,6 +133,7 @@ const viewMenuPanels = document.getElementById('view-menu-panels') as HTMLElemen const viewSaveLayoutBtn = document.getElementById('view-save-layout') as HTMLButtonElement; const viewResetLayoutBtn = document.getElementById('view-reset-layout') as HTMLButtonElement; const viewSavedLayouts = document.getElementById('view-saved-layouts') as HTMLElement; +const viewSemanticLayouts = document.getElementById('view-semantic-layouts') as HTMLElement; const newFileBtn = document.getElementById('new-file') as HTMLButtonElement; const fileListEl = document.getElementById('file-list')!; const tabsEl = document.getElementById('tabs')!; @@ -336,12 +350,64 @@ interface Tab { model: monaco.editor.ITextModel; dirty: boolean; saveTimer?: number; + /** Disposable for the sharing gutter decorator. Cleared when the tab is + * closed; safely no-op if sharing wasn't ready when the tab opened. */ + gutterHandle?: GutterHandle; } const tabs = new Map(); let activeName: string | null = null; let editor: monaco.editor.IStandaloneCodeEditor | null = null; +/** + * Force every pending 600ms-debounced autosave to land *now*. Used by the + * commit flow to guarantee the working tree on disk reflects the editor + * before we snapshot it — otherwise an in-flight edit could be silently + * excluded from the commit. Takes `workspace` as a parameter to match the + * `renderFileList` / `openFile` pattern (workspace lives in bootstrap scope). + */ +/** True iff any open Monaco tab has unflushed edits. The sharing + * panel's "Save" button enables on the first keystroke via this + * signal — without it the button stays greyed out for ~600 ms while + * the autosave debounce waits to write through to OPFS. */ +function anyTabDirty(): boolean { + for (const tab of tabs.values()) if (tab.dirty) return true; + return false; +} + +async function flushPendingSaves(workspace: OpfsWorkspace): Promise { + const promises: Promise[] = []; + const flushedNames: string[] = []; + for (const tab of tabs.values()) { + if (!tab.dirty) continue; + if (tab.saveTimer != null) { + clearTimeout(tab.saveTimer); + tab.saveTimer = undefined; + } + const name = tab.name; + const value = tab.model.getValue(); + flushedNames.push(name); + promises.push( + workspace.write(name, value).then(() => { tab.dirty = false; }), + ); + } + if (promises.length) { + await Promise.all(promises); + // Mirror the debounced-autosave path: invalidate the sharing panel's + // hash cache for every file we just wrote. Without this the panel + // keeps the pre-edit blob sha and a Save right after typing reports + // the file as still "modified" against the just-captured snapshot. + for (const name of flushedNames) { + sharingController?.invalidateHashFor(name); + } + // All flushed tabs are now clean — drop the dirty-tabs signal so + // the Save button's enabled state lines up with what's actually + // on disk again. + if (!anyTabDirty()) sharingController?.setHasDirtyTabs(false); + renderTabs(); + } +} + function languageFor(name: string): string { if (name.endsWith('.fbasic') || name.endsWith('.fb')) return 'fade'; const extra = languageForExtra(name); @@ -371,15 +437,41 @@ async function openFile(workspace: OpfsWorkspace, name: string) { // Hook this model for LSP push + decoration (if available) (window as any).__fadeHookModel?.(model); tab = { name, model, dirty: false }; + // Source-control gutter: paint per-line markers showing what changed + // vs. the last synced commit. Lives on the model (not the editor) so + // tab-switches don't lose the decorations. Disposes itself when the + // model is gone or via tab cleanup; safe to skip if sharing isn't set + // up yet (gets attached lazily on next open of the same file). + if (sharingController) { + const handle = attachGutter({ + model, + getSavedText: () => sharingController!.getSavedText(name), + getPublishedText: () => sharingController!.getPublishedText(name), + onShouldRefresh: (cb) => sharingController!.onStatusChange(() => cb()), + }); + tab.gutterHandle = handle; + } // Debounced auto-save: 600ms idle → write to OPFS model.onDidChangeContent(() => { tab!.dirty = true; + // Surface the unflushed-edit state to the sharing panel + // immediately so its Save button enables on the very first + // keystroke (rather than after the 600 ms autosave debounce + // + a refreshStatus round-trip). + sharingController?.setHasDirtyTabs(true); clearTimeout(tab!.saveTimer); tab!.saveTimer = window.setTimeout(async () => { try { await workspace.write(tab!.name, tab!.model.getValue()); tab!.dirty = false; + if (!anyTabDirty()) sharingController?.setHasDirtyTabs(false); renderTabs(); + // Lightweight per-file refresh — invalidates just this + // path's cached hash. The other ~N-1 files in the + // workspace stay cached, which is the difference + // between hashing every file every 600 ms and hashing + // only the file the user just typed in. + void sharingController?.refreshStatusForFile(tab!.name); } catch (e) { console.error('[fade] save failed for', tab!.name, e); } @@ -526,6 +618,18 @@ interface ProjectOps { } let projectOps: ProjectOps | null = null; +// Source-control wiring: the panel mounts at bootstrap (after dockview is up) +// and publishes a status map (path → A/M/D) via onStatusChange. We mirror it +// here so renderFileList can render badges without coupling back into the +// panel module. Same idea for pendingPullPaths — paths whose remote-head +// version differs from our last synced state, surfaced as a ↓ badge. +let sharingController: CollaborationController | null = null; +let sharingStatus: Map = new Map(); +let sharingPendingPull: Set = new Set(); +/** Files currently in conflict (text-with-markers or binary-conflict-copy + * exists). Drives the red 'C' badge in the workspace file list. */ +let sharingConflicts: { text: Set; binary: Set } = { text: new Set(), binary: new Set() }; + async function renderFileList(workspace: OpfsWorkspace) { const names = await workspace.list(); fileListEl.innerHTML = ''; @@ -570,6 +674,69 @@ async function renderFileList(workspace: OpfsWorkspace) { } li.append(badge); } + // Source-control status badge (A/M/D) when the workspace is bound to + // a repo. Empty / unchanged files get no badge to keep the file list + // visually quiet. + const scStatus = sharingStatus.get(name); + if (scStatus && scStatus !== 'unchanged') { + const scBadge = document.createElement('span'); + scBadge.className = `sharing-status sharing-${scStatus}`; + scBadge.textContent = statusGlyph(scStatus); + scBadge.title = scStatus.charAt(0).toUpperCase() + scStatus.slice(1); + li.append(scBadge); + } + // "Remote has changes for this file" badge — surfaced when the + // poll loop has detected the upstream branch moved and the new + // tree differs from our last synced base for this path. + if (sharingPendingPull.has(name)) { + const pull = document.createElement('span'); + pull.className = 'sharing-status sharing-pending-pull'; + pull.textContent = '↓'; + pull.title = 'Remote has changes for this file. Click Pull in the Source Control panel to fetch.'; + li.append(pull); + } + // Conflict badge — distinct red 'C' for files mid-merge. Covers + // both text (markers in the file) and binary (a sibling + // `.fade-conflict.` exists). Clicking the badge opens + // the merge editor for text conflicts; binary points at the + // Source Control panel's binary section. + const isTextConflict = sharingConflicts.text.has(name); + // For binary: the set holds the full conflict-copy filename. A + // base file has a binary conflict if ANY entry in the set starts + // with `.fade-conflict.`. Bounded by the set size (typically + // 0–1 entries). + const conflictCopyPrefix = `${name}.fade-conflict.`; + let isBinaryConflict = false; + for (const cf of sharingConflicts.binary) { + if (cf.startsWith(conflictCopyPrefix)) { isBinaryConflict = true; break; } + } + if (isTextConflict || isBinaryConflict) { + const conf = document.createElement('span'); + conf.className = 'sharing-status sharing-conflict'; + conf.textContent = 'C'; + conf.title = isTextConflict + ? 'Merge conflict in progress. Click to open the merge editor.' + : 'Binary conflict — a remote-version sibling file exists. Resolve via Source Control.'; + if (isTextConflict) { + conf.style.cursor = 'pointer'; + conf.addEventListener('click', (e) => { + e.stopPropagation(); + sharingController?.openConflictEditor(name); + }); + } + li.append(conf); + } + // Conflict-copy file itself (.fade-conflict. sibling) — show + // a special "(remote copy)" label so it's visually distinct from + // normal files in the list. + if (/\.fade-conflict\.[a-f0-9]+$/i.test(name)) { + li.classList.add('fade-conflict-sibling'); + const tag = document.createElement('span'); + tag.className = 'sharing-status sharing-conflict-sibling'; + tag.textContent = 'remote'; + tag.title = 'This is the REMOTE side of a binary conflict. The base file (without the .fade-conflict suffix) is your local version. Resolve via the Source Control panel.'; + li.append(tag); + } if (name === activeName) li.classList.add('active'); li.onclick = () => { // Binary files (.xnb, images, audio) go straight to the @@ -1593,7 +1760,6 @@ interface CompletionItem { async function bootstrap() { const pgSplash = (window as any).__pgSplash as { setStatus(t: string, e?: boolean): void; hide(): void } | undefined; - statusEl.textContent = 'Initializing services…'; pgSplash?.setStatus('Initializing editor…'); await initServices({ ...getModelServiceOverride(), @@ -1652,7 +1818,6 @@ async function bootstrap() { }); monaco.editor.setTheme('fade-dark'); - statusEl.textContent = 'Booting Fade runtime worker…'; pgSplash?.setStatus('Loading language server…'); // Heartbeat indicator — displayed in the Diagnostics panel. @@ -2519,6 +2684,9 @@ async function bootstrap() { // re-render so the source-order indicators update immediately. currentProjectRef = currentProject; renderFileList(workspace).catch(() => { /* ignore */ }); + // Source-control panel needs to re-bind to the new project's sync + // index (different repo, different baseTree, different status). + sharingController?.setActiveProject(workspace.currentProject()); // Title bar reflects the resolved project name. if (currentProject?.name) { const hasErrors = currentProjectErrors.some((e) => e.severity === 'error'); @@ -2625,11 +2793,14 @@ async function bootstrap() { const newTab: Tab = { name: newName, model: newModel, dirty: false }; newTab.model.onDidChangeContent(() => { newTab.dirty = true; + sharingController?.setHasDirtyTabs(true); clearTimeout(newTab.saveTimer); newTab.saveTimer = window.setTimeout(async () => { try { await workspace.write(newTab.name, newTab.model.getValue()); newTab.dirty = false; + if (!anyTabDirty()) sharingController?.setHasDirtyTabs(false); + sharingController?.invalidateHashFor(newTab.name); renderTabs(); } catch (e) { console.error('[fade] save failed for', newTab.name, e); @@ -3057,6 +3228,12 @@ async function bootstrap() { if (!source) return; const idx = testEntries.findIndex((t) => t.name === name); if (idx < 0) return; + // Test Mode semantic layout — focus Tests + Game. Skipped when a + // debug session is active (debugSingleTest already applied Debug + // Mode and we don't want to fight it). + if (!debugSessionActive) { + try { applySemanticLayout('test'); } catch (e) { console.warn('[fade] applySemanticLayout(test) failed', e); } + } testEntries[idx].status = 'running'; testEntries[idx].failure = null; testEntries[idx].failureFrames = undefined; @@ -3107,6 +3284,11 @@ async function bootstrap() { async function runAllTests() { const source = await getProjectSource(); if (!source) return; + // Test Mode semantic layout — focus Tests + Game. Skipped when a + // debug session is active (Debug Mode takes precedence). + if (!debugSessionActive) { + try { applySemanticLayout('test'); } catch (e) { console.warn('[fade] applySemanticLayout(test) failed', e); } + } // Mark every runnable test as queued (grey pulse). As the // cooperative pump advances, each test flips to 'running' // (yellow pulse) via test-progress's neighbor signal, then @@ -3299,7 +3481,6 @@ async function bootstrap() { renderTests(); }; - statusEl.textContent = 'Loading workspace…'; pgSplash?.setStatus('Loading workspace…'); const workspace = new OpfsWorkspace(); await workspace.init(); @@ -3317,7 +3498,21 @@ async function bootstrap() { // Help moved into that group's tab strip. Old v3 layouts persisted // 240+ px for the bottom group; v4 starts users on the new 180 px // default so the Help tab doesn't feel oversized. - const LAYOUT_STORAGE_KEY = 'fade.dockview.layout.v4'; + // v5 added: source-control, logs, history. Users on v4 don't have + // these panels in their saved layout; bumping the key forces a clean + // rebuild so the new tabs appear. healLayout also lists them as + // missing-defaults, but bumping is the simpler guarantee. + // v6 renamed 'source-control' panel id → 'collaboration'. Stored + // v5 layouts still reference the old id; bump again so they get a + // default rebuild instead of dockview discarding the renamed panel. + // v7 dropped Collaboration / Logs / History from the default tab strip + // (they now open into the editor tab group on demand), folded Debug + // into the Workspace tab group, and changed which tabs are focused on + // startup. Bump forces a clean rebuild for users on v6. + // v8 moved Tests from the bottom tab group into the Workspace tab + // group (so the left column tabs are Workspace / Debug / Tests). + // Bump again so existing v7 users get the rebuild. + const LAYOUT_STORAGE_KEY = 'fade.dockview.layout.v8'; function setupDockview(): DockviewApi { const dockRoot = document.getElementById('dock-root')!; @@ -3353,6 +3548,82 @@ async function bootstrap() { readBytes: (n) => workspace.readBytes(n), }); } + if (name === 'diff-viewer') { + // Read-only Monaco diff editor. Params arrive via + // init({ params }) — caller is responsible for + // fetching the before/after strings before opening + // (see openDiffViewer below) since dockview's + // createComponent only gets {id, name} synchronously. + return createDiffViewer(); + } + if (name === 'conflict-editor') { + // id encodes the path: `conflict-editor:`. + const path = id.startsWith('conflict-editor:') + ? id.slice('conflict-editor:'.length) + : ''; + const element = document.createElement('div'); + element.style.height = '100%'; + element.style.width = '100%'; + let handle: { dispose(): void } | null = null; + return { + element, + init() { + if (!path) { + element.textContent = 'conflict-editor missing path in panel id'; + return; + } + // Read the file fresh from OPFS so the conflict + // editor has the on-disk content as its starting + // point. The conflict editor creates its own + // throwaway Monaco model — autosave only fires + // for the regular tab's model, which we never + // touch from here. + (async () => { + let initialContent = ''; + try { + initialContent = await workspace.read(path); + } catch (e) { + element.textContent = `conflict-editor: cannot read ${path}: ${(e as Error).message}`; + return; + } + handle = mountConflictEditor({ + container: element, + path, + initialContent, + languageId: languageFor(path), + onSave: async (resolvedPath, content) => { + try { + await workspace.write(resolvedPath, content); + // Update the regular tab's model + // (if open) so it reflects the + // resolved content immediately. + const uri = monaco.Uri.file(`/workspace/${resolvedPath}`); + const existingModel = monaco.editor.getModel(uri); + if (existingModel && existingModel.getValue() !== content) { + existingModel.setValue(content); + } + // Reflect change in the visible tab list. + const tab = tabs.get(resolvedPath); + if (tab) tab.dirty = false; + renderTabs(); + } catch (e) { + console.error('[fade] conflict-editor save failed', e); + } + try { dock.getPanel(`conflict-editor:${resolvedPath}`)?.api.close(); } catch { /* ignore */ } + await sharingController?.refreshStatus(); + }, + onClose: () => { + try { dock.getPanel(`conflict-editor:${path}`)?.api.close(); } catch { /* ignore */ } + }, + }); + })(); + }, + dispose() { + handle?.dispose(); + handle = null; + }, + }; + } const cell = panelCells.querySelector( `.panel-cell[data-panel="${name}"]`, ); @@ -3426,6 +3697,12 @@ async function bootstrap() { 'binary-preview', 'ai-chat', 'ai-models', + 'collaboration', + 'logs', + 'history', + // Dynamic — one per conflict file; created when the collaboration + // panel's "Resolve in editor →" button opens a file. + 'conflict-editor', ]); function healLayout(dock: DockviewApi) { @@ -3484,7 +3761,7 @@ async function bootstrap() { renderer: RENDER_ALWAYS, title: 'Workspace', }); addMissing('debug', { - position: { referencePanel: dock.getPanel('workspace')?.id ?? 'editor', direction: 'below' }, + position: { referencePanel: dock.getPanel('workspace')?.id ?? 'editor', direction: 'within' }, renderer: RENDER_ALWAYS, title: 'Debug', }); addMissing('output', { @@ -3497,7 +3774,7 @@ async function bootstrap() { renderer: RENDER_ALWAYS, title: 'Problems', }); addMissing('tests', { - position: { referencePanel: bottomRef, direction: 'within' }, + position: { referencePanel: dock.getPanel('workspace')?.id ?? 'workspace', direction: 'within' }, renderer: RENDER_ALWAYS, title: 'Tests', }); addMissing('debug-console', { @@ -3517,6 +3794,12 @@ async function bootstrap() { position: { referencePanel: helpRef, direction: 'within' }, renderer: RENDER_ALWAYS, title: 'Help', }); + // Collaboration / Logs / History are no longer part of the + // default tab strip — they open into the editor tab group on + // demand via openPanelById. If a restored layout already + // contained them (e.g. the user opened them previously and + // dockview persisted the position), we leave them where they + // are. If absent, we deliberately do NOT re-add them here. } catch (e) { console.warn('[fade] healLayout failed — falling back to default', e); try { dock.clear(); } catch { /* dockview clear may not exist */ } @@ -3560,20 +3843,31 @@ async function bootstrap() { initialWidth: 260, renderer: RENDER_ALWAYS, }); - // Single consolidated Debug panel (Variables / Watch / Call Stack / - // Breakpoints sections inside). + // Workspace tab group: Workspace / Debug / Tests. Single left + // column with the file tree, the debugger, and the test list as + // tabs. Users flip between them with one click instead of losing + // vertical real estate to stacked sub-panes. dock.addPanel({ id: 'debug', component: 'debug', title: 'Debug', - position: { referencePanel: workspacePanel.id, direction: 'below' }, + position: { referencePanel: workspacePanel.id, direction: 'within' }, renderer: RENDER_ALWAYS, }); - // Bottom tab group: Output / Problems / Tests / Debug Console / Help. + dock.addPanel({ + id: 'tests', + component: 'tests', + title: 'Tests', + position: { referencePanel: workspacePanel.id, direction: 'within' }, + renderer: RENDER_ALWAYS, + }); + // Bottom tab group: Output / Problems / Debug Console. // Default height kept modest — the editor + game canvas should // dominate the viewport, with the bottom panel showing a few lines // of output by default. Users can drag the splitter taller when - // they want to dig into Tests / Help / etc. + // they want to dig in. Note: Collaboration / Logs / History are + // NOT added here — they open into the editor tab group on demand + // via openPanelById. const outputPanel = dock.addPanel({ id: 'output', component: 'output', @@ -3589,13 +3883,6 @@ async function bootstrap() { position: { referencePanel: outputPanel.id, direction: 'within' }, renderer: RENDER_ALWAYS, }); - dock.addPanel({ - id: 'tests', - component: 'tests', - title: 'Tests', - position: { referencePanel: outputPanel.id, direction: 'within' }, - renderer: RENDER_ALWAYS, - }); dock.addPanel({ id: 'debug-console', component: 'debug-console', @@ -3626,8 +3913,13 @@ async function bootstrap() { position: { referencePanel: gamePanel.id, direction: 'within' }, renderer: RENDER_ALWAYS, }); - const out = dock.getPanel('output'); - if (out) out.api.setActive(); + // Default-focused tabs in each group: Workspace, Editor, Help, Problems. + // setActive() on a panel activates it within its own group, so calling + // it on one panel per group gives the user the intended startup view. + try { dock.getPanel('workspace')?.api?.setActive(); } catch { /* ignore */ } + try { dock.getPanel('editor')?.api?.setActive(); } catch { /* ignore */ } + try { dock.getPanel('help')?.api?.setActive(); } catch { /* ignore */ } + try { dock.getPanel('problems')?.api?.setActive(); } catch { /* ignore */ } // `initialWidth/Height` on AddPanelOptions is honored as a hint but // dockview's grid balances new groups proportionally against @@ -3651,12 +3943,255 @@ async function bootstrap() { // Build the dockable layout BEFORE monaco mounts so #editor is visible // in the DOM by the time create() runs (Monaco's automaticLayout // measures the container at construction). - statusEl.textContent = 'Mounting layout…'; pgSplash?.setStatus('Mounting layout…'); const dockApi = setupDockview(); // Expose for tests + future "Reset layout" command. (window as any).__fadeDockview = dockApi; + // Logs panel: subscribes to the app-wide LogBus and renders a filterable + // terminal-style log feed. Mounted once at boot; survives panel + // tab-switches because the dockview component renderer is 'always'. + const logsHost = document.getElementById('logs-host'); + if (logsHost) { + mountLogsPanel({ container: logsHost }); + // Surface a couple of app-lifecycle events to the bus so the panel + // isn't empty on first open. Future: pipe LSP / monogame events too. + const bootLog = getLogger('app'); + bootLog.info('Playground booted'); + } + + // Source Control panel: mounts into the offscreen #collaboration-host + // that dockview moves into the workspace tab group. Bound to the active + // project; flushPendingSaves is bound here so the panel doesn't need a + // direct reference to the editor's tabs map. + const scHost = document.getElementById('collaboration-host'); + if (scHost) { + sharingController = mountCollaboration({ + container: scHost, + workspace, + getActiveProject: () => workspace.currentProject(), + flushPendingSaves: () => flushPendingSaves(workspace), + onAfterPull: async (changedPaths) => { + // Reflect pulled bytes in any open Monaco editor whose file + // is among the changed set. Text files only; binary tabs + // don't have a model. + for (const path of changedPaths) { + const tab = tabs.get(path); + if (!tab) continue; + try { + const fresh = await workspace.read(path); + if (tab.model.getValue() !== fresh) tab.model.setValue(fresh); + } catch { /* binary or deleted — skip */ } + } + await renderFileList(workspace); + }, + // Open the dedicated conflict-resolution editor in its own + // dockview tab. The editor reads the file from OPFS into a + // throwaway Monaco model — independent of any regular tab — so + // edits stay in-memory until the user clicks Save & close. + onOpenConflict: async (path) => { + const panelId = `conflict-editor:${path}`; + const existing = dockApi.getPanel(panelId); + if (existing) { existing.api.setActive(); return; } + dockApi.addPanel({ + id: panelId, + component: 'conflict-editor', + title: `⚠ ${path}`, + position: { referencePanel: 'editor', direction: 'within' }, + renderer: 'always', + }); + }, + // Open a read-only Monaco diff for "Show diff" buttons in the + // Collaboration + History panels. Controller resolves + // before/after content; host just owns the dockview tab. + onOpenDiff: (args) => { + openDiffViewer({ + id: args.id, + params: { + title: args.title, + path: args.path, + languageId: args.languageId, + beforeText: args.beforeText, + afterText: args.afterText, + beforeLabel: args.beforeLabel, + afterLabel: args.afterLabel, + }, + }); + }, + }); + sharingController.onStatusChange((map) => { + sharingStatus = map; + void renderFileList(workspace); + renderSharingChips(); + renderSharingStatusIcon(); + }); + sharingController.onPendingPullChange((paths) => { + sharingPendingPull = paths; + void renderFileList(workspace); + renderSharingChips(); + renderSharingStatusIcon(); + }); + sharingController.onConflictChange((state) => { + sharingConflicts = state; + void renderFileList(workspace); + renderSharingChips(); + renderSharingStatusIcon(); + }); + sharingController.onSavesChange(() => { + renderSharingChips(); + renderSharingStatusIcon(); + }); + // Header chips — paint once at mount, then re-render every time a + // sharing signal changes. Each chip is a button that focuses the + // Collaboration tab so the user can drill in. + renderSharingChips(); + // Persistent status icon — wire the one-time click handler now, + // then keep its badge state in sync via the listeners above. + wireSharingStatusIcon(); + renderSharingStatusIcon(); + + // History panel binds to the same controller. Mounting here (after + // sharing is up) guarantees the controller is non-null when the + // panel subscribes for history updates. + const historyHost = document.getElementById('history-host'); + if (historyHost) { + mountHistoryPanel({ + container: historyHost, + controller: sharingController, + }); + } + } + + /** Open (or re-focus) a read-only Monaco diff editor in its own + * dockview tab. `id` identifies the panel uniquely — clicking + * "Show diff" twice for the same context just re-activates the + * existing tab. Caller is responsible for fetching before/after + * text up front; this helper doesn't know about the controller. */ + function openDiffViewer(args: { + id: string; + params: DiffViewerParams; + }) { + try { + const existing = dockApi.getPanel(args.id); + if (existing) { + existing.api.updateParameters(args.params); + existing.api.setActive(); + return; + } + dockApi.addPanel({ + id: args.id, + component: 'diff-viewer', + title: args.params.title, + position: { referencePanel: 'editor', direction: 'within' }, + renderer: 'always', + params: args.params as unknown as Record, + }); + } catch (e) { + console.warn('[fade] openDiffViewer failed', e); + } + } + + /** Paint the four sharing-status pills into the app header. Counts + * come straight off the controller's getters so we don't have to + * cache state at module scope. Clicking any pill focuses the + * Collaboration tab — the panel itself has the detailed view. */ + function renderSharingChips() { + const host = document.getElementById('sharing-chips'); + if (!host || !sharingController) return; + host.replaceChildren(); + // Chips only make sense in the context of a remote — they're + // about save/publish/pull state vs. that remote. When the user + // is disconnected (or the project never had a remote), there's + // nothing to surface. Local saves persist across disconnect but + // showing "↑ N unpublished" without a target is more confusing + // than helpful. + if (!sharingController.getRepoInfo()) return; + const statusMap = sharingController.getStatusMap(); + let unsaved = 0; + for (const status of statusMap.values()) if (status !== 'unchanged') unsaved++; + const savesCount = sharingController.getPendingSaves().length; + const pullCount = sharingController.getPendingPullPaths().size; + const conflicts = sharingController.getConflictPaths(); + const conflictCount = conflicts.text.size + conflicts.binary.size; + const addChip = (label: string, variant: string, title: string) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `sharing-chip sharing-chip-${variant}`; + b.textContent = label; + b.title = title; + b.addEventListener('click', focusCollaborationPanel); + host.append(b); + }; + if (unsaved > 0) addChip(`● ${unsaved} unsaved`, 'unsaved', 'Working-tree changes not yet snapshotted. Click to open Collaboration.'); + if (savesCount > 0) addChip(`↑ ${savesCount} unpublished`, 'unpublished', 'Local saves not yet pushed to the remote. Click to open Collaboration.'); + if (pullCount > 0) addChip(`↓ ${pullCount} remote`, 'remote', 'Remote branch has changes you haven\'t pulled. Click to open Collaboration.'); + if (conflictCount > 0) addChip(`⚠ ${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`, 'conflict', 'Unresolved merge conflicts. Click to open Collaboration.'); + } + + /** Shared "open the Collaboration panel" action used by both the + * header chips and the persistent status icon. Re-adds the panel + * to the dockview if the user previously closed it, so the click + * is reliable regardless of layout history. */ + function focusCollaborationPanel() { + try { + let panel = dockApi.getPanel('collaboration'); + if (!panel) { + const workspaceRef = dockApi.getPanel('workspace')?.id + ?? dockApi.getPanel('editor')?.id; + dockApi.addPanel({ + id: 'collaboration', + component: 'collaboration', + title: 'Collaboration', + renderer: 'always', + position: workspaceRef ? { referencePanel: workspaceRef, direction: 'within' } : undefined, + }); + panel = dockApi.getPanel('collaboration'); + } + panel?.api.setActive(); + } catch (e) { + console.warn('[fade] failed to focus Collaboration panel', e); + } + } + + /** Bind the header status icon's click handler once. The badge + * state is repainted by `renderSharingStatusIcon` whenever the + * controller emits a relevant change. */ + function wireSharingStatusIcon() { + const btn = document.getElementById('sharing-status-icon'); + if (!btn) return; + btn.addEventListener('click', focusCollaborationPanel); + } + + /** Update the header status icon's badge to reflect the current + * GitHub connection state. Three states: + * - connected → green dot (signed in + repo bound) + * - disconnected → grey dot (signed in, no repo) + * - signedout → darker grey (no token at all) + * Loading state is the initial CSS class; it persists until the + * controller is up and renderSharingStatusIcon runs at least + * once. */ + function renderSharingStatusIcon() { + const btn = document.getElementById('sharing-status-icon'); + const badge = document.getElementById('sharing-status-badge'); + if (!btn || !badge) return; + const repo = sharingController?.getRepoInfo() ?? null; + // Heuristic for "signed in": the controller exposes signed-in + // identity via getStatusMap and getRepoInfo, but not a direct + // "is user signed in" flag. If there's a repo, they're + // definitely signed in. Otherwise we don't know with certainty + // from this surface — but a non-empty status map means the + // controller has done a refreshStatus, which requires a token. + // Good enough for the icon. + const signedIn = repo !== null || (sharingController?.getStatusMap().size ?? 0) > 0; + const variant = repo ? 'connected' : (signedIn ? 'disconnected' : 'signedout'); + badge.className = `sharing-status-badge sharing-status-badge-${variant}`; + btn.title = repo + ? `GitHub: connected to ${repo.owner}/${repo.name} · ${repo.branch}. Click to open Collaboration.` + : signedIn + ? 'GitHub: signed in but no repo connected for this project. Click to open Collaboration.' + : 'GitHub: not signed in. Click to open Collaboration.'; + } + // Mount the Help panel's TOC + search + reader. Populated below // once the LSP worker is ready (no source needed — it reads from // the bridge's loaded CommandCollection). @@ -3734,6 +4269,9 @@ async function bootstrap() { const VIEW_PANELS: Array<{ id: string; label: string }> = [ { id: 'editor', label: 'Editor' }, { id: 'workspace', label: 'Workspace' }, + { id: 'collaboration', label: 'Collaboration' }, + { id: 'logs', label: 'Logs' }, + { id: 'history', label: 'History' }, { id: 'debug', label: 'Debug' }, { id: 'output', label: 'Output' }, { id: 'problems', label: 'Problems' }, @@ -3759,6 +4297,112 @@ async function bootstrap() { try { localStorage.setItem(SAVED_LAYOUTS_KEY, JSON.stringify(layouts)); } catch { /* ignore */ } } + // ─── Semantic layouts ──────────────────────────────────────────────── + // Named layouts associated with a mode (Debug, Test). Built-in defaults + // ensure the relevant panels exist and are focused. Users can override + // a slot by saving the current dock state to it via the View menu; + // overrides live in localStorage and persist across reloads. + type SemanticLayoutId = 'debug' | 'test'; + interface SemanticLayoutDef { + id: SemanticLayoutId; + label: string; + icon: string; // codicon class (e.g. 'codicon-debug-alt') + focus: string[]; // panel ids to activate in their groups + } + const SEMANTIC_LAYOUTS: SemanticLayoutDef[] = [ + { id: 'debug', label: 'Debug Mode', icon: 'codicon-debug-alt', + focus: ['debug', 'game', 'debug-console'] }, + { id: 'test', label: 'Test Mode', icon: 'codicon-beaker', + focus: ['tests', 'game'] }, + ]; + const SEMANTIC_LAYOUTS_KEY = 'fade.dockview.semanticLayouts'; + + function loadSemanticLayouts(): Partial> { + try { + const raw = localStorage.getItem(SEMANTIC_LAYOUTS_KEY); + if (raw) return JSON.parse(raw) as Partial>; + } catch { /* ignore */ } + return {}; + } + + function saveSemanticLayouts(layouts: Partial>) { + try { localStorage.setItem(SEMANTIC_LAYOUTS_KEY, JSON.stringify(layouts)); } catch { /* ignore */ } + } + + // Focus the panels associated with a semantic layout. Re-adds missing + // panels via openPanelById so e.g. Debug Mode still works after the + // user closed the Debug tab. + function focusSemanticPanels(id: SemanticLayoutId) { + const def = SEMANTIC_LAYOUTS.find((s) => s.id === id); + if (!def) return; + for (const panelId of def.focus) { + let p = dockApi.getPanel(panelId); + if (!p) { + openPanelById(panelId); + p = dockApi.getPanel(panelId); + } + try { p?.api?.setActive(); } catch { /* ignore */ } + } + } + + function applySemanticLayout(id: SemanticLayoutId) { + // Debug Mode auto-restore: stash the pre-apply layout so we can + // snap back when the session ends — unless the user has made + // structural view changes (opened tabs, redocked, etc) in the + // meantime. The post-apply fingerprint below is the comparator; + // if it differs at end-of-session, the user has touched things + // and we leave their layout alone. + if (id === 'debug') { + try { preDebugLayoutSnapshot = dockApi.toJSON() as object; } + catch { preDebugLayoutSnapshot = null; } + } + const stored = loadSemanticLayouts(); + const saved = stored[id]; + let applied = false; + if (saved) { + try { + dockApi.fromJSON(saved as any); + healLayout(dockApi); + focusSemanticPanels(id); + applied = true; + } catch (e) { + console.warn(`[fade] failed to restore semantic layout ${id} — falling back to focus-only`, e); + } + } + if (!applied) { + // No saved override → don't reshape the grid, just activate + // the relevant tabs in their groups. + focusSemanticPanels(id); + } + if (id === 'debug') { + try { postDebugLayoutFingerprint = JSON.stringify(dockApi.toJSON()); } + catch { postDebugLayoutFingerprint = null; } + } + } + + // Debug-session view stash. Captured on entry to Debug Mode; consulted + // on exit. We restore the pre-debug layout only when the current dock + // fingerprint matches what we wrote on apply — that's our proxy for + // "the user didn't open tabs or redock during the session." If they + // did anything, the fingerprint diverges and we leave their layout + // alone so we don't undo their work. + let preDebugLayoutSnapshot: object | null = null; + let postDebugLayoutFingerprint: string | null = null; + + function restorePreDebugLayoutIfUnchanged() { + const pre = preDebugLayoutSnapshot; + const post = postDebugLayoutFingerprint; + preDebugLayoutSnapshot = null; + postDebugLayoutFingerprint = null; + if (!pre || !post) return; + let currentJson: string; + try { currentJson = JSON.stringify(dockApi.toJSON()); } + catch { return; } + if (currentJson !== post) return; // user changed something — keep their layout + try { dockApi.fromJSON(pre as any); healLayout(dockApi); } + catch (e) { console.warn('[fade] failed to restore pre-debug layout', e); } + } + // Open a named panel that is currently absent from the dock. // Core panels use healLayout (which knows their default positions). // Panels that are hidden by default (e.g. diagnostics) are added @@ -3793,6 +4437,24 @@ async function bootstrap() { }); dockApi.getPanel('diagnostics')?.api?.setActive(); } catch (e) { console.warn('[fade] failed to open diagnostics panel', e); } + } else if (id === 'collaboration' || id === 'history' || id === 'logs') { + // These panels aren't part of the default tab strip — opening + // them via the View menu drops them into the editor tab group + // so they share screen space with code rather than crowding + // the bottom panel or splitting the workspace column. + const ref = dockApi.getPanel('editor')?.id; + const titleFor = (k: string) => k === 'collaboration' ? 'Collaboration' + : k === 'history' ? 'History' : 'Logs'; + try { + dockApi.addPanel({ + id, + component: id, + title: titleFor(id), + position: ref ? { referencePanel: ref, direction: 'within' } : undefined, + renderer: RENDER_ALWAYS, + }); + dockApi.getPanel(id)?.api?.setActive(); + } catch (e) { console.warn(`[fade] failed to open ${id} panel`, e); } } else { // For standard panels, healLayout handles re-adding with the // right default position. @@ -3821,6 +4483,60 @@ async function bootstrap() { viewMenuPanels.appendChild(btn); } + // ── Semantic (mode) layouts ─────────────────────────────────────── + viewSemanticLayouts.innerHTML = ''; + const semanticOverrides = loadSemanticLayouts(); + for (const def of SEMANTIC_LAYOUTS) { + const isCustomized = semanticOverrides[def.id] != null; + const row = document.createElement('div'); + row.className = 'view-saved-layout-row'; + + const nameBtn = document.createElement('button'); + nameBtn.className = 'layout-name-btn'; + const badgeTitle = isCustomized + ? 'Customized — built-in default overridden' + : 'Semantic layout (built-in default)'; + nameBtn.title = `Apply ${def.label}`; + nameBtn.innerHTML = + `` + + `${def.label}` + + ``; + nameBtn.addEventListener('click', () => { + closeViewMenu(); + applySemanticLayout(def.id); + }); + + const saveBtn = document.createElement('button'); + saveBtn.className = 'layout-save-btn'; + saveBtn.title = `Save current layout as ${def.label}`; + saveBtn.innerHTML = ''; + saveBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const overrides = loadSemanticLayouts(); + overrides[def.id] = dockApi.toJSON() as object; + saveSemanticLayouts(overrides); + renderViewMenu(); + }); + + row.appendChild(nameBtn); + row.appendChild(saveBtn); + if (isCustomized) { + const resetBtn = document.createElement('button'); + resetBtn.className = 'layout-del-btn'; + resetBtn.title = `Restore built-in ${def.label}`; + resetBtn.textContent = '↺'; + resetBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const overrides = loadSemanticLayouts(); + delete overrides[def.id]; + saveSemanticLayouts(overrides); + renderViewMenu(); + }); + row.appendChild(resetBtn); + } + viewSemanticLayouts.appendChild(row); + } + // ── Saved layouts ───────────────────────────────────────────────── viewSavedLayouts.innerHTML = ''; const saved = loadSavedLayouts(); @@ -4085,7 +4801,6 @@ async function bootstrap() { }); projectNewInput.addEventListener('input', clearProjectError); - statusEl.textContent = 'Mounting editor…'; pgSplash?.setStatus('Mounting editor…'); editor = monaco.editor.create(editorContainer, { value: '', @@ -4260,7 +4975,6 @@ async function bootstrap() { await renderFileList(workspace); } - statusEl.textContent = 'Ready.'; pgSplash?.hide(); monoGameHost.notifyPgSplashHidden(); // Enable Run / Debug / Export through refreshRunButtons so a project @@ -5539,6 +6253,7 @@ async function bootstrap() { clearDebugInspectionPanels(); setDebugEmptyStates(true); setDebugButtons(); + restorePreDebugLayoutIfUnchanged(); break; case 'REV_REQUEST_EXPLODE': { debugSessionActive = false; @@ -5578,6 +6293,7 @@ async function bootstrap() { clearDebugInspectionPanels(); setDebugEmptyStates(true); setDebugButtons(); + restorePreDebugLayoutIfUnchanged(); break; } case 'PROTO_ACK': { @@ -5735,6 +6451,12 @@ async function bootstrap() { // per-test Debug share the same "prep UI → start → sync bps → continue" // sequence. async function beginDebugSession(starter: () => Promise): Promise { + // Debug Mode semantic layout: focus Debug, Game, and Debug Console + // (or apply the user's saved Debug Mode override). Called once per + // session start so a per-test debug also opts into Debug Mode — + // user requirement: "running a test in debug mode should opt to + // Debug Mode". + try { applySemanticLayout('debug'); } catch (e) { console.warn('[fade] applySemanticLayout(debug) failed', e); } clearOutput(); debugReplOutput.textContent = ''; setDebugStatus('starting', 'paused'); @@ -5749,6 +6471,10 @@ async function bootstrap() { appendReplLine(result.error ?? 'Failed to start', 'err'); runActive = false; refreshRunButtons(); + // Roll back the Debug Mode layout we applied above — there's + // no session to keep, and the user shouldn't be stuck looking + // at Debug Mode after a failed start. + restorePreDebugLayoutIfUnchanged(); return false; } runActive = false; @@ -5910,6 +6636,7 @@ async function bootstrap() { setCurrentLine(null); clearDebugInspectionPanels(); setDebugEmptyStates(true); + restorePreDebugLayoutIfUnchanged(); } if (currentProject?.type === 'monogame') { // Pause the canvas regardless of debug state — even after debug @@ -6273,7 +7000,6 @@ if ((window as any).__fadeBootstrapStarted) { (window as any).__fadeBootstrapStarted = true; bootstrap().catch((e) => { console.error('bootstrap failed', e); - statusEl.textContent = 'Bootstrap failed: ' + (e?.message ?? e); (window as any).__pgSplash?.setStatus('Failed to start — see browser console.', true); }); } diff --git a/Playground/src/sharing/adapter.ts b/Playground/src/sharing/adapter.ts new file mode 100644 index 0000000..11ead85 --- /dev/null +++ b/Playground/src/sharing/adapter.ts @@ -0,0 +1,81 @@ +// Storage adapter interface — the surface the Repo engine talks to. +// +// **Third pivot edition**: we used to layer our own object store + manifest +// format on top of an arbitrary key-value backend. Now that the backend is +// always GitHub (= real git), we collapsed that layer and speak git's data +// model directly. The shape mirrors GitHub's Git Data API endpoints so the +// adapter is essentially a 1:1 wire wrapper. +// +// Anything that answers these calls correctly works as a backend — the +// MockAdapter in this directory satisfies the interface against in-memory +// state for tests; the GitHubAdapter is the production implementation. + +import type { GitCommitMeta, GitTree } from './git-types'; + +// Thrown when updateBranch's fast-forward check rejects (the branch ref +// moved between our read and write). The Repo engine catches this to drive +// pull-before-commit (see sharing.md §8.5). +export class HeadConflictError extends Error { + constructor(public expected: string | null, public actual: string | null) { + super(`branch moved: expected ${expected ?? ''}, got ${actual ?? ''}`); + this.name = 'HeadConflictError'; + } +} + +export interface GitAdapter { + // ─── read path ────────────────────────────────────────────────────────── + + /** Current branch HEAD's commit SHA, or null if the branch doesn't exist + * yet (a freshly-created repo without auto_init). */ + branchHead(): Promise; + + /** Metadata for a single commit. Throws if the SHA isn't a commit object. */ + getCommit(sha: string): Promise; + + /** Resolve a commit SHA to its full recursive tree. The adapter takes a + * *commit* SHA (not a tree SHA) and does the two-step lookup internally; + * that's the affordance the engine wants. */ + getTree(commitSha: string): Promise; + + /** Raw bytes of a single blob. */ + getBlob(blobSha: string): Promise; + + // ─── write path (Git Data API) ────────────────────────────────────────── + + /** Upload a blob. Idempotent: same bytes yield the same SHA, and the + * adapter MAY short-circuit if it already knows the SHA exists. */ + createBlob(bytes: Uint8Array): Promise<{ sha: string }>; + + /** Build a new tree by patching a base tree with the supplied entries. + * Entries with `blobSha = null` delete that path; otherwise the path is + * added or replaced. Omit `baseTreeSha` to build a tree from scratch. */ + createTree(opts: { + baseTreeSha?: string; + entries: Array<{ path: string; blobSha: string | null }>; + }): Promise<{ sha: string }>; + + /** Wrap a tree as a commit object referencing zero or more parents. + * An empty `parents` array creates a root commit. */ + createCommit(opts: { + message: string; + treeSha: string; + parents: string[]; + author?: { name: string; email: string }; + }): Promise<{ sha: string }>; + + /** + * Move the branch ref to a new commit. Implementations MUST enforce the + * git fast-forward rule: the new commit must descend from the ref's + * current value (i.e. its parent chain reaches the current head). If + * not, throw {@link HeadConflictError} so the Repo engine can drive a + * pull-before-commit retry. This is how we get CAS without a separate + * expected-old-sha parameter — the commit's parent IS the expectation. + */ + updateBranch(commitSha: string): Promise; + + // ─── log ──────────────────────────────────────────────────────────────── + + /** Walk commits starting at `start` (defaults to branch HEAD), newest + * first, up to `limit`. */ + listCommits(opts?: { start?: string; limit?: number }): Promise; +} diff --git a/Playground/src/sharing/auth-ui.ts b/Playground/src/sharing/auth-ui.ts new file mode 100644 index 0000000..1c0936c --- /dev/null +++ b/Playground/src/sharing/auth-ui.ts @@ -0,0 +1,359 @@ +// Sign-in dialog — GitHub OAuth device flow. +// +// Replaces the previous PAT-paste dialog. The device flow is the only +// browser-safe path GitHub offers (web flow still requires +// `client_secret`); we route the two CORS-blocked endpoints through +// the stateless oauth-proxy worker. See ../../../oauth-proxy/README.md +// for the worker side. +// +// UX: +// 1. User clicks "Sign in with GitHub" → dialog opens, shows a +// spinner while we request a device code. +// 2. Dialog shows a short user_code ("WDJB-MJHT") + an "Open +// GitHub" button. Browser opens github.com/login/device in a new +// tab; user pastes the code; the dialog polls in the background. +// 3. When GitHub returns the token, dialog closes and the panel +// gets the TokenSet for storage. +// 4. Cancel button aborts polling and rejects with AbortError. +// +// The dialog itself: vanilla DOM, scoped CSS, resolves to the full +// TokenSet (access_token + refresh_token + expires_in). + +import { GITHUB_APP_CLIENT_ID, GITHUB_OAUTH_SCOPE } from './github-auth-config'; +import { + DeviceFlowError, + requestDeviceCode, + pollForToken, + type DeviceCodePrompt, + type TokenSet, +} from './github-auth'; + +const CSS_PREFIX = 'fade-auth'; +const STYLE_ID = `${CSS_PREFIX}-styles`; + +export interface SignInDialogOptions { + /** Override the initial explainer paragraph. */ + explainer?: string; + /** Injected for tests. */ + fetchImpl?: typeof fetch; + /** Injected for tests. */ + sleepImpl?: (ms: number) => Promise; + /** Override the App's client_id (defaults to the config module). + * Useful for tests; production uses GITHUB_APP_CLIENT_ID. */ + clientId?: string; +} + +/** + * Opens the modal device-flow dialog. Resolves with a TokenSet when + * the user finishes authorizing; rejects with: + * - `DOMException('canceled', 'AbortError')` if the user clicks Cancel. + * - `DeviceFlowError` for explicit denial / expiry / config errors. + * - `Error` for network failures fetching the device code. + * + * The dialog stays open on a recoverable failure so the user can retry + * without losing the dialog state. + */ +export function openSignInDialog(opts: SignInDialogOptions = {}): Promise { + return new Promise((resolve, reject) => { + injectStylesOnce(); + + const clientId = opts.clientId ?? GITHUB_APP_CLIENT_ID; + const abortController = new AbortController(); + + const overlay = el('div', `${CSS_PREFIX}-overlay`); + const panel = el('div', `${CSS_PREFIX}-panel`); + overlay.appendChild(panel); + + function close() { + overlay.remove(); + } + function fail(err: unknown) { + abortController.abort(); + close(); + reject(err); + } + function done(tokenSet: TokenSet) { + close(); + resolve(tokenSet); + } + + // Stage 1: requesting device code from GitHub (via proxy). Shows + // a spinner. Quick — usually <500ms. + const stage1 = el('div', `${CSS_PREFIX}-stage`); + stage1.append( + heading('Sign in with GitHub'), + p(opts.explainer ?? 'You\'ll get a short code and a link to github.com. Paste the code there, authorize the app, and you\'re back — no token paperwork.'), + row(spinner(), spanText('Requesting a device code…')), + ); + const cancelStage1 = button('Cancel', 'ghost', + () => fail(new DOMException('canceled', 'AbortError'))); + stage1.append(row(cancelStage1)); + + panel.append(stage1); + document.body.appendChild(overlay); + + // Kick off the device-code request immediately. If it fails + // hard (network, proxy misconfig), surface an error inside the + // dialog and offer Retry. + void (async () => { + let prompt: DeviceCodePrompt; + try { + prompt = await requestDeviceCode({ + clientId, + // GITHUB_OAUTH_SCOPE is 'repo' for OAuth Apps; + // empty for GitHub Apps (which ignore scope). + // Empty-string scopes are stripped in + // requestDeviceCode so we don't post `scope: ''`. + scope: GITHUB_OAUTH_SCOPE || undefined, + fetchImpl: opts.fetchImpl, + }); + } catch (e) { + showRequestError(e); + return; + } + // Replace stage1 with stage2 (code + open button + spinner). + panel.replaceChildren(); + panel.append(buildStage2(prompt)); + startPolling(prompt); + })(); + + function showRequestError(err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + const retryBtn = button('Retry', 'primary', () => { + // Re-open. Simplest path; preserves the device-flow + // promise without complex state machines. + close(); + openSignInDialog(opts).then(resolve, reject); + }); + const cancelBtn = button('Cancel', 'ghost', + () => fail(new DOMException('canceled', 'AbortError'))); + panel.replaceChildren( + heading('Couldn\'t reach the OAuth proxy'), + p(`Failed to fetch a device code. The proxy worker may be down, or your network is blocking it. Details: ${msg}`), + row(retryBtn, cancelBtn), + ); + } + + function buildStage2(prompt: DeviceCodePrompt): HTMLElement { + const wrap = el('div', `${CSS_PREFIX}-stage`); + + const codeBox = el('div', `${CSS_PREFIX}-codebox`); + codeBox.textContent = prompt.userCode; + codeBox.title = 'Click to copy'; + codeBox.addEventListener('click', () => { + void navigator.clipboard?.writeText(prompt.userCode); + codeBox.classList.add(`${CSS_PREFIX}-codebox-copied`); + setTimeout(() => codeBox.classList.remove(`${CSS_PREFIX}-codebox-copied`), 600); + }); + + const verifyUrl = prompt.verificationUriComplete ?? prompt.verificationUri; + const openBtn = button('Open GitHub →', 'primary', () => { + window.open(verifyUrl, '_blank', 'noopener,noreferrer'); + }); + const cancelBtn = button('Cancel', 'ghost', + () => fail(new DOMException('canceled', 'AbortError'))); + + const statusLine = el('div', `${CSS_PREFIX}-status`); + statusLine.append(spinner(), spanText('Waiting for you to authorize…')); + + const errorLine = el('p', `${CSS_PREFIX}-p ${CSS_PREFIX}-p-err`); + errorLine.style.display = 'none'; + errorLine.id = `${CSS_PREFIX}-stage2-error`; + + wrap.append( + heading('Authorize on github.com'), + p('1. Click "Open GitHub" — it loads github.com/login/device in a new tab.'), + p('2. Paste this code:'), + codeBox, + p('3. Confirm the app on the GitHub page. Come back here when you\'re done — we\'ll detect it automatically.'), + row(openBtn, cancelBtn), + statusLine, + errorLine, + ); + return wrap; + } + + function startPolling(prompt: DeviceCodePrompt) { + void (async () => { + try { + const tokenSet = await pollForToken({ + clientId, + deviceCode: prompt.deviceCode, + interval: prompt.interval, + fetchImpl: opts.fetchImpl, + sleepImpl: opts.sleepImpl, + signal: abortController.signal, + }); + done(tokenSet); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') { + // Cancel button already called fail() — nothing + // to do here. + return; + } + handlePollError(e); + } + })(); + } + + function handlePollError(err: unknown) { + // DeviceFlowErrors are recoverable in some cases: + // - access_denied → terminal, user said no. + // - expired_token → recoverable, offer a fresh code. + // - unsupported_grant_type → config error on the App; + // terminal from the user's POV. + // - incorrect_client_credentials → config error; terminal. + // - bad_refresh_token → can't happen in this path (no + // refresh attempted yet); show as unknown. + // - unknown → show details; offer retry. + const msg = err instanceof Error ? err.message : String(err); + if (err instanceof DeviceFlowError && err.code === 'expired_token') { + // Auto-restart with a fresh code. + close(); + openSignInDialog(opts).then(resolve, reject); + return; + } + const retryBtn = button('Start over', 'primary', () => { + close(); + openSignInDialog(opts).then(resolve, reject); + }); + const cancelBtn = button('Cancel', 'ghost', + () => fail(err)); + const cause = err instanceof DeviceFlowError ? err.code : 'error'; + panel.replaceChildren( + heading('Sign-in failed'), + p(`${cause}: ${msg}`), + row(retryBtn, cancelBtn), + ); + } + }); +} + +// ─── DOM helpers ──────────────────────────────────────────────────────────── + +function el(tag: K, className: string): HTMLElementTagNameMap[K] { + const n = document.createElement(tag); + n.className = className; + return n; +} + +function heading(text: string): HTMLElement { + const h = el('h2', `${CSS_PREFIX}-h`); + h.textContent = text; + return h; +} + +function p(text: string): HTMLElement { + const n = el('p', `${CSS_PREFIX}-p`); + n.textContent = text; + return n; +} + +function row(...children: HTMLElement[]): HTMLElement { + const r = el('div', `${CSS_PREFIX}-row`); + for (const c of children) r.appendChild(c); + return r; +} + +function spanText(text: string): HTMLSpanElement { + const s = document.createElement('span'); + s.textContent = text; + return s; +} + +function button(text: string, variant: 'primary' | 'ghost', onClick: () => void): HTMLButtonElement { + const b = document.createElement('button'); + b.className = `${CSS_PREFIX}-btn ${CSS_PREFIX}-btn-${variant}`; + b.textContent = text; + b.type = 'button'; + b.onclick = onClick; + return b; +} + +function spinner(): HTMLElement { + const s = el('span', `${CSS_PREFIX}-spinner`); + s.setAttribute('aria-hidden', 'true'); + return s; +} + +// ─── styles (injected once into ) ───────────────────────────────────── + +function injectStylesOnce(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` +.${CSS_PREFIX}-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.45); + display: flex; align-items: center; justify-content: center; + z-index: 99999; + font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; + color: inherit; +} +.${CSS_PREFIX}-panel { + background: var(--vscode-editor-background, #1e1e1e); + color: var(--vscode-foreground, #ddd); + border: 1px solid var(--vscode-panel-border, #444); + border-radius: 8px; + padding: 24px 28px; + max-width: 520px; width: calc(100% - 32px); + box-shadow: 0 12px 36px rgba(0,0,0,0.5); +} +.${CSS_PREFIX}-stage { + display: flex; flex-direction: column; gap: 12px; +} +.${CSS_PREFIX}-h { font-size: 18px; font-weight: 600; margin: 0 0 4px; } +.${CSS_PREFIX}-p { margin: 0; opacity: 0.9; } +.${CSS_PREFIX}-p-err { color: #e88; } +.${CSS_PREFIX}-codebox { + font: 22px/1.2 ui-monospace, monospace; + font-weight: 700; + letter-spacing: 0.12em; + text-align: center; + padding: 12px 16px; + border: 1px dashed var(--vscode-panel-border, #555); + border-radius: 6px; + background: rgba(255,255,255,0.04); + cursor: pointer; + user-select: all; + transition: background 0.15s; +} +.${CSS_PREFIX}-codebox:hover { background: rgba(255,255,255,0.07); } +.${CSS_PREFIX}-codebox-copied { + background: rgba(120, 220, 120, 0.18) !important; + border-color: rgba(120, 220, 120, 0.5); +} +.${CSS_PREFIX}-status { + display: flex; align-items: center; gap: 10px; opacity: 0.85; + font-size: 13px; + margin-top: 4px; +} +.${CSS_PREFIX}-spinner { + width: 14px; height: 14px; border-radius: 50%; + border: 2px solid currentColor; border-right-color: transparent; + animation: ${CSS_PREFIX}-spin 0.8s linear infinite; + display: inline-block; +} +@keyframes ${CSS_PREFIX}-spin { to { transform: rotate(360deg); } } +.${CSS_PREFIX}-row { display: flex; gap: 8px; flex-wrap: wrap; } +.${CSS_PREFIX}-btn { + appearance: none; border: 0; cursor: pointer; + padding: 8px 14px; border-radius: 6px; + font: inherit; font-weight: 500; + transition: filter 0.1s; +} +.${CSS_PREFIX}-btn:hover { filter: brightness(1.15); } +.${CSS_PREFIX}-btn:disabled { opacity: 0.5; cursor: not-allowed; filter: none; } +.${CSS_PREFIX}-btn-primary { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); +} +.${CSS_PREFIX}-btn-ghost { + background: transparent; + color: inherit; + border: 1px solid var(--vscode-panel-border, #555); +} +`; + document.head.appendChild(style); +} diff --git a/Playground/src/sharing/collaboration-panel.ts b/Playground/src/sharing/collaboration-panel.ts new file mode 100644 index 0000000..b80edc1 --- /dev/null +++ b/Playground/src/sharing/collaboration-panel.ts @@ -0,0 +1,2847 @@ +// The Collaboration dockview panel — every share/save/publish/pull +// affordance for the active workspace. +// +// State machine (rendered top-to-bottom): +// +// 1. Not signed in → "Sign in to GitHub" button (opens PAT dialog) +// 2. Signed in, no repo → "Publish to GitHub" + "Connect to existing repo" +// 3. Connected → repo link, unsaved changes, save action, +// publish action + preview, conflicts, pull box +// +// Lives in a single DOM root. `mountCollaboration(...)` returns a small +// controller the host can use to: +// - tell the panel which project is active (project switches) +// - tell the panel to re-read the working tree (after edits, autosave flush) +// - subscribe to status changes for file-list badging +// +// All state is held in module-level closure vars per instance — one panel per +// playground window, like every other dockview component here. + +import { GitHubAdapter, GitHubApiError, type CreateRepoOptions } from './github-adapter'; +import { Repo, type ProgressFn, type ProgressEvent } from './repo'; +import { getLogger } from '../log-bus'; +import { OpfsWorkingTree, isHiddenFromCommits, type OpfsWorkspaceLike } from './opfs-working-tree'; +import { gitBlobSha } from './hash'; +import { diffGitTrees, type GitTree, type TreeDiff } from './git-types'; +import { diff3Merge, hasConflictMarkers } from './diff3'; +import { + isConnected, + loadSyncIndex, + saveSyncIndex, + type ProjectSyncIndex, +} from './sync-index'; +import { computeStatus, HashCache, statusGlyph, type FileStatus, type FileStatusEntry } from './file-status'; +import { clearSaves, createSave, dropSave, loadSaves, migrateLegacyLocalStorageSaves, revertToSave, upgradeSave, type LocalSave } from './local-saves'; +import { openSignInDialog } from './auth-ui'; +import { + refreshAccessToken, + validateToken, + type ValidatedToken, +} from './github-auth'; +import { GITHUB_APP_CLIENT_ID } from './github-auth-config'; +import { + SessionTokenStore, + isAccessExpired, + isRefreshUsable, + tokenSetToStored, +} from './token-store'; +import { HeadConflictError } from './adapter'; + +const CSS_PREFIX = 'fade-collab'; +const STYLE_ID = `${CSS_PREFIX}-styles`; + +export interface SharingCommitInfo { + /** Git commit SHA. */ + id: string; + /** First-parent SHA, or null for the root commit. */ + parent: string | null; + message: string; + author: string; + /** ISO-8601 timestamp string from the git commit object. */ + time: string; +} + +export interface CollaborationController { + /** Tell the panel which project is now active. Triggers a re-render and a status refresh. */ + setActiveProject(projectName: string): void; + /** Re-read the working tree and refresh staged-changes / badges. Hits + * the per-path hash cache, so files that haven't been invalidated + * since the last refresh are essentially free. */ + refreshStatus(): Promise; + /** Lightweight refresh for the autosave hot path: invalidates the + * cached hash for one path so the next pass re-reads only that file. + * Use when an external writer (autosave, restore-from-tab, etc.) + * modified the file outside the panel's own actions. */ + refreshStatusForFile(path: string): Promise; + /** Drop the cached hash for one path WITHOUT triggering a refresh. + * Used by `flushPendingSaves` so a single bulk flush doesn't fan out + * into N status passes — the immediate next `refreshStatus` (run by + * doSave/doPublish/etc.) picks up the fresh bytes. */ + invalidateHashFor(path: string): void; + /** Host tells us whether there are dirty Monaco buffers waiting on + * the debounced autosave. Used by the Save button to enable + * instantly when the user types — without this it would stay greyed + * out for ~600 ms because `staged` is computed from OPFS bytes that + * haven't been written yet. */ + setHasDirtyTabs(hasDirty: boolean): void; + /** Subscribe to status changes — fires whenever the per-file status map updates. */ + onStatusChange(listener: (map: Map) => void): () => void; + /** Current per-file status map. Always non-null; empty when not connected. */ + getStatusMap(): Map; + /** Subscribe to changes in the "remote has changes for these paths" set. + * Fires whenever polling detects the branch moved and computes which + * paths differ from what we last synced. */ + onPendingPullChange(listener: (paths: Set) => void): () => void; + /** Paths whose remote head differs from our last synced state. Empty + * when up-to-date. */ + getPendingPullPaths(): Set; + /** Subscribe to local-save changes — fires whenever the saves chain + * is updated (save / drop / clear / publish). Used by the History + * panel to render the saves section. */ + onSavesChange(listener: (saves: LocalSave[]) => void): () => void; + /** Current pending-saves snapshot, newest first. */ + getPendingSaves(): LocalSave[]; + /** Drop a save by id (delegates to the local-saves store). */ + dropLocalSave(id: string): Promise; + /** Revert the working tree to a save by id (delegates internally). */ + revertToLocalSave(id: string): Promise; + /** File-level diff between a save and its predecessor (or baseTree if + * it's the oldest save). Returns null when the save can't be found. */ + getSaveDiff(id: string): Promise; + /** Subscribe to changes in the conflict state — fires whenever the + * text-marker set or binary-conflict-copy set changes. */ + onConflictChange(listener: (state: { text: Set; binary: Set }) => void): () => void; + /** Paths currently in conflict — split into text (file has diff3 + * markers) and binary (sibling `.fade-conflict.` copy exists). */ + getConflictPaths(): { text: Set; binary: Set }; + /** Open the dedicated conflict-resolution editor for `path`. Returns + * false if the host didn't wire `onOpenConflict`. */ + openConflictEditor(path: string): boolean; + /** Open a read-only diff tab for `path` in the given context. + * Fetches the appropriate before/after texts itself (caller just + * passes context + path). Returns false if the host didn't wire + * `onOpenDiff` or the context can't be satisfied (e.g. requesting + * a save diff for an id that no longer exists). + * + * - `unsaved`: latest save (or published baseTree, if no saves) + * → working tree. "What have I changed since my + * last save?" — the diff that drives the orange + * gutter and the "unsaved" chip. + * - `publish`: published baseTree → working tree. What Publish + * would push. + * - `save`: predecessor save (or baseTree) → this save. + * - `commit`: parent commit → this commit. + * - `pull`: working tree → remote HEAD. A "what's coming if I + * click Pull" preview that bundles every pending + * remote commit since our last sync (the engine + * fast-forwards to remote HEAD in one shot, so the + * preview matches what actually lands). */ + openDiffViewer(args: + | { kind: 'unsaved'; path: string } + | { kind: 'publish'; path: string } + | { kind: 'save'; saveId: string; path: string } + | { kind: 'commit'; commitSha: string; path: string } + | { kind: 'pull'; path: string } + ): Promise; + /** Return the base (last-synced) text content of a path, or null if the + * path has no base (file was added since last sync) or the workspace + * isn't connected. Used by the gutter decorator. + * + * Deprecated alias for `getPublishedText` — kept so existing callers + * (the gutter pre-tri-state) don't break. New code should use the + * paired `getSavedText` / `getPublishedText` for the tri-state diff. */ + getBaseText(path: string): Promise; + /** Text content of `path` at the user's latest local save, or null if + * the path isn't in any save. Used by the gutter to compute the + * "unsaved changes" diff (current vs latest save). */ + getSavedText(path: string): Promise; + /** Text content of `path` at the last published commit, or null if + * the path isn't on the remote yet. Used by the gutter to compute + * the "saved-but-not-yet-published" diff (latest save vs published). */ + getPublishedText(path: string): Promise; + + // ─── history surface (consumed by the history dockview panel) ────────── + + /** Current recent-commits list. Returns a copy. */ + getRecentCommits(): SharingCommitInfo[]; + /** Fire whenever recentCommits changes (after pull/commit/restore/connect). */ + onHistoryChange(listener: (commits: SharingCommitInfo[]) => void): () => void; + /** Per-commit file diff against its parent. Cached. Returns null if + * the commit isn't available or not connected. */ + getCommitDiff(sha: string): Promise; + /** Restore the working tree to a commit's contents and commit the + * result as a NEW commit on top of HEAD (no history rewrite). */ + restoreCommit(targetSha: string): Promise; + /** Owner / name / branch for the currently bound repo, or null. */ + getRepoInfo(): { owner: string; name: string; branch: string } | null; +} + +export interface CollaborationOptions { + container: HTMLElement; + workspace: OpfsWorkspaceLike; + /** Resolves to the active project name; called whenever the panel needs to consult it. */ + getActiveProject: () => string; + /** Force any pending OPFS autosaves to land before snapshotting for commit. */ + flushPendingSaves?: () => Promise; + /** Optional: hook for when the user pulls remote changes — host may need to refresh open editors. */ + onAfterPull?: (changedPaths: string[]) => void | Promise; + /** Optional: open the conflict-resolution editor for a path. The host + * registers a dynamic dockview panel and calls `mountConflictEditor` + * inside it. Without this hook the panel falls back to inline-only + * resolution via the legacy "Mark resolved" button. */ + onOpenConflict?: (path: string) => void | Promise; + /** Optional: open a read-only diff tab for a file. Used by "Show + * diff" buttons in the publish preview and history panels. The + * host owns dockview registration; the panel just hands over the + * pre-fetched before/after strings + display labels. */ + onOpenDiff?: (args: { + id: string; + title: string; + path: string; + languageId: string; + beforeText: string | null; + afterText: string | null; + beforeLabel?: string; + afterLabel?: string; + }) => void; +} + +export function mountCollaboration(opts: CollaborationOptions): CollaborationController { + injectStylesOnce(); + + const tokenStore = new SessionTokenStore(); + + // ─── state ────────────────────────────────────────────────────────────── + let activeProject = opts.getActiveProject(); + let user: ValidatedToken | null = null; // null until we validate the stored token + let index: ProjectSyncIndex = loadSyncIndex(activeProject); + // Last branch head we observed from polling. When this differs from + // `index.syncedCommitSha`, the remote has new commits and the Pull + // button gets a "new upstream" affordance. + let remoteHeadSha: string | null = null; + /** Paths whose remote-head version differs from `baseTree[path]`. + * Populated by the poll loop when it sees a new remote head and + * fetches the new tree to diff against. Cleared after pull/commit. */ + let pendingPullPaths: Set = new Set(); + const pendingPullListeners = new Set<(paths: Set) => void>(); + function emitPendingPull() { + const snap = new Set(pendingPullPaths); + for (const l of pendingPullListeners) { + try { l(snap); } catch (e) { console.error('[sharing] pendingPull listener threw', e); } + } + } + let pollTimer: number | null = null; + const POLL_INTERVAL_MS = 30_000; + + // History-related caches. `commitTreeCache` and `commitDiffCache` + // short-circuit re-fetches when the history panel toggles a commit + // open/closed repeatedly. Expanded-state lives in the history panel + // itself (per-panel UI state, not shared). + const commitTreeCache = new Map(); + const commitDiffCache = new Map(); + + // Per-panel logger — every action this module fires (commit, pull, + // conflict-resolve, restore) emits structured entries that the Logs + // dockview panel surfaces. Channel 'sharing' so filters can isolate. + const log = getLogger('sharing'); + + /** Path → git-blob-sha cache. Bypasses re-hashing every file on every + * autosave (the previous hot path). Invalidated by every workspace + * write the panel knows about + by `refreshStatusForFile` when the + * host (autosave) tells us about an external write. */ + const hashCache = new HashCache(); + + /** Workspace-write helpers — every internal write to OPFS must go + * through these so the hash cache stays coherent. External writes + * (autosave from the editor tab) come through + * `refreshStatusForFile` which invalidates separately. */ + async function writeFile(path: string, bytes: Uint8Array): Promise { + await opts.workspace.writeBytes(path, bytes); + hashCache.invalidate(path); + } + async function deleteFile(path: string): Promise { + await opts.workspace.delete(path); + hashCache.invalidate(path); + } + + /** Host-supplied "any Monaco buffer dirty?" flag. Edits set this + * true on keystroke (before autosave fires); the panel ORs it with + * `hasUnsaved` so Save enables immediately. */ + let hasDirtyTabs = false; + let staged: FileStatusEntry[] = []; + /** Per-file diff between the current working tree and the *published* + * baseTree — i.e. the set of changes a Publish would roll up. Different + * from `staged`, which compares against the latest local save. Computed + * in `refreshStatus`; cheap because it shares the same HashCache. */ + let publishStaged: FileStatusEntry[] = []; + /** Persisted save-message draft. Survives panel re-renders so the + * user doesn't lose what they typed when a status refresh fires. */ + let saveMessageDraft = ''; + let publishMessageDraft = ''; + /** Cached list of local saves for the active project (newest first). + * Re-read from localStorage by `refreshSaves()`. */ + let pendingSaves: LocalSave[] = []; + const savesListeners = new Set<(saves: LocalSave[]) => void>(); + function emitSaves(): void { + const snap = [...pendingSaves]; + for (const l of savesListeners) { + try { l(snap); } catch (e) { console.error('[sharing] saves listener threw', e); } + } + } + /** Async-resolved load + upgrade pipeline. refreshSaves kicks off an + * OPFS read and (if needed) legacy treeHashes upgrade; consumers + * await this promise before reading `pendingSaves` if they need + * guaranteed-current data. */ + let savesUpgrade: Promise = Promise.resolve(); + function refreshSaves(): void { + savesUpgrade = (async () => { + pendingSaves = await loadSaves(activeProject); + emitSaves(); + const needsUpgrade = pendingSaves.some((s) => !s.treeHashes); + if (!needsUpgrade) return; + // Lazy-upgrade legacy saves (no treeHashes). Re-render after + // the batch so the unsaved chip flips correctly once hashes + // land. + for (let i = 0; i < pendingSaves.length; i++) { + if (pendingSaves[i].treeHashes) continue; + pendingSaves[i] = await upgradeSave(pendingSaves[i]); + } + emitSaves(); + render(); + })(); + } + + /** Reference tree for the "unsaved" comparison: the latest local + * save's tree if any exist, otherwise the published baseTree. + * + * Concretely: "unsaved changes" means "differs from my latest + * checkpoint." After clicking Save, the working tree matches the + * save → no unsaved diff. The saves-vs-published gap shows up as + * the separate "unpublished" chip. */ + function referenceTree(): Record { + if (pendingSaves.length > 0 && pendingSaves[0].treeHashes) { + return pendingSaves[0].treeHashes; + } + return index.baseTree; + } + /** Conflict-copy files in the workspace ('.fade-conflict.'), + * populated by refreshStatus from the unfiltered workspace listing. + * Only binary files end up here now — text files get diff3-merged + * with markers written in-place. */ + let conflictFiles: string[] = []; + /** Text files that contain `<<<<<<< / ======= / >>>>>>>` markers and + * must be resolved before the next Publish. Populated by + * `mergeFromRemote` (when diff3 leaves markers) and re-detected on + * every refreshStatus scan so reloads can pick conflicts back up. */ + let textConflicts: Set = new Set(); + const conflictListeners = new Set<(state: { text: Set; binary: Set }) => void>(); + function emitConflicts() { + const snap = { + text: new Set(textConflicts), + binary: new Set(conflictFiles), + }; + for (const l of conflictListeners) { + try { l(snap); } catch (e) { console.error('[sharing] conflict listener threw', e); } + } + } + let recentCommits: SharingCommitInfo[] = []; + const historyListeners = new Set<(commits: SharingCommitInfo[]) => void>(); + function emitHistory() { + const snap = [...recentCommits]; + for (const l of historyListeners) { + try { l(snap); } catch (e) { console.error('[sharing] history listener threw', e); } + } + } + /** Current long-running operation's user-facing status, if any. The + * label updates as the engine emits progress events; the optional + * progress field drives the inline bar. */ + let busy: { label: string; progress?: { current: number; total: number } } | null = null; + /** Hard failures: thrown exceptions, validation errors, "Not + * connected" guards. Shows in a red banner. Cleared explicitly on + * retry or replaced by a new error. */ + let errorBanner: string | null = null; + /** Transient status from a multi-step flow: "auto-merged N files, + * click Publish to push", "Pull once more before Publishing", etc. + * Shows in a neutral banner. Auto-clears when the next-action + * state changes (e.g. all conflicts resolved, or a fresh runBusy + * begins). Never used for errors. */ + let infoBanner: string | null = null; + + // Hash → bytes cache for base content. Blobs are content-addressed so + // dedup is automatic across paths. Unbounded for now — typical playground + // projects fit in tens of MB. + const baseContentCache = new Map(); + + const statusListeners = new Set<(m: Map) => void>(); + function statusMapFromStaged(): Map { + const m = new Map(); + for (const e of staged) m.set(e.path, e.status); + return m; + } + function emitStatus() { + const m = statusMapFromStaged(); + for (const l of statusListeners) l(m); + } + + // ─── adapter / repo (lazy, rebuilt whenever the binding changes) ─────── + // buildRepo is async because it may transparently refresh an + // expired access token before producing an adapter. Without this, + // a long-running session (>8h default) silently starts hitting 401s + // on every API call. + async function buildRepo(): Promise<{ adapter: GitHubAdapter; repo: Repo } | null> { + if (!index.remoteRepo) return null; + const accessToken = await ensureFreshAccessToken(); + if (!accessToken) return null; + const adapter = GitHubAdapter.open({ + owner: index.remoteRepo.owner, + repo: index.remoteRepo.name, + branch: index.remoteRepo.branch, + token: accessToken, + }); + const repo = new Repo(adapter); + // Rehydrate the engine's synced state from our persisted sync-index + // so a freshly-built Repo doesn't see itself as "nothing synced." The + // engine uses syncedHead.treeSha as `base_tree` on createTree (sends + // only the diff, not the full tree) and syncedTree to detect when a + // working-tree change should mark a file as modified. + if (index.syncedCommitSha && index.syncedTreeSha) { + const tree: GitTree = {}; + for (const [path, blobSha] of Object.entries(index.baseTree)) { + tree[path] = { blobSha }; + } + repo.setSyncedHead( + { commitSha: index.syncedCommitSha, treeSha: index.syncedTreeSha }, + tree, + ); + } + return { adapter, repo }; + } + + /** Load the stored TokenSet, refresh the access token if it's near + * expiry, persist the result, and return a usable access token. + * Returns null when: + * - no token stored (user never signed in) + * - access expired AND no usable refresh token (user has to + * sign in again — token store is cleared) + * - refresh request failed (treated the same as no refresh) + * Callers should treat null as "not authenticated"; the next + * user-initiated action will show the sign-in dialog. */ + async function ensureFreshAccessToken(): Promise { + const stored = tokenStore.load(); + if (!stored) return null; + if (!isAccessExpired(stored)) return stored.accessToken; + if (!isRefreshUsable(stored)) { + // Access expired AND we can't refresh — wipe so the panel's + // signed-in indicator updates and the user gets prompted. + tokenStore.clear(); + user = null; + return null; + } + try { + const fresh = await refreshAccessToken({ + clientId: GITHUB_APP_CLIENT_ID, + refreshToken: stored.refreshToken!, + }); + const updated = tokenSetToStored(fresh); + tokenStore.save(updated); + log.info('Refreshed GitHub access token.'); + return updated.accessToken; + } catch (e) { + log.warn(`Token refresh failed — clearing stored credentials: ${errMsg(e)}`); + tokenStore.clear(); + user = null; + return null; + } + } + + // ─── upstream polling ─────────────────────────────────────────────────── + + /** One poll tick — checks remote head and, if changed, refreshes + * pendingPullPaths. Pulled out so both the background timer and the + * manual "Check now" button can fire it. `force=true` bypasses the + * hidden-tab skip; callers that are user-initiated should pass true. + */ + let checkingRemote = false; + async function checkRemote(force: boolean): Promise { + if (!user || !isConnected(index)) return; + if (!force && typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + if (checkingRemote) return; // single-flight + // Skip polling while a long-running op is mid-flight. The "phantom + // pull after publish" bug was this race: commit() updates the + // remote branch ref, then yields for getCommit(); a poll that + // fires during that yield sees the new remote SHA but our local + // index.syncedCommitSha is still the pre-publish one, so it + // populates pendingPullPaths against the stale baseTree — i.e. + // with our own just-published commit's paths. persistSyncedFrom + // later clears it, but the chip flickers on for the user. + if (!force && busy !== null) return; + checkingRemote = true; + try { + const built = await buildRepo(); + if (!built) return; + const sha = await built.adapter.branchHead(); + if (sha === remoteHeadSha) { + // Even when sha hasn't changed, the user might want feedback + // that the check ran (manual click). Re-render either way. + if (force) render(); + return; + } + remoteHeadSha = sha; + const ahead = sha !== null && sha !== index.syncedCommitSha; + if (ahead) { + try { + const remoteTree = await built.adapter.getTree(sha!); + const next = new Set(); + const baseTree = index.baseTree; + for (const [path, entry] of Object.entries(remoteTree)) { + if (baseTree[path] !== entry.blobSha) next.add(path); + } + for (const path of Object.keys(baseTree)) { + if (!(path in remoteTree)) next.add(path); + } + pendingPullPaths = next; + } catch (e) { + log.warn(`Could not fetch remote tree for pending-pull diff: ${errMsg(e)}`); + pendingPullPaths = new Set(); + } + } else { + pendingPullPaths = new Set(); + } + emitPendingPull(); + render(); + } catch (e) { + console.warn('[sharing] poll failed', e); + if (force) log.warn(`Check-now failed: ${errMsg(e)}`); + } finally { + checkingRemote = false; + } + } + + /** Start a background poll. Idempotent — calling while a timer is + * running stops the old one first. */ + function startPolling() { + stopPolling(); + if (!user || !isConnected(index)) return; + pollTimer = window.setInterval(() => { void checkRemote(false); }, POLL_INTERVAL_MS); + // Fire immediately so the "ahead" indicator appears without waiting + // a full interval after sign-in. + void checkRemote(false); + } + + function stopPolling() { + if (pollTimer !== null) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + // The tab might be backgrounded for hours — re-fire a poll as soon as + // we're visible again so the "ahead" indicator isn't stale. + const visibilityHandler = () => { + if (typeof document === 'undefined') return; + if (document.visibilityState === 'visible' && pollTimer !== null) { + void checkRemote(true); + } + }; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', visibilityHandler); + } + + /** After any flow that changes the synced commit (commit, pull, clone), + * push the new state back into the sync-index. Centralizes the + * field-mapping so we don't duplicate it across doCommit / pull / + * mergeFromRemote / connectExisting. Also clears the polled-remote + * indicator since local just caught up. */ + function persistSyncedFrom(repo: Repo) { + const synced = repo.getSyncedHead(); + if (!synced) { + index.syncedCommitSha = null; + index.syncedTreeSha = null; + index.baseTree = {}; + } else { + index.syncedCommitSha = synced.commitSha; + index.syncedTreeSha = synced.treeSha; + index.baseTree = repo.syncedTreeToBlobShas(); + // Local caught up to (or moved past) what the poll last saw. + // Mirror that so the "ahead" indicator turns off immediately. + remoteHeadSha = synced.commitSha; + if (pendingPullPaths.size > 0) { + pendingPullPaths = new Set(); + emitPendingPull(); + } + } + saveSyncIndex(activeProject, index); + } + + // ─── render ───────────────────────────────────────────────────────────── + function render() { + opts.container.replaceChildren(); + opts.container.classList.add(`${CSS_PREFIX}-root`); + + // Header — sign-in + active project context. + const header = el('div', `${CSS_PREFIX}-header`); + const who = el('div', `${CSS_PREFIX}-who`); + if (user) { + who.append(text(`@${user.login}`), el('span', `${CSS_PREFIX}-dim`, ` · project: ${activeProject}`)); + } else { + who.append(text('not signed in'), el('span', `${CSS_PREFIX}-dim`, ` · project: ${activeProject}`)); + } + const headerActions = el('div', `${CSS_PREFIX}-row`); + if (user) { + headerActions.append(button('Sign out', 'ghost-small', signOut)); + } else { + headerActions.append(button('Sign in', 'primary-small', signIn)); + } + header.append(who, headerActions); + opts.container.append(header); + + // Status chips live in the app header now (see main.ts — + // `mountSharingChips`). Keeping them out of the panel itself means + // the user can glance at sync state without focusing the + // Collaboration tab. + + // Body — depends on (signed in?) × (connected?) + const body = el('div', `${CSS_PREFIX}-body`); + + if (busy) { + body.append(renderBusyBanner(busy)); + } + if (errorBanner) { + body.append(banner(errorBanner, 'err')); + } + if (infoBanner) { + body.append(banner(infoBanner, 'info')); + } + + if (!user) { + body.append(p('Sign in with a GitHub Personal Access Token to publish this project, sync with collaborators, and back up your work.')); + } else if (!isConnected(index)) { + body.append(renderNotConnected()); + } else { + body.append(renderConnected()); + } + opts.container.append(body); + } + + function renderNotConnected(): HTMLElement { + const wrap = el('div', `${CSS_PREFIX}-section`); + wrap.append( + heading('Workspace not connected'), + p(`The "${activeProject}" project isn't backed by a GitHub repo yet. Pick one of:`), + row( + button('Publish to GitHub…', 'primary', publishNew), + button('Connect existing repo…', 'ghost', connectExisting), + ), + ); + return wrap; + } + + // Status chips moved to the app header — see main.ts + // `renderSharingChips`. The panel no longer owns this surface, but + // it still exposes the underlying state via getStatusMap / + // getPendingSaves / getPendingPullPaths / getConflictPaths so the + // header can paint its pills. + + function renderConnected(): HTMLElement { + const wrap = el('div', `${CSS_PREFIX}-section`); + const repo = index.remoteRepo!; + const link = document.createElement('a'); + link.href = `https://github.com/${repo.owner}/${repo.name}`; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = `${repo.owner}/${repo.name}`; + link.className = `${CSS_PREFIX}-link`; + + // Conflict banner sits ABOVE the connected-repo heading so it's + // impossible to miss. Always visible while any conflict exists, + // regardless of how the rest of the panel renders. + const totalConflicts = textConflicts.size + conflictFiles.length; + if (totalConflicts > 0) { + const firstText = [...textConflicts][0]; + const banner = el('div', `${CSS_PREFIX}-conflict-banner`); + const msg = el('div', `${CSS_PREFIX}-conflict-banner-msg`); + const parts: string[] = []; + if (textConflicts.size > 0) parts.push(`${textConflicts.size} text`); + if (conflictFiles.length > 0) parts.push(`${conflictFiles.length} binary`); + msg.textContent = `${totalConflicts} unresolved conflict${totalConflicts === 1 ? '' : 's'} (${parts.join(', ')}). Commits are blocked until resolved.`; + banner.append(msg); + if (firstText && opts.onOpenConflict) { + const openBtn = button('Open merge editor →', 'primary-small', + () => { void opts.onOpenConflict!(firstText); }); + banner.append(openBtn); + } + wrap.append(banner); + } + + // Repo header — link + branch + inline Disconnect. The old top-row + // of Pull/Refresh/Revert-all/Disconnect buttons is gone; Pull is + // surfaced contextually via the polling chip when ahead, the + // background poll covers Refresh, and Revert-all moved next to + // the unsaved-changes list it acts on. + const repoHeader = el('div', `${CSS_PREFIX}-repo-header`); + const repoLine = el('div', `${CSS_PREFIX}-repo-line`); + repoLine.append(link, el('span', `${CSS_PREFIX}-dim`, ` · branch ${repo.branch}`)); + // Check now — bypasses the 30s background poll. The background + // poll skips itself while a long-running op is in flight (to + // avoid the phantom-pull race during commit), so an explicit + // user click is the way to force an immediate refresh. + const checkNowBtn = button( + checkingRemote ? '⟳ Checking…' : '⟳ Check now', + 'ghost-small', + () => { void checkRemote(true); }, + ); + checkNowBtn.title = checkingRemote + ? 'A check is already in flight.' + : 'Ask the remote whether anything has changed since the last poll.'; + checkNowBtn.disabled = busy !== null || checkingRemote; + const disconnectBtn = button('Disconnect', 'ghost-small', disconnect); + disconnectBtn.title = 'Disconnect this project from the remote (local files kept).'; + if (busy !== null) disconnectBtn.disabled = true; + repoHeader.append(repoLine, checkNowBtn, disconnectBtn); + wrap.append(heading('Connected repo')); + wrap.append(repoHeader); + + const conflictBlocked = textConflicts.size > 0; + const changed = staged.filter((e) => e.status !== 'unchanged'); + // `hasUnsaved` reflects the OPFS-vs-save diff *plus* any + // not-yet-flushed Monaco edits. Without the dirty-tabs OR, the + // Save button stays greyed out for ~600 ms after each + // keystroke (until autosave fires + refreshStatus runs). + const hasUnsaved = changed.length > 0 || hasDirtyTabs; + const hasUnpublished = pendingSaves.length > 0; + const hasRemoteToPull = pendingPullPaths.size > 0; + const busyNow = busy !== null; + const isSaving = busyNow && busy!.label.startsWith('Saving'); + const isPublishing = busyNow && busy!.label.startsWith('Publishing'); + const isPulling = busyNow && busy!.label.startsWith('Pulling'); + + // ── Pull section (contextual) ────────────────────────────────── + // Renders only when the background poll has detected the remote + // moved past our last synced commit. The header chip + this + // button together are the only Pull surface — no permanent top- + // row button anymore. + if (hasRemoteToPull) { + const pullSection = el('div', `${CSS_PREFIX}-pullbox`); + // Header row: title + Pull button. + const pullHeadRow = el('div', `${CSS_PREFIX}-pullbox-headrow`); + const pullHead = el('div', `${CSS_PREFIX}-pullbox-h`); + pullHead.textContent = `↓ Remote has ${pendingPullPaths.size} change${pendingPullPaths.size === 1 ? '' : 's'} for you`; + const pullBtn = button( + isPulling ? '⟳ Pulling…' : 'Pull', + 'primary-small', + () => { void pull(); }, + ); + pullBtn.disabled = busyNow; + if (busyNow) pullBtn.title = 'Busy…'; + else pullBtn.title = hasUnsaved || hasUnpublished + ? 'Pull merges remote changes into your working tree (3-way merge against your local saves + edits).' + : 'Fast-forward to the latest remote commit.'; + pullHeadRow.append(pullHead, pullBtn); + pullSection.append(pullHeadRow); + + // Per-file list with Show-diff buttons. The diff is + // "working tree → remote HEAD"; multiple pending remote + // commits collapse into one fast-forward to remote HEAD, + // so the preview matches what Pull would actually land. + const pullList = el('ul', `${CSS_PREFIX}-pullbox-list`); + const sortedPullPaths = [...pendingPullPaths].sort(); + for (const path of sortedPullPaths) { + const li = document.createElement('li'); + li.className = `${CSS_PREFIX}-pullbox-row`; + const pathEl = el('span', `${CSS_PREFIX}-path`); + pathEl.textContent = path; + li.append(pathEl); + if (opts.onOpenDiff) { + const diffBtn = button('Show diff', 'revert-mini', () => { + void openDiffViewerImpl({ kind: 'pull', path }); + }); + diffBtn.title = `Preview ${path} (working tree → remote HEAD).`; + li.append(diffBtn); + } + pullList.append(li); + } + pullSection.append(pullList); + wrap.append(pullSection); + } + + // ── Unsaved changes section ────────────────────────────────────── + // Header row: title + Revert-all (only when there are changes). + // File list with per-row revert. + // Save action: button anchored to THIS section, since Save's + // target is the working-tree-vs-latest-save diff. + const unsavedHeader = el('div', `${CSS_PREFIX}-section-header`); + unsavedHeader.append(heading('Unsaved changes', 2)); + if (hasUnsaved) { + const revertAllBtn = button(`Revert all (${changed.length})`, 'ghost-small', () => { void revertAll(); }); + revertAllBtn.title = `Overwrite ${changed.length} changed file${changed.length === 1 ? '' : 's'} with the last-synced version.`; + revertAllBtn.disabled = busyNow; + unsavedHeader.append(revertAllBtn); + } + wrap.append(unsavedHeader); + + if (!hasUnsaved) { + wrap.append(p('Working tree matches the latest save.', 'dim')); + } else { + const list = el('ul', `${CSS_PREFIX}-stagedlist`); + for (const e of changed) { + const li = document.createElement('li'); + li.className = `${CSS_PREFIX}-staged ${CSS_PREFIX}-staged-${e.status}`; + const g = el('span', `${CSS_PREFIX}-glyph`); + g.textContent = statusGlyph(e.status); + const path = el('span', `${CSS_PREFIX}-path`); + path.textContent = e.path; + li.append(g, path); + // Show-diff lands the diff editor on the same reference + // the unsaved chip uses: latest save (or baseTree) vs + // working tree. Hidden when the host didn't wire + // onOpenDiff — keeps the row tight in that case. + if (opts.onOpenDiff) { + const diffBtn = button('Show diff', 'revert-mini', () => { + void openDiffViewerImpl({ kind: 'unsaved', path: e.path }); + }); + diffBtn.disabled = busyNow; + diffBtn.title = `Open a read-only diff of ${e.path} (latest save → working tree).`; + li.append(diffBtn); + } + const revertBtn = button('Revert', 'revert-mini', () => { void revertFile(e.path); }); + revertBtn.disabled = busyNow; + revertBtn.title = e.status === 'added' + ? `Delete ${e.path} (it has no base content)` + : `Overwrite ${e.path} with the last-synced version`; + li.append(revertBtn); + list.append(li); + } + wrap.append(list); + } + + // Save action — anchored to the unsaved-changes section. Quick + // checkpoint; the inline textbox holds an optional message. + const saveMsgInput = document.createElement('input'); + saveMsgInput.type = 'text'; + saveMsgInput.className = `${CSS_PREFIX}-msg-inline`; + saveMsgInput.placeholder = 'Optional save message…'; + saveMsgInput.value = saveMessageDraft; + saveMsgInput.disabled = busyNow || !hasUnsaved; + saveMsgInput.addEventListener('input', () => { saveMessageDraft = saveMsgInput.value; }); + + const saveBtn = button( + isSaving ? '⟳ Saving…' : 'Save', + 'primary-small', + async () => { + const typed = saveMsgInput.value.trim(); + const msg = typed || defaultSaveMessage(); + await runBusy('Saving…', async () => { await doSave(msg); }); + saveMessageDraft = ''; + render(); + }, + ); + const saveBlocked = !hasUnsaved || conflictBlocked || busyNow; + saveBtn.disabled = saveBlocked; + if (busyNow) saveBtn.title = 'Busy…'; + else if (!hasUnsaved) saveBtn.title = 'No changes to save.'; + else if (conflictBlocked) saveBtn.title = `Resolve ${textConflicts.size} text conflict${textConflicts.size > 1 ? 's' : ''} first.`; + else saveBtn.title = 'Snapshot the current working tree locally.'; + wrap.append(rowInline(saveMsgInput, saveBtn)); + + // ── Publish section ────────────────────────────────────────────── + // Show only when there are actual changes to push. Saves alone + // aren't a reason to display Publish — if the working tree + // matches the remote (e.g. user reverted away from a save), the + // engine's commit() throws "nothing to commit" and the click is + // a silent no-op. Hiding the section in that case keeps the + // affordance honest: visible == clickable == "will publish". + const publishChanged = publishStaged.filter((e) => e.status !== 'unchanged'); + const hasChangesToPublish = publishChanged.length > 0; + + if (hasUnpublished && hasChangesToPublish) { + wrap.append(heading('Publish', 2)); + + const pubMsgInput = document.createElement('input'); + pubMsgInput.type = 'text'; + pubMsgInput.className = `${CSS_PREFIX}-msg-inline`; + pubMsgInput.placeholder = 'Optional commit message…'; + pubMsgInput.value = publishMessageDraft; + pubMsgInput.disabled = busyNow; + pubMsgInput.addEventListener('input', () => { publishMessageDraft = pubMsgInput.value; }); + + const publishBtn = button( + isPublishing + ? '⟳ Publishing…' + : `Publish ${pendingSaves.length} save${pendingSaves.length === 1 ? '' : 's'}`, + 'primary', + async () => { + const msg = pubMsgInput.value.trim() || defaultPublishMessage(); + await runBusy('Publishing…', (progress) => doPublish(msg, progress)); + publishMessageDraft = ''; + }, + ); + // Pull-before-Publish: when the remote has new commits we + // haven't fetched, block Publish entirely. The user must + // click Pull explicitly so the merge result lands in their + // working tree (visible, inspectable, conflict-resolvable) + // before anything goes back to GitHub. Without this gate, + // commit() would still race-detect HeadConflictError and + // auto-merge — but only AFTER the user tried to push, and + // some users (rightly) want pull-then-publish to be an + // explicit two-step workflow. + const publishBlocked = !hasChangesToPublish || conflictBlocked || hasRemoteToPull || busyNow; + publishBtn.disabled = publishBlocked; + if (busyNow) publishBtn.title = 'Busy…'; + else if (hasRemoteToPull) publishBtn.title = `Remote has ${pendingPullPaths.size} change${pendingPullPaths.size === 1 ? '' : 's'} you haven't pulled. Click Pull first to merge them locally — then Publish.`; + else if (conflictBlocked) publishBtn.title = `Resolve ${textConflicts.size} text conflict${textConflicts.size > 1 ? 's' : ''} first.`; + else publishBtn.title = `Squash ${pendingSaves.length} local save${pendingSaves.length === 1 ? '' : 's'} into one commit on the remote.`; + + wrap.append(rowInline(pubMsgInput, publishBtn)); + + // Publish preview — what would actually land on the remote. + // Union of saved-but-not-yet-published changes and any + // uncommitted edits since the last save. + const addedN = publishChanged.filter((e) => e.status === 'added').length; + const modN = publishChanged.filter((e) => e.status === 'modified').length; + const delN = publishChanged.filter((e) => e.status === 'deleted').length; + const summary = el('div', `${CSS_PREFIX}-pubprev`); + const h = el('div', `${CSS_PREFIX}-pubprev-h`); + h.textContent = `Preview · ${publishChanged.length} file${publishChanged.length === 1 ? '' : 's'}`; + const counts = el('span', `${CSS_PREFIX}-pubprev-counts`); + const parts: string[] = []; + if (addedN) parts.push(`+${addedN}`); + if (modN) parts.push(`~${modN}`); + if (delN) parts.push(`-${delN}`); + counts.textContent = parts.join(' · '); + h.append(text(' '), counts); + summary.append(h); + const plist = el('ul', `${CSS_PREFIX}-pubprev-list`); + for (const e of publishChanged) { + const li = document.createElement('li'); + li.className = `${CSS_PREFIX}-pubprev-row ${CSS_PREFIX}-staged-${e.status}`; + const g = el('span', `${CSS_PREFIX}-glyph`); + g.textContent = statusGlyph(e.status); + const pathEl = el('span', `${CSS_PREFIX}-path`); + pathEl.textContent = e.path; + li.append(g, pathEl); + // Show-diff affordance — hidden when the host didn't + // wire onOpenDiff (older callers) so the row stays + // clean. Even deletions get a button — the diff editor + // renders one-sided changes cleanly. + if (opts.onOpenDiff) { + const diffBtn = button('Show diff', 'revert-mini', () => { + void openDiffViewerImpl({ kind: 'publish', path: e.path }); + }); + diffBtn.title = `Open a read-only diff of ${e.path} (published → working tree).`; + li.append(diffBtn); + } + plist.append(li); + } + summary.append(plist); + wrap.append(summary); + } else if (hasUnpublished && !hasChangesToPublish) { + // Edge case: user has saves but reverted the working tree + // back to the published state, so there's nothing to push. + // Surface this rather than disabling the button silently. + wrap.append(p( + `${pendingSaves.length} local save${pendingSaves.length === 1 ? '' : 's'} exist but the working tree matches the remote — nothing to publish. Drop saves from the History tab if you don't need them.`, + 'dim', + )); + } + + // Conflicts — split into two categories with different affordances. + // Text conflicts: diff3 left `<<<<<<< / ======= / >>>>>>>` markers + // in the live file. User edits in Monaco, then + // clicks Mark resolved. + // Binary conflicts: a `.fade-conflict.` copy was written. + // User picks Use mine / Use theirs. + const textConflictList = [...textConflicts].sort(); + if (textConflictList.length > 0) { + wrap.append(heading('Conflicts (text — merge in editor)', 2)); + wrap.append(p('These files have `<<<<<<<` / `=======` / `>>>>>>>` markers from the 3-way merge. Open each file, keep the lines you want, delete the markers, then click "Mark resolved". The commit button will stay disabled until every text conflict is resolved.', 'dim')); + const tList = el('ul', `${CSS_PREFIX}-stagedlist`); + for (const path of textConflictList) { + const li = document.createElement('li'); + li.className = `${CSS_PREFIX}-conflict`; + const top = el('div', `${CSS_PREFIX}-conflict-row`); + const g = el('span', `${CSS_PREFIX}-glyph`); + g.textContent = 'C'; + g.style.color = '#f88'; + const pathEl = el('span', `${CSS_PREFIX}-path`); + pathEl.textContent = path; + top.append(g, pathEl); + const actions = el('div', `${CSS_PREFIX}-row`); + if (opts.onOpenConflict) { + const openBtn = button('Resolve in editor →', 'primary-small', () => { void opts.onOpenConflict!(path); }); + const markBtn = button('Mark resolved', 'ghost-small', () => { void resolveTextConflict(path); }); + if (busyNow) { openBtn.disabled = true; markBtn.disabled = true; } + actions.append(openBtn, markBtn); + } else { + const markBtn = button('Mark resolved', 'primary-small', () => { void resolveTextConflict(path); }); + if (busyNow) markBtn.disabled = true; + actions.append(markBtn); + } + li.append(top, actions); + tList.append(li); + } + wrap.append(tList); + } + if (conflictFiles.length > 0) { + wrap.append(heading('Conflicts (binary — pick a side)', 2)); + wrap.append(p('Binary files can\'t be line-merged. We saved their remote version as a sibling `.fade-conflict.` copy. "Use mine" keeps your local edits; "Use theirs" overwrites your file with the remote version. Either way the conflict copy is deleted.', 'dim')); + const cList = el('ul', `${CSS_PREFIX}-stagedlist`); + for (const cf of conflictFiles) { + const original = cf.replace(/\.fade-conflict\.[a-f0-9]+$/, ''); + const li = document.createElement('li'); + li.className = `${CSS_PREFIX}-conflict`; + const top = el('div', `${CSS_PREFIX}-conflict-row`); + const g = el('span', `${CSS_PREFIX}-glyph`); + g.textContent = 'C'; + g.style.color = '#f88'; + const pathEl = el('span', `${CSS_PREFIX}-path`); + pathEl.textContent = original; + top.append(g, pathEl); + const actions = el('div', `${CSS_PREFIX}-row`); + const mineBtn = button('Use mine', 'ghost-small', () => { void resolveConflict(cf, 'mine'); }); + const theirsBtn = button('Use theirs', 'ghost-small', () => { void resolveConflict(cf, 'theirs'); }); + if (busyNow) { mineBtn.disabled = true; theirsBtn.disabled = true; } + actions.append(mineBtn, theirsBtn); + li.append(top, actions); + cList.append(li); + } + wrap.append(cList); + } + + return wrap; + } + + // ─── actions ──────────────────────────────────────────────────────────── + + async function signIn() { + errorBanner = null; + infoBanner = null; + try { + const tokenSet = await openSignInDialog(); + tokenStore.save(tokenSetToStored(tokenSet)); + user = await validateToken(tokenSet.accessToken); + await refreshStatus(); + await refreshHistory(); + startPolling(); + } catch (e) { + if (!(e instanceof DOMException && e.name === 'AbortError')) { + errorBanner = errMsg(e); + } + } + render(); + } + + function signOut() { + tokenStore.clear(); + user = null; + staged = []; + publishStaged = []; + recentCommits = []; + remoteHeadSha = null; + pendingPullPaths = new Set(); + emitPendingPull(); + stopPolling(); + emitStatus(); + render(); + } + + async function publishNew() { + if (!user) return; + const token = await ensureFreshAccessToken(); + if (!token) { errorBanner = 'Session expired — please sign in again.'; render(); return; } + const defaultName = activeProject.toLowerCase().replace(/[^a-z0-9-_]+/g, '-'); + const name = window.prompt( + 'Name for the new GitHub repo:', + defaultName, + ); + if (!name) return; + await runBusy(`Creating ${name}…`, async (progress) => { + const createOpts: CreateRepoOptions = { name, token, private: false }; + const adapter = await GitHubAdapter.createRepo(createOpts); + log.info(`Created GitHub repo ${user!.login}/${name}`); + index = { + ...index, + remoteRepo: { owner: user!.login, name, branch: 'main' }, + syncedCommitSha: null, + syncedTreeSha: null, + baseTree: {}, + }; + saveSyncIndex(activeProject, index); + // Initial commit so the repo isn't a bare auto_init. + await doInitialCommit(adapter, progress); + await refreshHistory(); + startPolling(); + }); + } + + async function connectExisting() { + if (!user) return; + const input = window.prompt( + 'Enter the repo as owner/name (you must already be a collaborator):', + `${user.login}/${activeProject}`, + ); + if (!input) return; + const m = input.match(/^([^/\s]+)\s*\/\s*([^/\s]+)$/); + if (!m) { errorBanner = 'Expected owner/name format.'; render(); return; } + const [, owner, name] = m; + + await runBusy(`Connecting to ${owner}/${name}…`, async (progress) => { + const token = await ensureFreshAccessToken(); + if (!token) { errorBanner = 'Session expired — please sign in again.'; return; } + const adapter = GitHubAdapter.open({ owner, repo: name, branch: 'main', token }); + // Probe — 404 here means the repo doesn't exist or we lack access. + try { + await adapter.branchHead(); + } catch (e) { + if (e instanceof GitHubApiError && e.status === 404) { + throw new Error(`${owner}/${name} not found, or you don't have access.`); + } + throw e; + } + index = { + ...index, + remoteRepo: { owner, name, branch: 'main' }, + }; + saveSyncIndex(activeProject, index); + + // Clone if remote has content. + const wt = new OpfsWorkingTree(opts.workspace); + const repo = new Repo(adapter); + const remoteSha = await adapter.branchHead(); + if (remoteSha) { + log.info(`Cloning ${owner}/${name} @ ${remoteSha.slice(0, 8)}`); + await repo.checkout(wt, remoteSha, { onProgress: progress }); + persistSyncedFrom(repo); + const paths = Object.keys(repo.getSyncedTree()); + if (opts.onAfterPull) await opts.onAfterPull(paths); + } + await refreshHistory(); + startPolling(); + }); + } + + async function disconnect() { + if (!confirm(`Disconnect "${activeProject}" from ${index.remoteRepo?.owner}/${index.remoteRepo?.name}? Local files are kept; the remote is untouched.`)) return; + index = { remoteRepo: null, syncedCommitSha: null, syncedTreeSha: null, baseTree: {} }; + saveSyncIndex(activeProject, index); + staged = []; + publishStaged = []; + recentCommits = []; + remoteHeadSha = null; + pendingPullPaths = new Set(); + emitPendingPull(); + stopPolling(); + emitStatus(); + render(); + } + + async function doInitialCommit(adapter: GitHubAdapter, progress?: ProgressFn) { + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + const wt = new OpfsWorkingTree(opts.workspace); + const repo = new Repo(adapter); + // A freshly-created repo with auto_init has a root commit (just the + // README). Rebase our engine onto that as the parent — otherwise + // commit() would emit a root commit and updateBranch would reject + // it as a non-FF. + await repo.refreshSyncedHead(); + await repo.commit(wt, { + author: user?.login ?? 'unknown', + message: `Initial commit from Fade playground`, + onProgress: progress, + }); + persistSyncedFrom(repo); + await refreshStatus(); + } + + /** Local save: snapshot the working tree to OPFS, no network. + * Returns the created save record (handy for "save & publish" to + * reuse the message). */ + async function doSave(message: string): Promise { + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + try { + const save = await createSave(activeProject, opts.workspace, message); + log.info(`Saved locally: ${save.id} — "${message}"`); + refreshSaves(); + // refreshStatus internally awaits `savesUpgrade`, which + // refreshSaves() just reassigned to the in-flight OPFS load. + // So by the time computeStatus sees referenceTree(), + // pendingSaves is up-to-date with the new snapshot. + await refreshStatus(); + return save; + } catch (e) { + errorBanner = `Save failed: ${errMsg(e)}`; + log.error(`Save failed: ${errorBanner}`); + return null; + } + } + + /** Publish: squash everything in the working tree into a single git + * commit on the remote branch. Accrued local saves (if any) are + * cleared on success — they were always just local checkpoints, and + * the new published commit supersedes them. + * + * Default message is generated from the pending saves if the user + * didn't supply one. */ + async function doPublish(message: string, progress?: ProgressFn) { + const built = await buildRepo(); + if (!built) { errorBanner = 'Not connected.'; return; } + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + const wt = new OpfsWorkingTree(opts.workspace); + try { + await built.repo.commit(wt, { + author: user?.login ?? 'unknown', + message, + onProgress: progress, + }); + persistSyncedFrom(built.repo); + // Saves are obsolete — the published commit captures their net + // result. Clear the chain so the chips reset. + await clearSaves(activeProject); + refreshSaves(); + await refreshStatus(); + await refreshHistory(); + // Successful publish — wipe any stale info banner left over + // from a prior auto-merge ("click Publish to push your + // merged state"), since that action is now complete. + infoBanner = null; + } catch (e) { + if (e instanceof HeadConflictError) { + // The remote moved between our last poll and this Publish — + // a genuine race. The safer flow is Pull-then-merge-then- + // Publish (so the merged result is in the WT and the user + // can inspect it before pushing). Fall through to a Pull + // automatically so the user just sees "we pulled the new + // changes for you, resolve and Publish again." + log.warn('Publish raced with remote — auto-pulling to merge first.'); + await mergeFromRemote(built, 'publish-race'); + } else if (e instanceof Error && e.message === 'nothing to commit') { + infoBanner = null; + } else { + errorBanner = errMsg(e); + } + } + } + + /** Default label for a quick save when the user didn't type one. + * Auto-describes the change set so the history list is scannable + * ("3 files changed: main.fbasic, fade.json …") instead of a wall + * of identical "Quick save · HH:MM:SS" rows. Falls back to a + * timestamp if there are no changes to describe. */ + function defaultSaveMessage(): string { + const changed = staged.filter((e) => e.status !== 'unchanged'); + const t = new Date(); + const hh = String(t.getHours()).padStart(2, '0'); + const mm = String(t.getMinutes()).padStart(2, '0'); + const ss = String(t.getSeconds()).padStart(2, '0'); + if (changed.length === 0) { + return `Save · ${hh}:${mm}:${ss}`; + } + const added = changed.filter((e) => e.status === 'added').length; + const modified = changed.filter((e) => e.status === 'modified').length; + const deleted = changed.filter((e) => e.status === 'deleted').length; + const counts: string[] = []; + if (added) counts.push(`+${added}`); + if (modified) counts.push(`~${modified}`); + if (deleted) counts.push(`-${deleted}`); + // Show first 3 paths inline so the row is scannable; clamp the + // tail with an ellipsis. Path-only (no leading directory) so + // long nested paths don't dominate the line. + const headPaths = changed.slice(0, 3).map((e) => e.path.split('/').pop() ?? e.path); + const more = changed.length > 3 ? ` +${changed.length - 3} more` : ''; + return `Save ${hh}:${mm} · ${counts.join(' ')} · ${headPaths.join(', ')}${more}`; + } + + /** Build a default publish message from the pending-saves chain. + * Single save → its own message. Multiple → a header + bullet list. */ + function defaultPublishMessage(): string { + if (pendingSaves.length === 0) return 'Update from playground'; + if (pendingSaves.length === 1) return pendingSaves[0].message; + // pendingSaves is newest-first; reverse for chronological order in + // the commit body. + const chronological = [...pendingSaves].reverse(); + return [ + `Publish ${pendingSaves.length} saves`, + '', + ...chronological.map((s) => `- ${s.message}`), + ].join('\n'); + } + + /** Extension-based binary classifier — used to decide diff3 (text) vs + * conflict-copy (binary) at merge time. Matches the playground's own + * binary list (sprites, audio, packed assets). Conservative — anything + * not matched is treated as text and given a diff3 attempt. */ + const BINARY_EXT = /\.(png|jpe?g|gif|webp|bmp|ico|tiff|wav|mp3|ogg|m4a|aac|flac|mp4|webm|mov|xnb|wasm|zip|exe|dll|pdf)$/i; + function isLikelyBinary(path: string): boolean { + return BINARY_EXT.test(path); + } + + /** UTF-8 decode that returns null on invalid sequences. We refuse to + * diff3 anything that isn't clean UTF-8 — falling back to conflict-copy + * keeps binary files (with surprise extensions) from getting line-merged. */ + function decodeUtf8Strict(bytes: Uint8Array): string | null { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return null; + } + } + + /** + * Three-way merge between baseTree (last sync), local OPFS, and the new + * remote HEAD. This is the merge entry point for Pull when local + * divergence blocks a fast-forward, and a recovery path for Publish if + * the remote moved between our last poll and the push attempt. + * + * - Both-side-changed text → diff3 merge written in-place; markers + * surface as text-conflicts the user resolves in the editor. + * - Both-side-changed binary → `.fade-conflict.` + * written alongside; user picks via Use mine / Use theirs. + * - Remote-only → applied to OPFS straight up. + * - Local-only → kept intact. + * Updates the sync-index to track the new remote HEAD as our base — the + * user's next Publish will go cleanly once conflicts are resolved. + * + * `trigger` shapes the user-facing status message ("Pulled and merged…" + * vs "Remote moved during publish…") but doesn't change the merge logic. + */ + async function mergeFromRemote( + built: { adapter: GitHubAdapter; repo: Repo }, + trigger: 'pull' | 'publish-race', + ) { + log.info(`Merging remote into working tree (trigger: ${trigger})`); + const remoteSha = await built.adapter.branchHead(); + if (!remoteSha) { + errorBanner = 'Remote branch has no HEAD. Try Pull.'; + log.warn('mergeFromRemote: remote branch has no HEAD'); + return; + } + if (remoteSha === index.syncedCommitSha) { + // Nothing to merge — remote hasn't moved past our synced head. + // The engine's stale-state edge case from before the + // setSyncedHead rehydrate fix; defensive no-op here. + log.warn(`mergeFromRemote: remote not actually ahead (${remoteSha.slice(0,8)} == syncedCommitSha)`); + return; + } + // Flush in-flight Monaco edits to OPFS BEFORE we start writing + // merged bytes. Without this, a keystroke that lands between the + // localBytes snapshot below and the merge's writeFile would get + // clobbered when the next autosave fires (file already on disk + // from merge, autosave overwrites with the pre-merge editor + // buffer). pull() and doPublish both already flush, but they do + // so before the network round-trip — by the time we reach here + // the user has had seconds to type more. + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + log.info(`Remote moved: ${index.syncedCommitSha?.slice(0,8) ?? '(none)'} → ${remoteSha.slice(0,8)}`); + const remoteTree = await built.adapter.getTree(remoteSha); + const baseTree = index.baseTree; + const localPaths = await opts.workspace.list(); + const localSet = new Set(localPaths.filter((p) => !isHiddenFromCommits(p))); + log.info(`Merge inputs: ${localSet.size} local file(s), ${Object.keys(remoteTree).length} remote, ${Object.keys(baseTree).length} base`); + + // Categorize per path. The same loop both detects conflicts and + // applies the easy cases (remote-only, both-same-content). + const textConflictPaths: string[] = []; // diff3 emitted markers + const binaryConflictPaths: string[] = []; // conflict-copy written + const autoMerged: string[] = []; // diff3 merged cleanly + const remoteOnly: string[] = []; + const localOnly: string[] = []; + + const allPaths = new Set([ + ...localSet, + ...Object.keys(remoteTree), + ...Object.keys(baseTree), + ]); + + const shortRemote = remoteSha.slice(0, 8); + + for (const path of allPaths) { + const baseHash = baseTree[path] ?? null; + const remoteHash = remoteTree[path]?.blobSha ?? null; + const localBytes = localSet.has(path) + ? await opts.workspace.readBytes(path).catch(() => null) + : null; + const localHash = localBytes ? await gitBlobSha(localBytes) : null; + + const localChanged = (localHash ?? null) !== baseHash; + const remoteChanged = remoteHash !== baseHash; + + if (!localChanged && !remoteChanged) continue; + + if (localChanged && remoteChanged) { + if (localHash === remoteHash) { + // Both sides converged on the same content — no real conflict. + continue; + } + // Both-side change. + const remoteBytes = remoteHash ? await safeGetBlob(built.adapter, remoteHash) : null; + // If the path is binary or any side fails UTF-8, fall back + // to conflict-copy — diff3 is line-level and would mangle + // bytes that aren't legitimately newline-delimited text. + const oursText = localBytes ? decodeUtf8Strict(localBytes) : null; + const theirsText = remoteBytes ? decodeUtf8Strict(remoteBytes) : null; + const baseBytes = baseHash ? await safeGetBlob(built.adapter, baseHash) : null; + const baseText = baseBytes ? decodeUtf8Strict(baseBytes) : null; + + if ( + !isLikelyBinary(path) && + oursText !== null && theirsText !== null && baseText !== null + ) { + const merge = diff3Merge(baseText, oursText, theirsText, { + oursLabel: 'ours', + theirsLabel: `theirs (${shortRemote})`, + }); + const mergedBytes = new TextEncoder().encode(merge.merged); + try { + await writeFile(path, mergedBytes); + } catch (e) { + log.error(`Merge write failed for ${path}: ${errMsg(e)}`); + continue; + } + if (merge.hasConflicts) { + textConflictPaths.push(path); + textConflicts.add(path); + log.warn(`Merged ${path}: ${merge.conflicts.length} conflict region(s) — needs user resolution`); + } else { + autoMerged.push(path); + textConflicts.delete(path); + log.info(`Auto-merged ${path} (no overlapping changes)`); + } + continue; + } + + // Binary fallback: write the remote bytes as a sibling + // conflict copy and leave the live file as the user's. + binaryConflictPaths.push(path); + log.warn(`Binary conflict on ${path} — saved remote as sibling`); + if (remoteBytes) { + const conflictPath = `${path}.fade-conflict.${shortRemote}`; + try { await writeFile(conflictPath, remoteBytes); } + catch (e) { log.error(`Could not write conflict copy for ${path}: ${errMsg(e)}`); } + } + continue; + } + if (remoteChanged && !localChanged) { + if (remoteHash === null) { + if (localSet.has(path)) { + try { await deleteFile(path); } catch { /* fade.json guard etc — leave it */ } + } + } else { + const remoteBytes = await safeGetBlob(built.adapter, remoteHash); + if (remoteBytes) { + try { await writeFile(path, remoteBytes); } + catch (e) { console.warn('[sharing] could not pull', path, e); } + } + } + remoteOnly.push(path); + continue; + } + // localChanged && !remoteChanged → keep local as-is. + localOnly.push(path); + } + + // Advance our base to the EXACT remote HEAD we merged against — + // not whatever the branch points at now. Using + // `refreshSyncedHead()` (which re-fetches) would race: a fresh + // third-party push between our merge and the refetch would land + // an updated SHA into syncedHead while our merged working tree + // only incorporated the older one. setSyncedHead writes through + // without a network round-trip. + const mergedCommit = await built.adapter.getCommit(remoteSha); + built.repo.setSyncedHead( + { commitSha: remoteSha, treeSha: mergedCommit.treeSha }, + remoteTree, + ); + persistSyncedFrom(built.repo); + + // Race check: did the remote move AGAIN while we were merging? + // If so the user needs another Pull before Publishing — otherwise + // Publish will hit HeadConflictError and the auto-merger will + // chain a second merge against an even-newer tree. Cheap to + // detect with one extra branchHead() call. + const remoteAfter = await built.adapter.branchHead(); + const racedAgain = remoteAfter && remoteAfter !== remoteSha; + if (racedAgain) { + log.warn(`Remote moved during merge: ${remoteSha.slice(0,8)} → ${remoteAfter.slice(0,8)}. Pull again before Publishing.`); + } + + const totalConflicts = textConflictPaths.length + binaryConflictPaths.length; + const parts: string[] = []; + if (totalConflicts > 0) { + parts.push(`${totalConflicts} file${totalConflicts > 1 ? 's' : ''} need resolution — see Conflicts below`); + } + if (autoMerged.length > 0) { + parts.push(`${autoMerged.length} text file${autoMerged.length > 1 ? 's' : ''} auto-merged`); + } + if (remoteOnly.length > 0) { + parts.push(`${remoteOnly.length} remote-only change${remoteOnly.length > 1 ? 's' : ''} applied`); + } + // Status banner: only surface when the user actually needs to do + // something. Conflicts → block on resolution. Publish-race during + // a Publish click → reroute to Publish-after-resolve. Remote + // moved again mid-merge → tell user to Pull once more. Anything + // else (clean auto-merge from Pull) stays silent; the log + // channel already records what happened. + const racedSuffix = racedAgain + ? ` Remote moved again during merge (now at ${remoteAfter!.slice(0,8)}) — Pull once more before Publishing.` + : ''; + if (totalConflicts > 0) { + const verb = trigger === 'pull' ? 'Pulled' : 'Publish raced with remote'; + infoBanner = `${verb} — ${parts.join('; ')}.${racedSuffix}`; + } else if (trigger === 'publish-race') { + // Auto-pulled mid-publish with no conflicts — point the user + // back at the Publish button so they finish the action they + // started. + infoBanner = `Remote moved during publish — auto-merged${parts.length ? ' (' + parts.join('; ') + ')' : ''}. Click Publish to push your merged state.${racedSuffix}`; + } else if (racedAgain) { + // Pull from a chip click, no conflicts, but remote moved + // again — surface the staleness so the user pulls once more. + infoBanner = `Pulled, but remote moved again during merge (now at ${remoteAfter!.slice(0,8)}) — Pull once more before Publishing.`; + } else { + // Clean pull, no conflicts, no race. Nothing to say. + infoBanner = null; + } + void localOnly; // future: surface "your-only changes" count + + await refreshStatus(); + await refreshHistory(); + if (opts.onAfterPull) { + const refreshed = [ + ...remoteOnly, + ...autoMerged, + ...textConflictPaths, + ...binaryConflictPaths, + ...binaryConflictPaths.map((p) => `${p}.fade-conflict.${shortRemote}`), + ]; + if (refreshed.length > 0) await opts.onAfterPull(refreshed); + } + + // UX: when there's exactly one text conflict, jump the user straight + // into the conflict editor — by far the most common case, and the + // friction of "where's the resolve button?" was the original + // complaint that motivated this whole flow. Multi-conflict cases + // need the user to pick which to start with, so we leave it manual. + if (textConflictPaths.length === 1 && opts.onOpenConflict) { + const target = textConflictPaths[0]; + log.info(`Auto-opening conflict editor for ${target}`); + // Defer one tick so the panel re-render lands before the dock + // panel registration runs — avoids a flash of unstyled state. + setTimeout(() => { void opts.onOpenConflict!(target); }, 50); + } + } + + /** Helper used by mergeFromRemote: swallow getBlob failures so a single + * bad blob can't abort the entire merge pass. */ + async function safeGetBlob(adapter: GitHubAdapter, blobSha: string): Promise { + try { return await adapter.getBlob(blobSha); } + catch (e) { console.warn('[sharing] getBlob failed', blobSha, e); return null; } + } + + /** UTF-8 decode workspace bytes for the diff viewer. Null if the + * file isn't readable (deleted / binary path that failed decode). */ + async function readWorkspaceText(path: string): Promise { + try { + const bytes = await opts.workspace.readBytes(path); + return new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } catch { + return null; + } + } + + /** Decode a save's base64-stored bytes into a UTF-8 string. Null + * on decode failure (binary save content that wasn't actually + * text). */ + function decodeBase64Text(b64: string): string | null { + try { + return new TextDecoder('utf-8', { fatal: false }).decode(base64ToBytes(b64)); + } catch { + return null; + } + } + + /** Very small extension → Monaco language map. Defaults to + * plaintext so the diff editor still renders something readable + * even for unknown types. */ + function guessLanguageId(path: string): string { + const lower = path.toLowerCase(); + if (lower.endsWith('.fbasic') || lower.endsWith('.fb')) return 'fade'; + if (lower.endsWith('.json')) return 'json'; + if (lower.endsWith('.md')) return 'markdown'; + if (lower.endsWith('.ts')) return 'typescript'; + if (lower.endsWith('.js')) return 'javascript'; + if (lower.endsWith('.html')) return 'html'; + if (lower.endsWith('.css')) return 'css'; + return 'plaintext'; + } + + /** Shared "build a diff viewer payload for context X, hand it to + * the host" logic. Used by the controller's `openDiffViewer` + * method AND by the inline Show-diff buttons in the publish + * preview + history saves/commits. */ + async function openDiffViewerImpl(args: + | { kind: 'unsaved'; path: string } + | { kind: 'publish'; path: string } + | { kind: 'save'; saveId: string; path: string } + | { kind: 'commit'; commitSha: string; path: string } + | { kind: 'pull'; path: string } + ): Promise { + if (!opts.onOpenDiff) return false; + const language = guessLanguageId(args.path); + if (args.kind === 'unsaved') { + // Latest save → working tree. If there are no saves, fall + // back to the published baseTree (same reference the panel + // uses to compute `staged`). + const topSave = pendingSaves[0]; + let beforeText: string | null; + if (topSave) { + const b64 = topSave.files[args.path]; + beforeText = b64 !== undefined ? decodeBase64Text(b64) : null; + } else { + beforeText = await fetchPublishedText(args.path); + } + const afterText = await readWorkspaceText(args.path); + const beforeLabel = topSave ? `Save ${topSave.id.slice(0, 6)}` : 'Published'; + opts.onOpenDiff({ + id: `diff-viewer:unsaved:${args.path}`, + title: `${args.path} (unsaved)`, + path: args.path, + languageId: language, + beforeText, + afterText, + beforeLabel, + afterLabel: 'Working tree', + }); + return true; + } + if (args.kind === 'publish') { + const beforeText = await fetchPublishedText(args.path); + const afterText = await readWorkspaceText(args.path); + opts.onOpenDiff({ + id: `diff-viewer:publish:${args.path}`, + title: `${args.path} (publish preview)`, + path: args.path, + languageId: language, + beforeText, + afterText, + beforeLabel: 'Published', + afterLabel: 'Working tree', + }); + return true; + } + if (args.kind === 'save') { + const target = pendingSaves.find((s) => s.id === args.saveId); + if (!target) return false; + const idx = pendingSaves.indexOf(target); + // pendingSaves is newest-first → the predecessor (older + // save) sits at idx+1. Fall back to the published baseTree + // if this is the oldest save in the chain. + const prior = pendingSaves[idx + 1]; + const afterB64 = target.files[args.path]; + const afterText = afterB64 !== undefined ? decodeBase64Text(afterB64) : null; + let beforeText: string | null; + if (prior) { + const priorB64 = prior.files[args.path]; + beforeText = priorB64 !== undefined ? decodeBase64Text(priorB64) : null; + } else { + beforeText = await fetchPublishedText(args.path); + } + opts.onOpenDiff({ + id: `diff-viewer:save:${args.saveId}:${args.path}`, + title: `${args.path} (save: ${target.message.slice(0, 40)})`, + path: args.path, + languageId: language, + beforeText, + afterText, + beforeLabel: prior ? `Save ${prior.id.slice(0, 6)}` : 'Published', + afterLabel: `Save ${target.id.slice(0, 6)}`, + }); + return true; + } + if (args.kind === 'commit') { + const built = await buildRepo(); + if (!built) return false; + const commit = await built.adapter.getCommit(args.commitSha); + const tree = await built.adapter.getTree(args.commitSha); + const afterSha = tree[args.path]?.blobSha; + let afterText: string | null = null; + if (afterSha) { + const bytes = await safeGetBlob(built.adapter, afterSha); + if (bytes) afterText = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } + let beforeText: string | null = null; + if (commit.parents.length > 0) { + const parentTree = await built.adapter.getTree(commit.parents[0]); + const beforeSha = parentTree[args.path]?.blobSha; + if (beforeSha) { + const bytes = await safeGetBlob(built.adapter, beforeSha); + if (bytes) beforeText = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } + } + opts.onOpenDiff({ + id: `diff-viewer:commit:${args.commitSha}:${args.path}`, + title: `${args.path} (commit ${args.commitSha.slice(0, 8)})`, + path: args.path, + languageId: language, + beforeText, + afterText, + beforeLabel: commit.parents.length > 0 ? `Parent ${commit.parents[0].slice(0, 8)}` : '(empty)', + afterLabel: `Commit ${args.commitSha.slice(0, 8)}`, + }); + return true; + } + if (args.kind === 'pull') { + // Preview "if I click Pull, what will this file become?". + // Multiple pending remote commits collapse into one fast- + // forward (or one 3-way merge) to remote HEAD, so the + // after-side is whatever remoteHead's tree says — exactly + // what `tryFastForward` would materialise in the clean + // case. We re-fetch the tree on demand instead of caching; + // the poll's tree is typically fresh and another fetch is + // cheap (and avoids staleness if the user lingered). + const built = await buildRepo(); + if (!built || remoteHeadSha === null) return false; + const beforeText = await readWorkspaceText(args.path); + let afterText: string | null = null; + try { + const remoteTree = await built.adapter.getTree(remoteHeadSha); + const afterSha = remoteTree[args.path]?.blobSha; + if (afterSha) { + const bytes = await safeGetBlob(built.adapter, afterSha); + if (bytes) afterText = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } + } catch (e) { + console.warn('[sharing] pull-diff fetch failed', e); + } + opts.onOpenDiff({ + id: `diff-viewer:pull:${remoteHeadSha}:${args.path}`, + title: `${args.path} (pull preview)`, + path: args.path, + languageId: language, + beforeText, + afterText, + beforeLabel: 'Working tree', + afterLabel: `Remote ${remoteHeadSha.slice(0, 8)}`, + }); + return true; + } + return false; + } + + /** + * Overwrite a single locally-changed file with its last-synced base + * content (effectively `git checkout -- `). For locally-added + * files (no base entry), removes the file instead. fade.json's delete + * guard is honored — we just leave it if delete fails. + */ + async function revertFile(path: string) { + const built = await buildRepo(); + if (!built) { errorBanner = 'Not connected.'; render(); return; } + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + try { + const baseHash = index.baseTree[path]; + if (baseHash) { + const bytes = await built.adapter.getBlob(baseHash); + await writeFile(path, bytes); + log.info(`Reverted ${path} to base (${baseHash.slice(0, 8)})`); + } else { + try { + await deleteFile(path); + log.info(`Reverted ${path} (locally-added → deleted)`); + } catch (e) { + log.warn(`Revert delete refused for ${path}: ${errMsg(e)}`); + } + } + if (opts.onAfterPull) await opts.onAfterPull([path]); + await refreshStatus(); + } catch (e) { + errorBanner = `Revert failed: ${errMsg(e)}`; + log.error(`Revert ${path} failed: ${errorBanner}`); + render(); + } + } + + /** + * Revert *every* path that differs from the synced base. Confirms first + * (this is destructive). Iterates through staged changes — added files + * are deleted, modified/deleted are restored from the base blobs. + */ + async function revertAll() { + const changedPaths = staged + .filter((e) => e.status !== 'unchanged') + .map((e) => e.path); + if (changedPaths.length === 0) return; + if (!confirm( + `Revert ALL ${changedPaths.length} local change${changedPaths.length === 1 ? '' : 's'}? ` + + `Files will be overwritten with the last-synced content. This cannot be undone.`, + )) return; + await runBusy(`Reverting ${changedPaths.length} file${changedPaths.length === 1 ? '' : 's'}…`, async (progress) => { + const built = await buildRepo(); + if (!built) { errorBanner = 'Not connected.'; return; } + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + for (let i = 0; i < changedPaths.length; i++) { + const path = changedPaths[i]; + progress({ phase: 'apply', path, current: i + 1, total: changedPaths.length }); + try { + const baseHash = index.baseTree[path]; + if (baseHash) { + const bytes = await built.adapter.getBlob(baseHash); + await writeFile(path, bytes); + } else { + try { await deleteFile(path); } + catch { /* fade.json guard etc */ } + } + } catch (e) { + log.warn(`Revert ${path} failed: ${errMsg(e)}`); + } + } + if (opts.onAfterPull) await opts.onAfterPull(changedPaths); + await refreshStatus(); + }); + } + + /** + * Mark a text file's conflict as resolved. Reads the live file, verifies + * no `<<<<<<< / ======= / >>>>>>>` markers remain, and removes it from + * the tracked-conflicts set. Refuses if markers are still present so the + * user can't accidentally commit half-merged content. + */ + async function resolveTextConflict(path: string) { + try { + const bytes = await opts.workspace.readBytes(path); + const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + if (hasConflictMarkers(text)) { + errorBanner = `${path} still has conflict markers — open it and finish merging first.`; + render(); + return; + } + textConflicts.delete(path); + errorBanner = null; + await refreshStatus(); + } catch (e) { + errorBanner = `Resolve failed: ${errMsg(e)}`; + render(); + } + } + + /** + * User picked a side for a binary conflict file. `mine` = drop the + * .fade-conflict copy and keep the live file as-is. `theirs` = + * overwrite the live file with the conflict-copy bytes, then drop the + * copy. + */ + async function resolveConflict(conflictPath: string, choice: 'mine' | 'theirs') { + const originalPath = conflictPath.replace(/\.fade-conflict\.[a-f0-9]+$/, ''); + try { + if (choice === 'theirs') { + const bytes = await opts.workspace.readBytes(conflictPath); + await writeFile(originalPath, bytes); + } + await deleteFile(conflictPath); + await refreshStatus(); + if (opts.onAfterPull) { + // Refresh open editors for the original path so they pick up + // any "use theirs" overwrite. + await opts.onAfterPull([originalPath]); + } + } catch (e) { + errorBanner = `Resolve failed: ${errMsg(e)}`; + render(); + } + } + + async function pull() { + const built = await buildRepo(); + if (!built) { errorBanner = 'Not connected.'; return; } + await runBusy('Pulling…', async (progress) => { + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + const wt = new OpfsWorkingTree(opts.workspace); + const result = await built.repo.tryFastForward(wt, { onProgress: progress }); + if (result.applied) { + // Clean fast-forward — remote moved, we had no local + // divergence, the engine materialised the new tree. + persistSyncedFrom(built.repo); + if (opts.onAfterPull) { + await opts.onAfterPull(Object.keys(built.repo.getSyncedTree())); + } + infoBanner = null; + await refreshStatus(); + await refreshHistory(); + return; + } + if (result.dirty) { + // Local divergence (unsaved edits OR unpublished saves + // OR both) — Pull does the 3-way merge so the user gets + // remote changes before Publishing, the safer direction. + // mergeFromRemote handles persisting + onAfterPull + log + // status. + await mergeFromRemote(built, 'pull'); + return; + } + // Already up-to-date. + infoBanner = null; + }); + } + + // ─── history-viewer helpers (consumed by the controller methods that + // the history dockview panel calls into) ──────────────────────────── + + async function computeCommitDiff(sha: string) { + const built = await buildRepo(); + if (!built) return null; + const commit = recentCommits.find((c) => c.id === sha); + if (!commit) return null; + const [tree, parentTree] = await Promise.all([ + getCachedTree(built, sha), + commit.parent ? getCachedTree(built, commit.parent) : Promise.resolve({} as GitTree), + ]); + return diffGitTrees(parentTree, tree); + } + + async function getCachedTree(built: { adapter: GitHubAdapter }, sha: string): Promise { + let tree = commitTreeCache.get(sha); + if (!tree) { + tree = await built.adapter.getTree(sha); + commitTreeCache.set(sha, tree); + } + return tree; + } + + /** Restore the working tree to an older commit's tree, then commit the + * result on top of the current branch HEAD. History is preserved — we + * never rewrite; the restore shows up as a new commit. */ + async function restoreCommit(targetSha: string) { + const targetCommit = recentCommits.find((c) => c.id === targetSha); + const short = targetSha.slice(0, 8); + const msg = targetCommit ? `"${targetCommit.message.split('\n')[0].slice(0, 60)}"` : ''; + if (!confirm(`Restore the working tree to commit ${short} ${msg}? Any uncommitted local changes will be overwritten. The branch history is preserved — this creates a new commit on top of HEAD.`)) return; + await runBusy(`Restoring to ${short}…`, async (progress) => { + const built = await buildRepo(); + if (!built) { errorBanner = 'Not connected.'; return; } + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + // Align the engine with current branch HEAD before we materialize + // and commit on top — otherwise the new commit's parent might be + // the old syncedHead, and updateBranch would reject as non-FF. + await built.repo.refreshSyncedHead(); + + const targetTree = await getCachedTree(built, targetSha); + + // Apply target tree to the working tree. We do this manually + // (not via repo.checkout) because checkout would also move the + // engine's syncedHead to targetSha — which would then make the + // subsequent commit emit `targetSha` as its parent and fail FF. + const liveList = await opts.workspace.list(); + const liveSet = new Set(liveList.filter((p) => !isHiddenFromCommits(p))); + const targetPaths = Object.entries(targetTree); + for (let i = 0; i < targetPaths.length; i++) { + const [path, entry] = targetPaths[i]; + progress({ phase: 'blob-download', path, current: i + 1, total: targetPaths.length }); + const bytes = await built.adapter.getBlob(entry.blobSha); + progress({ phase: 'apply', path, current: i + 1, total: targetPaths.length }); + await writeFile(path, bytes); + } + for (const path of liveSet) { + if (!(path in targetTree)) { + progress({ phase: 'delete', path }); + try { await deleteFile(path); } + catch { /* fade.json delete-guard etc — leave it */ } + } + } + + // Commit the restored tree as a new commit on top of HEAD. + const wt = new OpfsWorkingTree(opts.workspace); + try { + await built.repo.commit(wt, { + author: user?.login ?? 'unknown', + message: `Restore to ${short}${targetCommit ? ` (${targetCommit.message.split('\n')[0].slice(0, 40)})` : ''}`, + onProgress: progress, + }); + persistSyncedFrom(built.repo); + if (opts.onAfterPull) await opts.onAfterPull(Object.keys(targetTree)); + await refreshStatus(); + await refreshHistory(); + // Drop diff cache — the commit list shifted, old per-commit + // diffs against old parents are still valid but the new tip + // wasn't in the cache anyway. + infoBanner = null; + } catch (e) { + errorBanner = `Restore failed: ${errMsg(e)}`; + } + }); + } + + async function refreshHistory() { + const built = await buildRepo(); + if (!built) { recentCommits = []; emitHistory(); return; } + try { + const head = await built.adapter.branchHead(); + if (!head) { recentCommits = []; emitHistory(); return; } + const log2 = await built.repo.log({ from: head, limit: 30 }); + recentCommits = log2.map((c) => ({ + id: c.sha, + parent: c.parents[0] ?? null, + message: c.message, + author: c.author, + time: c.time, + })); + } catch (e) { + // Non-fatal but worth surfacing — a 404 here means listCommits + // failed (e.g. the commits endpoint hiccuped on a brand-new + // repo). Push to the Logs panel so the user can see why their + // history is empty. + log.warn(`History failed to load: ${errMsg(e)}`); + console.warn('[sharing] refreshHistory failed', e); + recentCommits = []; + } + emitHistory(); + } + + async function refreshStatus() { + try { + // Wait for any pending legacy-save upgrades so referenceTree() + // returns the right answer the first time. Cheap when there's + // nothing to upgrade (Promise.resolve()). + await savesUpgrade; + const wt = new OpfsWorkingTree(opts.workspace); + staged = await computeStatus(wt, referenceTree(), hashCache); + // Second pass: same working tree, but vs the *published* baseTree. + // The HashCache is warm from the first call, so this only adds an + // extra `wt.list()` + map lookups — no rereads, no rehashing. + // When there are no pending saves, this matches `staged` exactly. + publishStaged = pendingSaves.length === 0 + ? staged + : await computeStatus(wt, index.baseTree, hashCache); + // Read unfiltered list to pick up *.fade-conflict.* copies — those + // are filtered out of the engine snapshot deliberately, so they + // don't appear in `staged`. + const rawList = await opts.workspace.list(); + const conflictFilesBefore = conflictFiles.length; + conflictFiles = rawList.filter(isHiddenFromCommits); + // Detect text-conflict markers across reloads + verify existing + // entries. Strategy: + // 1. Verify every path currently in textConflicts — if its + // markers are gone (user resolved manually), drop it. + // 2. Scan every staged path (regardless of status) for markers + // and add to the set. We don't restrict by status because + // a hash-collision / classification edge case shouldn't + // hide a real conflict. + const stillConflicted = new Set(); + const oldSnap = [...textConflicts].sort().join('|'); + + const checked = new Set(); + for (const path of textConflicts) { + checked.add(path); + try { + const bytes = await opts.workspace.readBytes(path); + const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + if (hasConflictMarkers(text)) stillConflicted.add(path); + } catch { /* file gone — drop from set */ } + } + for (const entry of staged) { + if (checked.has(entry.path)) continue; + checked.add(entry.path); + try { + const bytes = await opts.workspace.readBytes(entry.path); + const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + if (hasConflictMarkers(text)) stillConflicted.add(entry.path); + } catch { /* unreadable — ignore */ } + } + textConflicts = stillConflicted; + const newSnap = [...textConflicts].sort().join('|'); + if (oldSnap !== newSnap) { + log.info(`textConflicts now: [${[...textConflicts].join(', ') || '(none)'}]`); + } + // If the user just finished resolving the last conflict (text + // or binary) — clear the stale merge banner. mergeFromRemote + // sets infoBanner with "Pulled — 1 file need resolution", + // and used to linger forever because neither the conflict + // editor's onSave nor the Use mine/theirs flow clears it. + // Detect the transition (had conflicts, now empty) and + // reset the info channel only — errors stay put. + const hadConflicts = oldSnap !== '' || conflictFiles.length > 0 || conflictFilesBefore > 0; + const cleanNow = textConflicts.size === 0 && conflictFiles.length === 0; + if (hadConflicts && cleanNow && infoBanner) { + infoBanner = null; + } + emitStatus(); + emitConflicts(); + } catch (e) { + console.warn('[sharing] refreshStatus failed', e); + log.warn(`refreshStatus failed: ${errMsg(e)}`); + staged = []; + publishStaged = []; + conflictFiles = []; + textConflicts = new Set(); + emitConflicts(); + } + render(); + } + + /** + * Run a long-running action with a visible progress banner + log + * channel. The action callback receives a `ProgressFn` it can pass into + * `repo.commit/checkout/tryFastForward` so engine-level phases bubble + * up into the UI and the Logs panel. For arbitrary work outside the + * engine (e.g. "creating repo…"), the action can call the progress + * function directly with a custom label by emitting a synthetic event; + * usually it's simpler to just call `setBusy(label)`. + */ + async function runBusy(initialLabel: string, fn: (progress: ProgressFn) => Promise) { + busy = { label: initialLabel }; + // Wipe the error channel at the start of every long-running + // action: by entering the operation, the user is implicitly + // retrying or moving on. Info banners stay put — they carry + // multi-step state that survives across operations (e.g. "click + // Publish to push your merged state" persists from a Pull's + // auto-merge until the next Publish settles it). + errorBanner = null; + render(); + log.info(`▶ ${initialLabel}`); + const progressFn: ProgressFn = (event) => { + const { label, progress } = formatProgress(event); + busy = { label, progress }; + log.info(label, progress); + render(); + }; + try { + await fn(progressFn); + log.info(`✓ ${initialLabel}`); + } catch (e) { + errorBanner = errMsg(e); + log.error(`✗ ${initialLabel}: ${errorBanner}`); + } finally { + busy = null; + render(); + } + } + + /** Map an engine ProgressEvent into a human label + optional bar info. */ + function formatProgress(event: ProgressEvent): { label: string; progress?: { current: number; total: number } } { + switch (event.phase) { + case 'snapshot': return { label: 'Snapshotting working tree…' }; + case 'diff': { + const total = event.added + event.modified + event.deleted; + return { label: `Computed diff: +${event.added} ~${event.modified} -${event.deleted} (${total} file${total === 1 ? '' : 's'})` }; + } + case 'blob-upload': return { label: `Uploading ${event.path}`, progress: { current: event.current, total: event.total } }; + case 'blob-download': return { label: `Downloading ${event.path}`, progress: { current: event.current, total: event.total } }; + case 'apply': return { label: `Writing ${event.path}`, progress: { current: event.current, total: event.total } }; + case 'delete': return { label: `Removing ${event.path}` }; + case 'fetch-tree': return { label: `Fetching tree at ${event.commitSha.slice(0, 8)}…` }; + case 'tree': return { label: 'Building tree object…' }; + case 'commit-object': return { label: 'Creating commit object…' }; + case 'update-branch': return { label: 'Updating branch ref…' }; + } + } + + // ─── bootstrap: try to reuse a stored token ───────────────────────────── + (async function bootstrap() { + // One-shot migration of any pre-OPFS save chains so the user + // doesn't lose history when we switch backends. Best-effort; a + // failure here just delays the move until next boot. + try { + const moved = await migrateLegacyLocalStorageSaves(); + if (moved > 0) log.info(`Migrated ${moved} legacy save chain(s) from localStorage to OPFS.`); + } catch (e) { + log.warn(`Save migration skipped: ${errMsg(e)}`); + } + // Boot path: load the stored TokenSet (migrating any legacy + // localStorage PAT into it), refresh if needed, validate the + // resulting access token by hitting /user. A 401 means the + // token (refresh-token included) is no longer good; wipe. + const accessToken = await ensureFreshAccessToken(); + if (accessToken) { + try { + user = await validateToken(accessToken); + } catch { + tokenStore.clear(); + user = null; + } + } + refreshSaves(); + await refreshStatus(); + if (user) await refreshHistory(); + if (user && isConnected(index)) startPolling(); + render(); + })(); + + // ─── controller exposed to the host ───────────────────────────────────── + return { + setActiveProject(name: string) { + activeProject = name; + index = loadSyncIndex(name); + staged = []; + publishStaged = []; + recentCommits = []; + remoteHeadSha = null; + pendingPullPaths = new Set(); + emitPendingPull(); + stopPolling(); + refreshSaves(); + emitStatus(); + render(); + void (async () => { + await refreshStatus(); + if (user && isConnected(index)) { + await refreshHistory(); + startPolling(); + } + render(); + })(); + }, + refreshStatus, + async refreshStatusForFile(path: string) { + hashCache.invalidate(path); + await refreshStatus(); + }, + invalidateHashFor(path: string) { + hashCache.invalidate(path); + }, + setHasDirtyTabs(b) { + if (hasDirtyTabs === b) return; + hasDirtyTabs = b; + // Cheap re-render — the Save button's enabled-state reads + // `hasDirtyTabs`, and refreshStatus is too expensive (does a + // full working-tree hash) just to flip one button. + render(); + }, + onStatusChange(listener) { + statusListeners.add(listener); + listener(statusMapFromStaged()); + return () => { statusListeners.delete(listener); }; + }, + getStatusMap: statusMapFromStaged, + onPendingPullChange(listener) { + pendingPullListeners.add(listener); + listener(new Set(pendingPullPaths)); + return () => { pendingPullListeners.delete(listener); }; + }, + getPendingPullPaths() { + return new Set(pendingPullPaths); + }, + onConflictChange(listener) { + conflictListeners.add(listener); + listener({ text: new Set(textConflicts), binary: new Set(conflictFiles) }); + return () => { conflictListeners.delete(listener); }; + }, + getConflictPaths() { + return { text: new Set(textConflicts), binary: new Set(conflictFiles) }; + }, + openConflictEditor(path) { + if (!opts.onOpenConflict) return false; + void opts.onOpenConflict(path); + return true; + }, + openDiffViewer(args) { return openDiffViewerImpl(args); }, + getRecentCommits() { + return [...recentCommits]; + }, + onHistoryChange(listener) { + historyListeners.add(listener); + listener([...recentCommits]); + return () => { historyListeners.delete(listener); }; + }, + async getCommitDiff(sha: string) { + const cached = commitDiffCache.get(sha); + if (cached) return cached; + const fresh = await computeCommitDiff(sha); + if (fresh) commitDiffCache.set(sha, fresh); + return fresh ?? null; + }, + restoreCommit(sha: string) { + return restoreCommit(sha); + }, + getRepoInfo() { + return index.remoteRepo ? { ...index.remoteRepo } : null; + }, + async getBaseText(path: string): Promise { + return await fetchPublishedText(path); + }, + async getPublishedText(path: string): Promise { + return await fetchPublishedText(path); + }, + onSavesChange(listener) { + savesListeners.add(listener); + listener([...pendingSaves]); + return () => { savesListeners.delete(listener); }; + }, + getPendingSaves() { + return [...pendingSaves]; + }, + async dropLocalSave(id) { + await dropSave(activeProject, id); + refreshSaves(); + void refreshStatus(); + }, + async revertToLocalSave(id) { + const target = pendingSaves.find((s) => s.id === id); + if (!target) return; + if (!confirm(`Revert the working tree to "${target.message}"? Current uncommitted changes will be overwritten.`)) return; + await runBusy(`Reverting to save…`, async () => { + if (opts.flushPendingSaves) await opts.flushPendingSaves(); + await revertToSave(opts.workspace, target); + for (const path of Object.keys(target.files)) hashCache.invalidate(path); + if (opts.onAfterPull) await opts.onAfterPull(Object.keys(target.files)); + await refreshStatus(); + }); + }, + async getSaveDiff(id) { + const idx = pendingSaves.findIndex((s) => s.id === id); + if (idx < 0) return null; + const save = pendingSaves[idx]; + if (!save.treeHashes) return null; + // Diff against the prior save (next index, since newest-first) + // or the published baseTree if this is the oldest save. + const priorTree: Record = + idx < pendingSaves.length - 1 + ? (pendingSaves[idx + 1].treeHashes ?? index.baseTree) + : index.baseTree; + // Build GitTree-shaped objects for diffGitTrees. + const toTree = (h: Record) => { + const out: Record = {}; + for (const [p, b] of Object.entries(h)) out[p] = { blobSha: b }; + return out; + }; + const { diffGitTrees } = await import('./git-types'); + return diffGitTrees(toTree(priorTree), toTree(save.treeHashes)); + }, + async getSavedText(path: string): Promise { + // Walk newest-first; the first save that has this path is the + // user's latest local checkpoint. Falls back to the published + // text if no save contains the path. + for (const save of pendingSaves) { + const b64 = save.files[path]; + if (b64 !== undefined) { + try { + return new TextDecoder('utf-8', { fatal: false }) + .decode(base64ToBytes(b64)); + } catch { + return null; + } + } + } + return await fetchPublishedText(path); + }, + }; + + /** Shared by getBaseText / getPublishedText. Hits the per-blob-sha + * cache to avoid re-fetching identical content. + * + * Returns: + * - `null` when there's no remote at all (publish doesn't apply, so + * the gutter falls back to a single-state view). + * - `''` when there IS a remote but `path` isn't in it yet (a file + * added since last publish — every line is a change-since-publish). + * - the decoded text when `path` is on the remote. */ + async function fetchPublishedText(path: string): Promise { + if (!index.remoteRepo) return null; + const blobSha = index.baseTree[path]; + if (!blobSha) return ''; + let bytes = baseContentCache.get(blobSha); + if (!bytes) { + const built = await buildRepo(); + if (!built) return null; + try { + bytes = await built.adapter.getBlob(blobSha); + baseContentCache.set(blobSha, bytes); + } catch { + return null; + } + } + try { + return new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } catch { + return null; + } + } +} + +/** Inline base64 → bytes helper; mirrors the one in local-saves.ts so + * the panel doesn't need to import private helpers from there. */ +function base64ToBytes(b64: string): Uint8Array { + const clean = b64.replace(/\s+/g, ''); + if (typeof Buffer !== 'undefined') { + const buf = Buffer.from(clean, 'base64'); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + const bin = atob(clean); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function errMsg(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} + +function el(tag: K, className: string, textContent?: string): HTMLElementTagNameMap[K] { + const n = document.createElement(tag); + n.className = className; + if (textContent !== undefined) n.textContent = textContent; + return n; +} + +function text(s: string): Text { return document.createTextNode(s); } + +function p(s: string, modifier?: 'dim'): HTMLElement { + const n = el('p', `${CSS_PREFIX}-p` + (modifier === 'dim' ? ` ${CSS_PREFIX}-dim` : '')); + n.textContent = s; + return n; +} + +function heading(s: string, level: 1 | 2 = 1): HTMLElement { + const tag = level === 2 ? 'h4' : 'h3'; + const n = el(tag, `${CSS_PREFIX}-h${level}`); + n.textContent = s; + return n; +} + +function row(...children: Array): HTMLElement { + const r = el('div', `${CSS_PREFIX}-row`); + for (const c of children) { + if (typeof c === 'string') r.appendChild(text(c)); + else r.appendChild(c); + } + return r; +} + +/** Like `row` but the layout is "input flexes; button hugs right." Used + * for the Save and Publish text-box/button pairs. */ +function rowInline(...children: Array): HTMLElement { + const r = el('div', `${CSS_PREFIX}-row-inline`); + for (const c of children) { + if (typeof c === 'string') r.appendChild(text(c)); + else r.appendChild(c); + } + return r; +} + +function banner(s: string, kind: 'busy' | 'err' | 'info'): HTMLElement { + const b = el('div', `${CSS_PREFIX}-banner ${CSS_PREFIX}-banner-${kind}`); + b.textContent = s; + return b; +} + +function renderBusyBanner(state: { label: string; progress?: { current: number; total: number } }): HTMLElement { + const b = el('div', `${CSS_PREFIX}-banner ${CSS_PREFIX}-banner-busy`); + const label = el('div', `${CSS_PREFIX}-banner-label`); + label.textContent = state.label; + b.append(label); + if (state.progress && state.progress.total > 0) { + const pct = Math.min(100, Math.max(0, (state.progress.current / state.progress.total) * 100)); + const barWrap = el('div', `${CSS_PREFIX}-bar-wrap`); + const bar = el('div', `${CSS_PREFIX}-bar`); + bar.style.width = `${pct.toFixed(1)}%`; + barWrap.append(bar); + const pctLabel = el('span', `${CSS_PREFIX}-bar-pct`); + pctLabel.textContent = `${state.progress.current}/${state.progress.total}`; + b.append(barWrap, pctLabel); + } + return b; +} + +type ButtonVariant = 'primary' | 'ghost' | 'primary-small' | 'ghost-small' | 'revert-mini'; +function button(label: string, variant: ButtonVariant, onClick: () => void): HTMLButtonElement { + const b = document.createElement('button'); + b.className = `${CSS_PREFIX}-btn ${CSS_PREFIX}-btn-${variant}`; + b.textContent = label; + b.type = 'button'; + b.onclick = onClick; + return b; +} + +function injectStylesOnce(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` +.${CSS_PREFIX}-root { + display: flex; flex-direction: column; + height: 100%; box-sizing: border-box; + padding: 8px 10px; + overflow-y: auto; + color: var(--vscode-foreground, #ddd); + font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; +} +.${CSS_PREFIX}-header { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding-bottom: 8px; + border-bottom: 1px solid var(--vscode-panel-border, #333); + margin-bottom: 8px; +} +.${CSS_PREFIX}-statusrow { + display: flex; gap: 6px; flex-wrap: wrap; + margin-bottom: 10px; +} +.${CSS_PREFIX}-chip { + appearance: none; cursor: pointer; + padding: 3px 10px; border-radius: 999px; + font: inherit; font-size: 11px; font-weight: 600; + border: 1px solid transparent; + transition: filter 0.1s; +} +.${CSS_PREFIX}-chip:hover:not(:disabled) { filter: brightness(1.2); } +.${CSS_PREFIX}-chip:disabled { opacity: 0.6; cursor: progress; } +.${CSS_PREFIX}-chip-unsaved { + background: rgba(255,200,90,0.15); + color: #fc6; + border-color: rgba(255,200,90,0.4); +} +.${CSS_PREFIX}-chip-unpublished { + background: rgba(180,140,240,0.15); + color: #cfa8ff; + border-color: rgba(180,140,240,0.4); +} +.${CSS_PREFIX}-chip-remote { + background: rgba(80,140,200,0.15); + color: #88c8ff; + border-color: rgba(80,140,200,0.4); +} +.${CSS_PREFIX}-chip-conflict { + background: rgba(255,90,90,0.18); + color: #ffb0b0; + border-color: rgba(255,90,90,0.4); +} +.${CSS_PREFIX}-chip-check { + background: transparent; + color: inherit; + border-color: var(--vscode-panel-border, #555); + opacity: 0.75; +} +.${CSS_PREFIX}-chip-check:hover:not(:disabled) { + opacity: 1; + background: rgba(255,255,255,0.05); +} +.${CSS_PREFIX}-who { font-family: ui-monospace, monospace; font-size: 12px; } +.${CSS_PREFIX}-body { display: flex; flex-direction: column; gap: 8px; } +.${CSS_PREFIX}-section { display: flex; flex-direction: column; gap: 8px; } +.${CSS_PREFIX}-h1 { font-size: 13px; font-weight: 600; margin: 4px 0 2px; opacity: 0.95; } +.${CSS_PREFIX}-h2 { font-size: 11px; font-weight: 600; margin: 8px 0 2px; opacity: 0.75; text-transform: uppercase; letter-spacing: 0.04em; } +.${CSS_PREFIX}-p { margin: 0; opacity: 0.92; } +.${CSS_PREFIX}-dim { opacity: 0.55; font-size: 12px; } +.${CSS_PREFIX}-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.${CSS_PREFIX}-link { color: var(--vscode-textLink-foreground, #4da6ff); text-decoration: none; font-family: ui-monospace, monospace; } +.${CSS_PREFIX}-link:hover { text-decoration: underline; } +.${CSS_PREFIX}-banner { + padding: 6px 10px; border-radius: 4px; font-size: 12px; +} +.${CSS_PREFIX}-banner-busy { + background: rgba(80,140,200,0.15); color: #aac8ff; + display: flex; flex-direction: column; gap: 4px; +} +.${CSS_PREFIX}-banner-label { font-size: 12px; } +.${CSS_PREFIX}-bar-wrap { + width: 100%; height: 4px; background: rgba(255,255,255,0.08); + border-radius: 2px; overflow: hidden; +} +.${CSS_PREFIX}-bar { + height: 100%; background: #4da6ff; + transition: width 0.15s ease-out; +} +.${CSS_PREFIX}-bar-pct { font-size: 11px; opacity: 0.7; text-align: right; } +.${CSS_PREFIX}-banner-err { background: rgba(200,80,80,0.18); color: #f88; } +.${CSS_PREFIX}-banner-info { background: rgba(77,166,255,0.12); color: #9ecbff; border: 1px solid rgba(77,166,255,0.3); } +.${CSS_PREFIX}-repo-header { + display: flex; align-items: baseline; gap: 12px; + justify-content: space-between; + margin-bottom: 4px; +} +.${CSS_PREFIX}-repo-line { + flex: 1 1 auto; + display: flex; align-items: baseline; gap: 4px; + overflow: hidden; text-overflow: ellipsis; +} +.${CSS_PREFIX}-section-header { + display: flex; align-items: baseline; gap: 8px; + justify-content: space-between; +} +.${CSS_PREFIX}-stagedlist { + list-style: none; padding: 0; margin: 0; + display: flex; flex-direction: column; gap: 1px; + background: rgba(255,255,255,0.03); border-radius: 4px; + padding: 4px 0; +} +.${CSS_PREFIX}-staged { + display: flex; align-items: center; gap: 8px; + padding: 2px 8px; font-family: ui-monospace, monospace; font-size: 12px; +} +.${CSS_PREFIX}-staged .${CSS_PREFIX}-path { flex: 1 1 auto; } +.${CSS_PREFIX}-btn-revert-mini { + background: transparent; + color: inherit; + border: 1px solid var(--vscode-panel-border, #555); + padding: 0 6px; font-size: 10px; line-height: 16px; + opacity: 0.55; +} +.${CSS_PREFIX}-staged:hover .${CSS_PREFIX}-btn-revert-mini { + opacity: 1; +} +.${CSS_PREFIX}-btn-revert-mini:hover { + background: rgba(255,120,120,0.15); + color: #f88; + border-color: rgba(255,120,120,0.4); +} +.${CSS_PREFIX}-glyph { + width: 14px; text-align: center; font-weight: 700; +} +.${CSS_PREFIX}-staged-added .${CSS_PREFIX}-glyph { color: #6e6; } +.${CSS_PREFIX}-staged-modified .${CSS_PREFIX}-glyph { color: #fc6; } +.${CSS_PREFIX}-staged-deleted .${CSS_PREFIX}-glyph { color: #f88; } +.${CSS_PREFIX}-path { word-break: break-all; } +.${CSS_PREFIX}-pubprev { + margin-top: 6px; + padding: 6px 8px; + background: rgba(184,138,255,0.06); + border-left: 2px solid #b88aff; + border-radius: 0 4px 4px 0; + display: flex; flex-direction: column; gap: 4px; +} +.${CSS_PREFIX}-pubprev-h { + font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.04em; + opacity: 0.85; +} +.${CSS_PREFIX}-pubprev-counts { + font-family: ui-monospace, monospace; + font-size: 11px; font-weight: 400; + opacity: 0.7; + text-transform: none; letter-spacing: 0; +} +.${CSS_PREFIX}-pubprev-list { + list-style: none; padding: 0; margin: 0; + display: flex; flex-direction: column; gap: 1px; +} +.${CSS_PREFIX}-pubprev-row { + display: flex; align-items: center; gap: 8px; + padding: 1px 0; font-family: ui-monospace, monospace; font-size: 11px; +} +.${CSS_PREFIX}-msg { + width: 100%; box-sizing: border-box; + background: rgba(255,255,255,0.05); + color: inherit; + border: 1px solid var(--vscode-panel-border, #444); + border-radius: 4px; + padding: 6px 8px; + font: inherit; font-size: 12px; + resize: vertical; +} +.${CSS_PREFIX}-msg:focus { outline: 2px solid var(--vscode-focusBorder, #0e639c); outline-offset: -1px; } +.${CSS_PREFIX}-msg-inline { + flex: 1 1 auto; min-width: 0; box-sizing: border-box; + background: rgba(255,255,255,0.05); + color: inherit; + border: 1px solid var(--vscode-panel-border, #444); + border-radius: 4px; + padding: 4px 8px; + font: inherit; font-size: 12px; + height: 28px; +} +.${CSS_PREFIX}-msg-inline:focus { outline: 2px solid var(--vscode-focusBorder, #0e639c); outline-offset: -1px; } +.${CSS_PREFIX}-msg-inline:disabled { opacity: 0.5; cursor: not-allowed; } +.${CSS_PREFIX}-row-inline { + display: flex; align-items: stretch; gap: 6px; +} +.${CSS_PREFIX}-row-inline > button { flex: 0 0 auto; } +.${CSS_PREFIX}-pullbox { + margin: 4px 0; + padding: 8px 10px; + background: rgba(77,166,255,0.08); + border-left: 2px solid #4da6ff; + border-radius: 0 4px 4px 0; + display: flex; flex-direction: column; gap: 6px; +} +.${CSS_PREFIX}-pullbox-headrow { + display: flex; align-items: center; gap: 12px; + justify-content: space-between; +} +.${CSS_PREFIX}-pullbox-h { + font-size: 12px; color: #4da6ff; font-weight: 600; +} +.${CSS_PREFIX}-pullbox-list { + list-style: none; padding: 0; margin: 0; + display: flex; flex-direction: column; gap: 1px; +} +.${CSS_PREFIX}-pullbox-row { + display: flex; align-items: center; gap: 8px; + padding: 1px 0; font-family: ui-monospace, monospace; font-size: 11px; +} +.${CSS_PREFIX}-pullbox-row .${CSS_PREFIX}-path { flex: 1 1 auto; min-width: 0; } +.${CSS_PREFIX}-history { + list-style: none; padding: 0; margin: 0; + display: flex; flex-direction: column; gap: 2px; +} +.${CSS_PREFIX}-history li { + padding: 2px 4px; font-size: 12px; + border-left: 2px solid var(--vscode-panel-border, #444); + padding-left: 8px; +} +.${CSS_PREFIX}-history-header { + display: flex; align-items: baseline; gap: 4px; + padding: 2px 0; + user-select: none; +} +.${CSS_PREFIX}-history-header:hover { + background: rgba(255,255,255,0.04); + border-radius: 3px; +} +.${CSS_PREFIX}-caret { + width: 12px; font-size: 9px; color: var(--vscode-icon-foreground, #888); + flex-shrink: 0; +} +.${CSS_PREFIX}-history-detail { + margin: 4px 0 8px 14px; + padding: 4px 8px; + background: rgba(255,255,255,0.03); + border-radius: 4px; + display: flex; flex-direction: column; gap: 4px; +} +.${CSS_PREFIX}-commitid { font-family: ui-monospace, monospace; color: var(--vscode-textLink-foreground, #4da6ff); } +.${CSS_PREFIX}-commitid-link { + text-decoration: none; + padding: 0 2px; + border-radius: 2px; +} +.${CSS_PREFIX}-commitid-link:hover { + text-decoration: underline; + background: rgba(77,166,255,0.1); +} +.${CSS_PREFIX}-commitid-link::after { + content: ' ↗'; + font-size: 9px; + opacity: 0.6; +} +.${CSS_PREFIX}-commitmsg { } +.${CSS_PREFIX}-btn { + appearance: none; border: 0; cursor: pointer; + padding: 6px 12px; border-radius: 4px; + font: inherit; font-weight: 500; + transition: filter 0.1s; +} +.${CSS_PREFIX}-btn:hover { filter: brightness(1.15); } +.${CSS_PREFIX}-btn:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } +.${CSS_PREFIX}-btn-primary { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); +} +.${CSS_PREFIX}-btn-primary-small { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); + padding: 3px 8px; font-size: 11px; +} +.${CSS_PREFIX}-btn-ghost { + background: transparent; color: inherit; + border: 1px solid var(--vscode-panel-border, #555); +} +.${CSS_PREFIX}-btn-ghost-small { + background: transparent; color: inherit; + border: 1px solid var(--vscode-panel-border, #555); + padding: 3px 8px; font-size: 11px; +} + +/* Local-save list row. Mirrors the staged-changes row geometry but with + a richer layout (message + metadata + action row). */ +.${CSS_PREFIX}-save { + display: flex; flex-direction: column; gap: 4px; + padding: 6px 8px; + background: rgba(180,140,240,0.06); + border-left: 2px solid rgba(180,140,240,0.5); + margin-bottom: 2px; + font-size: 12px; +} +.${CSS_PREFIX}-save-row { + display: flex; align-items: baseline; gap: 8px; + justify-content: space-between; +} +.${CSS_PREFIX}-save-msg { font-weight: 500; flex: 1 1 auto; word-break: break-word; } +.${CSS_PREFIX}-save-meta { font-family: ui-monospace, monospace; font-size: 11px; flex-shrink: 0; } + +/* Conflict banner — prominently above all the connected-repo UI so the + user can't miss a conflict state. */ +.${CSS_PREFIX}-conflict-banner { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: 6px; + background: rgba(255,90,90,0.18); + border: 1px solid rgba(255,90,90,0.4); + color: #ffb0b0; + font-weight: 500; + margin-bottom: 6px; +} +.${CSS_PREFIX}-conflict-banner-msg { flex: 1 1 auto; font-size: 12px; } + +/* A/M/D badges rendered in the workspace file list (renderFileList) — global + selectors, not prefixed, because they live in a different DOM subtree. + Mirrors the existing .source-badge geometry so the two badge styles align. */ +.sharing-status { + display: inline-block; + min-width: 14px; padding: 0 4px; margin-left: 4px; + border-radius: 3px; + font-family: ui-monospace, monospace; + font-size: 10px; font-weight: 700; line-height: 14px; + text-align: center; + vertical-align: middle; +} +.sharing-added { background: rgba(110,230,110,0.18); color: #6e6; } +.sharing-modified { background: rgba(255,200,90,0.18); color: #fc6; } +.sharing-deleted { background: rgba(255,120,120,0.18); color: #f88; } +.sharing-pending-pull { + background: rgba(80,140,200,0.20); color: #88c8ff; + font-weight: 700; +} +.sharing-conflict { + background: rgba(255,90,90,0.25); color: #ffb0b0; + font-weight: 700; + border: 1px solid rgba(255,90,90,0.55); +} +.sharing-conflict-sibling { + background: rgba(255,170,90,0.15); color: #ffc890; + font-weight: 500; + font-style: italic; + text-transform: uppercase; + letter-spacing: 0.04em; +} +li.fade-conflict-sibling { + opacity: 0.85; +} + +/* Monaco gutter decorations — narrow vertical bars in the editor margin. + linesDecorationsClassName attaches these to the gutter column. */ +.sharing-gutter { + width: 3px !important; + margin-left: 3px; +} +/* Tri-state gutter (see line-diff.ts → lineDiffTriState): + - unsaved = edits since the latest local save (live in the editor) + - saved = edits already in a local save, not yet published + The colours match the panel's chips so it all reads as one system. */ +.sharing-gutter-unsaved { background-color: #ffb74d; } +.sharing-gutter-saved { background-color: #b88aff; } +/* Deletion anchors stack a coloured border on the marker line. Unsaved + deletions win when they overlap saved deletions (border-top wins via + declaration order in the class list). */ +.sharing-gutter-deletion-above { border-top: 4px solid transparent; } +.sharing-gutter-deletion-below { border-bottom: 4px solid transparent; } +.sharing-gutter-deletion-unsaved.sharing-gutter-deletion-above { border-top-color: #f44336; } +.sharing-gutter-deletion-unsaved.sharing-gutter-deletion-below { border-bottom-color: #f44336; } +.sharing-gutter-deletion-saved.sharing-gutter-deletion-above { border-top-color: #b88aff; } +.sharing-gutter-deletion-saved.sharing-gutter-deletion-below { border-bottom-color: #b88aff; } + +/* Conflict list styling — distinct from regular staged-change rows. */ +.${CSS_PREFIX}-conflict { + display: flex; flex-direction: column; gap: 4px; + padding: 6px 8px; + background: rgba(255,120,120,0.06); + border-left: 2px solid #f88; + margin-bottom: 2px; + font-family: ui-monospace, monospace; + font-size: 12px; +} +.${CSS_PREFIX}-conflict-row { + display: flex; align-items: center; gap: 8px; +} +`; + document.head.appendChild(style); +} + +// Re-exported for the file-list-badge code to consume. +export { pathStatusHint, statusGlyph } from './file-status'; +export type { FileStatus } from './file-status'; diff --git a/Playground/src/sharing/conflict-editor.ts b/Playground/src/sharing/conflict-editor.ts new file mode 100644 index 0000000..54ff6bd --- /dev/null +++ b/Playground/src/sharing/conflict-editor.ts @@ -0,0 +1,348 @@ +// Monaco-backed conflict resolution editor. One instance per conflicting +// file; mounts into a dedicated dockview panel. +// +// UI: +// ┌─ Header ─────────────────────────────────────────────────────────────┐ +// │ N conflict(s) remaining [Save & close] │ +// ├─ Per-region toolbar (one row per region in the file) ───────────────┤ +// │ #1 line 14 [Accept mine] [Accept theirs] [Both] [Jump to →] │ +// │ #2 line 42 [Accept mine] [Accept theirs] [Both] [Jump to →] │ +// ├─ Monaco editor on the file's existing model ────────────────────────┤ +// │ (full editing; the buttons just patch the model with the right │ +// │ text and re-render) │ +// └──────────────────────────────────────────────────────────────────────┘ +// +// The model is owned by the host (main.ts opens the file via the regular +// tab system first, then hands the model to this module). Changes here +// flow through Monaco's onDidChangeContent the same way as a normal edit, +// so the collaboration gutter + autosave + file-list badge stay coherent. + +import * as monaco from 'monaco-editor'; +import { parseConflictRegions, type ParsedConflictRegion, hasConflictMarkers } from './diff3'; +import { getLogger } from '../log-bus'; + +const CSS_PREFIX = 'fade-conflict'; +const STYLE_ID = `${CSS_PREFIX}-styles`; + +export interface ConflictEditorOptions { + container: HTMLElement; + /** Path of the file (for header + log messages + temp-model URI). */ + path: string; + /** Initial content of the file (with `<<<<<<<...>>>>>>>` markers). + * The editor creates its own throwaway Monaco model from this; the + * regular tab's model is left alone, and edits only land in OPFS via + * `onSave`. */ + initialContent: string; + /** Language id for syntax highlighting (e.g. 'fade', 'json'). Default + * is plaintext. */ + languageId?: string; + /** Fired when the user clicks **Save & close** with no markers left. + * The host writes the supplied text back to OPFS and closes the panel. */ + onSave: (path: string, content: string) => Promise | void; + /** Fired when the user clicks **Close** without saving — discards in- + * memory edits. */ + onClose: () => void; +} + +export interface ConflictEditorHandle { + dispose(): void; +} + +export function mountConflictEditor(opts: ConflictEditorOptions): ConflictEditorHandle { + injectStylesOnce(); + const log = getLogger('sharing'); + const root = opts.container; + root.classList.add(`${CSS_PREFIX}-root`); + root.replaceChildren(); + + // ─── DOM ─────────────────────────────────────────────────────────────── + const header = el('div', `${CSS_PREFIX}-header`); + const title = el('div', `${CSS_PREFIX}-title`); + title.textContent = opts.path; + const counter = el('div', `${CSS_PREFIX}-counter`); + const closeBtn = button('Discard & close', () => { + log.info(`Discarded conflict edits for ${opts.path}`); + opts.onClose(); + }, 'ghost'); + const doneBtn = button('Save & close', async () => { + if (hasConflictMarkers(model.getValue())) { + log.warn(`Cannot save ${opts.path}: still has conflict markers`); + counter.classList.add(`${CSS_PREFIX}-shake`); + setTimeout(() => counter.classList.remove(`${CSS_PREFIX}-shake`), 400); + return; + } + log.info(`Resolved ${opts.path}`); + await opts.onSave(opts.path, model.getValue()); + }, 'primary'); + const headerActions = el('div', `${CSS_PREFIX}-header-actions`); + headerActions.append(closeBtn, doneBtn); + header.append(title, counter, headerActions); + + const regionList = el('div', `${CSS_PREFIX}-regions`); + + const monacoHost = el('div', `${CSS_PREFIX}-editor-host`); + + root.append(header, regionList, monacoHost); + + // ─── Throwaway model holding the in-flight merge ────────────────────── + // We never use the regular tab's model directly — that would trigger + // the editor's 600ms autosave-to-OPFS on every Accept-mine click, and + // the user reasonably expects edits to stay in-memory until Save. + // A URI under `inmemory://conflict/` is unique to this panel + // instance; we dispose it on close. + const modelUri = monaco.Uri.parse(`inmemory://conflict/${encodeURIComponent(opts.path)}`); + // If a stale model survived a previous panel close (shouldn't happen + // with our dispose path, but defensive), drop it before recreating. + const stale = monaco.editor.getModel(modelUri); + if (stale) stale.dispose(); + const model = monaco.editor.createModel(opts.initialContent, opts.languageId, modelUri); + + const editor = monaco.editor.create(monacoHost, { + model, + automaticLayout: true, + readOnly: false, + minimap: { enabled: false }, + renderLineHighlight: 'all', + }); + + // Decoration collection for the conflict-region highlighting. + const decorations = editor.createDecorationsCollection([]); + + function refresh() { + const regions = parseConflictRegions(model.getValue()); + renderRegions(regions); + applyDecorations(regions); + updateCounter(regions.length); + } + + function updateCounter(remaining: number) { + if (remaining === 0) { + counter.textContent = 'no conflicts remaining — ready to save'; + counter.className = `${CSS_PREFIX}-counter ${CSS_PREFIX}-counter-clean`; + doneBtn.removeAttribute('disabled'); + doneBtn.title = ''; + } else { + counter.textContent = `${remaining} conflict${remaining === 1 ? '' : 's'} remaining`; + counter.className = `${CSS_PREFIX}-counter ${CSS_PREFIX}-counter-busy`; + doneBtn.setAttribute('disabled', 'true'); + doneBtn.title = 'Resolve all conflict markers first'; + } + } + + function renderRegions(regions: ParsedConflictRegion[]) { + regionList.replaceChildren(); + if (regions.length === 0) return; + regions.forEach((r, i) => { + const row = el('div', `${CSS_PREFIX}-region-row`); + const label = el('div', `${CSS_PREFIX}-region-label`); + label.textContent = `#${i + 1} line ${r.startLine}`; + const acceptMine = button(`Accept mine${r.oursLabel ? ` (${r.oursLabel})` : ''}`, + () => resolveRegion(r, 'mine'), 'mine-small'); + const acceptTheirs = button(`Accept theirs${r.theirsLabel ? ` (${r.theirsLabel})` : ''}`, + () => resolveRegion(r, 'theirs'), 'theirs-small'); + const acceptBoth = button('Accept both', () => resolveRegion(r, 'both'), 'ghost-small'); + const jump = button('Jump to', () => { + editor.revealLineNearTop(r.startLine); + editor.setPosition({ lineNumber: r.startLine, column: 1 }); + editor.focus(); + }, 'ghost-small'); + row.append(label, acceptMine, acceptTheirs, acceptBoth, jump); + regionList.append(row); + }); + } + + function applyDecorations(regions: ParsedConflictRegion[]) { + const decos: monaco.editor.IModelDeltaDecoration[] = []; + for (const r of regions) { + // Highlight the "ours" lines (between start+1 and mid-1). + if (r.midLine > r.startLine + 1) { + decos.push({ + range: new monaco.Range(r.startLine + 1, 1, r.midLine - 1, 1), + options: { isWholeLine: true, className: `${CSS_PREFIX}-ours-bg` }, + }); + } + // Highlight the "theirs" lines. + if (r.endLine > r.midLine + 1) { + decos.push({ + range: new monaco.Range(r.midLine + 1, 1, r.endLine - 1, 1), + options: { isWholeLine: true, className: `${CSS_PREFIX}-theirs-bg` }, + }); + } + // Marker lines: subtle bg so they're easier to spot. + decos.push({ + range: new monaco.Range(r.startLine, 1, r.startLine, 1), + options: { isWholeLine: true, className: `${CSS_PREFIX}-marker-bg` }, + }); + decos.push({ + range: new monaco.Range(r.midLine, 1, r.midLine, 1), + options: { isWholeLine: true, className: `${CSS_PREFIX}-marker-bg` }, + }); + decos.push({ + range: new monaco.Range(r.endLine, 1, r.endLine, 1), + options: { isWholeLine: true, className: `${CSS_PREFIX}-marker-bg` }, + }); + } + decorations.set(decos); + } + + function resolveRegion(r: ParsedConflictRegion, choice: 'mine' | 'theirs' | 'both') { + let replacement: string; + switch (choice) { + case 'mine': replacement = r.ours.join('\n'); break; + case 'theirs': replacement = r.theirs.join('\n'); break; + case 'both': + // ours followed by theirs, deduped trivially if identical. + replacement = (r.ours.join('\n') === r.theirs.join('\n')) + ? r.ours.join('\n') + : r.ours.join('\n') + (r.ours.length && r.theirs.length ? '\n' : '') + r.theirs.join('\n'); + break; + } + // Replace lines [startLine..endLine] (inclusive) with the chosen + // text. Monaco ranges are end-exclusive on column, so we go from + // (startLine, 1) to (endLine + 1, 1) to absorb the trailing newline. + const range = new monaco.Range(r.startLine, 1, r.endLine + 1, 1); + const text = replacement.length > 0 ? replacement + '\n' : ''; + model.pushEditOperations( + [], + [{ range, text }], + () => null, + ); + log.info(`Accepted ${choice} for ${opts.path} at line ${r.startLine}`); + // refresh() is triggered by the onDidChangeContent listener below. + } + + const sub = model.onDidChangeContent(() => refresh()); + refresh(); + + return { + dispose() { + sub.dispose(); + decorations.clear(); + editor.dispose(); + // The temp model is private to this panel — dispose it so it + // doesn't linger in Monaco's global model registry. + model.dispose(); + root.replaceChildren(); + root.classList.remove(`${CSS_PREFIX}-root`); + }, + }; +} + +// ─── DOM + style helpers ─────────────────────────────────────────────────── + +function el(tag: K, cls: string): HTMLElementTagNameMap[K] { + const n = document.createElement(tag); + n.className = cls; + return n; +} + +type BtnVariant = 'primary' | 'ghost' | 'mine-small' | 'theirs-small' | 'ghost-small'; +function button(label: string, onClick: () => void | Promise, variant: BtnVariant): HTMLButtonElement { + const b = document.createElement('button'); + b.className = `${CSS_PREFIX}-btn ${CSS_PREFIX}-btn-${variant}`; + b.type = 'button'; + b.textContent = label; + b.onclick = () => { void onClick(); }; + return b; +} + +function injectStylesOnce(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` +.${CSS_PREFIX}-root { + display: flex; flex-direction: column; + height: 100%; box-sizing: border-box; + overflow: hidden; + color: var(--vscode-foreground, #ddd); + font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; +} +.${CSS_PREFIX}-header { + display: flex; align-items: center; gap: 12px; + padding: 6px 12px; + border-bottom: 1px solid var(--vscode-panel-border, #333); + flex-shrink: 0; +} +.${CSS_PREFIX}-title { + font-family: ui-monospace, monospace; font-size: 12px; + flex-shrink: 0; +} +.${CSS_PREFIX}-counter { + flex: 1 1 auto; + font-size: 12px; + padding: 2px 8px; + border-radius: 3px; +} +.${CSS_PREFIX}-counter-busy { background: rgba(255,140,90,0.15); color: #ffb074; } +.${CSS_PREFIX}-counter-clean { background: rgba(110,230,110,0.15); color: #8e8; } +.${CSS_PREFIX}-counter.${CSS_PREFIX}-shake { + animation: ${CSS_PREFIX}-shake 0.3s; +} +@keyframes ${CSS_PREFIX}-shake { + 0%,100% { transform: translateX(0); } + 25% { transform: translateX(-4px); } + 75% { transform: translateX(4px); } +} +.${CSS_PREFIX}-header-actions { display: flex; gap: 6px; } +.${CSS_PREFIX}-regions { + border-bottom: 1px solid var(--vscode-panel-border, #333); + padding: 4px 8px; + flex-shrink: 0; + max-height: 30vh; overflow-y: auto; + background: rgba(255,255,255,0.02); +} +.${CSS_PREFIX}-region-row { + display: flex; align-items: center; gap: 6px; + padding: 3px 0; + flex-wrap: wrap; +} +.${CSS_PREFIX}-region-label { + font-family: ui-monospace, monospace; font-size: 12px; + color: #fc6; + min-width: 110px; +} +.${CSS_PREFIX}-editor-host { + flex: 1 1 auto; min-height: 0; +} +/* Per-side line backgrounds in the editor. Subtle so the editor text is + still readable; the marker lines stand out more strongly. */ +.${CSS_PREFIX}-ours-bg { background: rgba(110,230,110,0.10); } +.${CSS_PREFIX}-theirs-bg { background: rgba(120,180,255,0.10); } +.${CSS_PREFIX}-marker-bg { background: rgba(255,140,90,0.18); } + +.${CSS_PREFIX}-btn { + appearance: none; border: 0; cursor: pointer; + padding: 4px 10px; border-radius: 4px; + font: inherit; font-size: 12px; font-weight: 500; + transition: filter 0.1s; +} +.${CSS_PREFIX}-btn:hover { filter: brightness(1.15); } +.${CSS_PREFIX}-btn:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } +.${CSS_PREFIX}-btn-primary { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); +} +.${CSS_PREFIX}-btn-ghost { + background: transparent; + color: inherit; + border: 1px solid var(--vscode-panel-border, #555); +} +.${CSS_PREFIX}-btn-ghost-small { + background: transparent; color: inherit; + border: 1px solid var(--vscode-panel-border, #555); + padding: 2px 8px; font-size: 11px; +} +.${CSS_PREFIX}-btn-mine-small { + background: rgba(110,230,110,0.18); color: #afe9af; + border: 1px solid rgba(110,230,110,0.4); + padding: 2px 8px; font-size: 11px; +} +.${CSS_PREFIX}-btn-theirs-small { + background: rgba(120,180,255,0.18); color: #b8d6ff; + border: 1px solid rgba(120,180,255,0.4); + padding: 2px 8px; font-size: 11px; +} +`; + document.head.appendChild(style); +} diff --git a/Playground/src/sharing/diff-viewer.ts b/Playground/src/sharing/diff-viewer.ts new file mode 100644 index 0000000..47a08f3 --- /dev/null +++ b/Playground/src/sharing/diff-viewer.ts @@ -0,0 +1,156 @@ +// Read-only Monaco diff editor for "show me what changed" affordances. +// Opens in its own dockview tab, mirroring conflict-editor's lifecycle. +// +// One panel per (context, path) — the panel id encodes both so opening +// the same diff twice just re-activates the existing tab. Params carry +// the resolved before/after strings + display labels; nothing async +// inside the component itself, so init() is essentially synchronous +// once dockview hands us params. + +import * as monaco from 'monaco-editor'; + +export interface DiffViewerParams { + /** Title for the dock tab. Caller composes context — e.g. + * "main.fbasic (Publish preview)" or "main.fbasic (Save #3)". */ + title: string; + /** Path is informational here — used in placeholder messages when + * one side is null ("deleted") or the file is new. */ + path: string; + /** Monaco language id for syntax highlighting (e.g. "fade", + * "json", "plaintext"). */ + languageId: string; + /** Original ("before") content. Null = file didn't exist on the + * before side (added). */ + beforeText: string | null; + /** New ("after") content. Null = file was deleted. */ + afterText: string | null; + /** Display labels for the two sides — show up in the editor's + * side-by-side header. Defaults: "Before" / "After". */ + beforeLabel?: string; + afterLabel?: string; +} + +export interface DiffViewerComponent { + element: HTMLElement; + // Loose param typing mirrors dockview's GroupPanelPartInitParameters + // (Record) so this component is structurally + // assignable to IContentRenderer. We validate the shape inside. + init(parameters?: { params?: any }): void; + update?(event: { params: any }): void; + dispose(): void; +} + +/** Build a dockview-compatible component. `initialParams` is optional + * because dockview calls init() with the real params right after + * createComponent — but having a default avoids a flash of "no + * params" content. */ +export function createDiffViewer(initialParams?: DiffViewerParams): DiffViewerComponent { + const root = document.createElement('div'); + root.style.display = 'flex'; + root.style.flexDirection = 'column'; + root.style.height = '100%'; + root.style.width = '100%'; + root.style.background = 'var(--vscode-editor-background, #1e1e1e)'; + + // Header strip — labels + path. Kept small; the diff editor's own + // gutters do most of the visual work. + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.alignItems = 'center'; + header.style.gap = '12px'; + header.style.padding = '4px 10px'; + header.style.borderBottom = '1px solid var(--vscode-panel-border, #333)'; + header.style.font = '12px/1.4 ui-sans-serif, system-ui, sans-serif'; + header.style.color = 'var(--vscode-foreground, #ddd)'; + header.style.flexShrink = '0'; + const pathSpan = document.createElement('span'); + pathSpan.style.fontFamily = 'ui-monospace, monospace'; + pathSpan.style.opacity = '0.85'; + const sidesSpan = document.createElement('span'); + sidesSpan.style.opacity = '0.55'; + sidesSpan.style.fontSize = '11px'; + header.append(pathSpan, sidesSpan); + root.append(header); + + const editorHost = document.createElement('div'); + editorHost.style.flex = '1 1 auto'; + editorHost.style.minHeight = '0'; + editorHost.style.position = 'relative'; + root.append(editorHost); + + let editor: monaco.editor.IStandaloneDiffEditor | null = null; + let originalModel: monaco.editor.ITextModel | null = null; + let modifiedModel: monaco.editor.ITextModel | null = null; + + function render(params: DiffViewerParams) { + pathSpan.textContent = params.path; + const beforeLabel = params.beforeLabel ?? 'Before'; + const afterLabel = params.afterLabel ?? 'After'; + sidesSpan.textContent = `${beforeLabel} → ${afterLabel}`; + + const beforeContent = params.beforeText ?? ''; + const afterContent = params.afterText ?? ''; + + if (!editor) { + // Build a throwaway pair of models — unique URIs so they + // can't collide with the regular editor's tab models. + const stamp = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + originalModel = monaco.editor.createModel( + beforeContent, + params.languageId, + monaco.Uri.parse(`inmemory://diff-before/${stamp}/${params.path}`), + ); + modifiedModel = monaco.editor.createModel( + afterContent, + params.languageId, + monaco.Uri.parse(`inmemory://diff-after/${stamp}/${params.path}`), + ); + editor = monaco.editor.createDiffEditor(editorHost, { + readOnly: true, + originalEditable: false, + automaticLayout: true, + renderSideBySide: true, + renderOverviewRuler: true, + ignoreTrimWhitespace: false, + theme: 'vs-dark', + fontSize: 12, + }); + editor.setModel({ original: originalModel, modified: modifiedModel }); + } else { + // Already initialised — just swap the contents. + originalModel?.setValue(beforeContent); + modifiedModel?.setValue(afterContent); + } + + // Placeholder hints for added / deleted files — Monaco diff + // alone renders empty-vs-content cleanly but a label makes the + // intent obvious to a casual viewer. + if (params.beforeText === null) { + sidesSpan.textContent = `(added) → ${afterLabel}`; + } else if (params.afterText === null) { + sidesSpan.textContent = `${beforeLabel} → (deleted)`; + } + } + + if (initialParams) render(initialParams); + + return { + element: root, + init(parameters) { + const p = parameters?.params as DiffViewerParams | undefined; + if (p && typeof p.path === 'string') render(p); + }, + update(event) { + const p = event.params as DiffViewerParams; + if (p && typeof p.path === 'string') render(p); + }, + dispose() { + editor?.dispose(); + editor = null; + originalModel?.dispose(); + originalModel = null; + modifiedModel?.dispose(); + modifiedModel = null; + }, + }; +} diff --git a/Playground/src/sharing/diff3.test.ts b/Playground/src/sharing/diff3.test.ts new file mode 100644 index 0000000..2912a9f --- /dev/null +++ b/Playground/src/sharing/diff3.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { diff3Merge, hasConflictMarkers, parseConflictRegions } from './diff3'; + +describe('diff3Merge', () => { + it('all three identical → no conflicts, unchanged output', () => { + const r = diff3Merge('a\nb\nc\n', 'a\nb\nc\n', 'a\nb\nc\n'); + expect(r.hasConflicts).toBe(false); + expect(r.merged).toBe('a\nb\nc\n'); + }); + + it('only ours changed → take ours', () => { + const r = diff3Merge('a\nb\nc\n', 'a\nB\nc\n', 'a\nb\nc\n'); + expect(r.hasConflicts).toBe(false); + expect(r.merged).toBe('a\nB\nc\n'); + }); + + it('only theirs changed → take theirs', () => { + const r = diff3Merge('a\nb\nc\n', 'a\nb\nc\n', 'a\nb\nZ\n'); + expect(r.hasConflicts).toBe(false); + expect(r.merged).toBe('a\nb\nZ\n'); + }); + + it('both changed identically → no conflict, single take', () => { + const r = diff3Merge('a\nb\nc\n', 'a\nX\nc\n', 'a\nX\nc\n'); + expect(r.hasConflicts).toBe(false); + expect(r.merged).toBe('a\nX\nc\n'); + }); + + it('non-overlapping changes → both applied without conflict', () => { + // base: a b c d e + // ours: a B c d e (changed line 2) + // theirs: a b c D e (changed line 4) + const r = diff3Merge('a\nb\nc\n d\ne\n'.replace(' ', ''), 'a\nB\nc\nd\ne\n', 'a\nb\nc\nD\ne\n'); + expect(r.hasConflicts).toBe(false); + expect(r.merged).toBe('a\nB\nc\nD\ne\n'); + }); + + it('overlapping changes → conflict with markers', () => { + const r = diff3Merge('a\nb\nc\n', 'a\nMINE\nc\n', 'a\nTHEIRS\nc\n'); + expect(r.hasConflicts).toBe(true); + expect(r.conflicts.length).toBe(1); + expect(r.merged).toBe('a\n<<<<<<< ours\nMINE\n=======\nTHEIRS\n>>>>>>> theirs\nc\n'); + }); + + it('label override appears in the markers', () => { + const r = diff3Merge('x\n', 'A\n', 'B\n', { oursLabel: 'my-branch', theirsLabel: 'main' }); + expect(r.merged).toContain('<<<<<<< my-branch'); + expect(r.merged).toContain('>>>>>>> main'); + }); + + it('pure addition on each side at different ends → merged', () => { + // base: b c + // ours: A b c (prepend) + // theirs: b c Z (append) + const r = diff3Merge('b\nc\n', 'A\nb\nc\n', 'b\nc\nZ\n'); + expect(r.hasConflicts).toBe(false); + expect(r.merged).toBe('A\nb\nc\nZ\n'); + }); + + it('one side deletes a line the other left alone → take the deletion', () => { + const r = diff3Merge('a\nb\nc\n', 'a\nc\n', 'a\nb\nc\n'); + expect(r.hasConflicts).toBe(false); + expect(r.merged).toBe('a\nc\n'); + }); + + it('both sides delete the same line → take the deletion (same change)', () => { + const r = diff3Merge('a\nb\nc\n', 'a\nc\n', 'a\nc\n'); + expect(r.hasConflicts).toBe(false); + expect(r.merged).toBe('a\nc\n'); + }); + + it('empty base, both sides added different things → conflict', () => { + const r = diff3Merge('', 'mine\n', 'theirs\n'); + expect(r.hasConflicts).toBe(true); + expect(r.merged).toContain('mine'); + expect(r.merged).toContain('theirs'); + }); + + it('preserves a no-trailing-newline file', () => { + const r = diff3Merge('a\nb', 'a\nB', 'a\nb'); + expect(r.merged).toBe('a\nB'); + }); + + it('multi-region: independent conflicts at top and bottom', () => { + // base: a b c d e + // ours: A b c d E + // theirs: a' b c d e' + // Two non-overlapping conflicts. + const r = diff3Merge( + 'a\nb\nc\nd\ne\n', + 'A\nb\nc\nd\nE\n', + "a'\nb\nc\nd\ne'\n", + ); + // Both sides changed line 1 differently AND line 5 differently. + expect(r.hasConflicts).toBe(true); + expect(r.conflicts.length).toBe(2); + }); +}); + +describe('parseConflictRegions', () => { + it('finds a single well-formed region', () => { + const text = [ + 'a', + '<<<<<<< ours', + 'mine1', + 'mine2', + '=======', + 'theirs1', + '>>>>>>> theirs', + 'b', + ].join('\n'); + const r = parseConflictRegions(text); + expect(r).toHaveLength(1); + expect(r[0]).toMatchObject({ + startLine: 2, + midLine: 5, + endLine: 7, + ours: ['mine1', 'mine2'], + theirs: ['theirs1'], + oursLabel: 'ours', + theirsLabel: 'theirs', + }); + }); + + it('finds multiple regions in order', () => { + const text = [ + '<<<<<<< ours', + 'A', + '=======', + 'B', + '>>>>>>> theirs', + 'unchanged', + '<<<<<<< ours', + 'C', + '=======', + 'D', + '>>>>>>> theirs', + ].join('\n'); + const r = parseConflictRegions(text); + expect(r).toHaveLength(2); + expect(r[0].ours).toEqual(['A']); + expect(r[1].ours).toEqual(['C']); + }); + + it('skips malformed regions (start without end)', () => { + const text = '<<<<<<< ours\nstuff\n(no end marker)'; + expect(parseConflictRegions(text)).toEqual([]); + }); + + it('extracts labels from the marker lines', () => { + const text = '<<<<<<< feature-branch\nx\n=======\ny\n>>>>>>> main\n'; + const r = parseConflictRegions(text); + expect(r[0].oursLabel).toBe('feature-branch'); + expect(r[0].theirsLabel).toBe('main'); + }); +}); + +describe('hasConflictMarkers', () => { + it('detects all three marker lines', () => { + expect(hasConflictMarkers('a\n<<<<<<< ours\nx\n=======\ny\n>>>>>>> theirs\nb\n')).toBe(true); + }); + it('ignores text that mentions <<<<<<< inline (not at line start)', () => { + expect(hasConflictMarkers('this is fine: <<<<<<< inside a comment')).toBe(false); + }); + it('clean text returns false', () => { + expect(hasConflictMarkers('print "hi"\n')).toBe(false); + }); + it('returns true if any single marker line is present', () => { + expect(hasConflictMarkers('blah\n=======\nblah\n')).toBe(true); + }); +}); diff --git a/Playground/src/sharing/diff3.ts b/Playground/src/sharing/diff3.ts new file mode 100644 index 0000000..b64ac0c --- /dev/null +++ b/Playground/src/sharing/diff3.ts @@ -0,0 +1,296 @@ +// Three-way text merge. The classic problem: given a common ancestor +// (`base`), a local edit (`ours`), and a remote edit (`theirs`), produce a +// merged result that incorporates both sides' changes — emitting git-style +// conflict markers for regions where both sides changed the same lines +// differently. +// +// Algorithm (line-level, LCS-anchored): +// 1. Compute the LCS of (base, ours) and (base, theirs). Each gives us a +// list of "matching pairs" — base indices that appear unchanged in the +// respective other side. +// 2. Intersect by base index → candidate triple anchors. Filter to a +// monotonically-increasing chain in (base, ours, theirs) so the +// anchors form a valid alignment across all three. +// 3. Anchors partition each input into corresponding regions. For each +// region: +// - both sides unchanged → take base +// - only `ours` changed → take ours +// - only `theirs` changed → take theirs +// - both changed identically → take either +// - both changed differently → conflict +// +// LCS table is O(N*M) memory; fine for playground-sized files (<<10k +// lines). Migration to Myers' O((N+M)*D) is a future optimization. + +const MARK_OURS = '<<<<<<<'; +const MARK_MID = '======='; +const MARK_THEIRS = '>>>>>>>'; + +export interface ConflictRegion { + /** 1-based line in the merged output where the `<<<<<<<` marker sits. */ + line: number; + ours: string[]; + theirs: string[]; + base: string[]; +} + +export interface Diff3Result { + /** Merged text — newline-joined, with a trailing newline preserved iff + * the inputs had one. Contains `<<<<<<< ======= >>>>>>>` markers for + * unresolvable regions. */ + merged: string; + hasConflicts: boolean; + conflicts: ConflictRegion[]; +} + +export interface Diff3Options { + /** Label written after `<<<<<<<` (default: "ours"). */ + oursLabel?: string; + /** Label written after `>>>>>>>` (default: "theirs"). */ + theirsLabel?: string; +} + +/** + * Merge `ours` and `theirs` against their common ancestor `base`. Returns + * the merged text (with conflict markers where automatic merge couldn't + * resolve) and the list of conflict regions for UI reporting. + */ +export function diff3Merge( + base: string, + ours: string, + theirs: string, + opts: Diff3Options = {}, +): Diff3Result { + const oursLabel = opts.oursLabel ?? 'ours'; + const theirsLabel = opts.theirsLabel ?? 'theirs'; + + const { lines: baseLines, hadTrailingNewline } = splitPreservingTrailingNewline(base); + const oursLines = splitPreservingTrailingNewline(ours).lines; + const theirsLines = splitPreservingTrailingNewline(theirs).lines; + + // Trivial shortcuts. + if (arrayEq(oursLines, theirsLines)) { + return { merged: assemble(oursLines, hadTrailingNewline), hasConflicts: false, conflicts: [] }; + } + if (arrayEq(baseLines, oursLines)) { + return { merged: assemble(theirsLines, hadTrailingNewline), hasConflicts: false, conflicts: [] }; + } + if (arrayEq(baseLines, theirsLines)) { + return { merged: assemble(oursLines, hadTrailingNewline), hasConflicts: false, conflicts: [] }; + } + + const lcsA = lcsPairs(baseLines, oursLines); + const lcsB = lcsPairs(baseLines, theirsLines); + + // Intersect on base index; greedy-filter to monotonic-in-(a,b). + const lcsBByBase = new Map(); + for (const p of lcsB) lcsBByBase.set(p.base, p.other); + const candidates: Array<{ base: number; a: number; b: number }> = []; + for (const p of lcsA) { + const bIdx = lcsBByBase.get(p.base); + if (bIdx !== undefined) candidates.push({ base: p.base, a: p.other, b: bIdx }); + } + candidates.sort((x, y) => x.base - y.base); + const anchors: Array<{ base: number; a: number; b: number }> = []; + let lastA = -1; let lastB = -1; + for (const c of candidates) { + if (c.a > lastA && c.b > lastB) { + anchors.push(c); + lastA = c.a; + lastB = c.b; + } + } + + // Walk the input arrays in lockstep, partitioning by anchors. Each + // anchor consumes ONE line from each side (the matched line itself). + const out: string[] = []; + const conflicts: ConflictRegion[] = []; + let bi = 0; let ai = 0; let ti = 0; + for (const anc of anchors) { + emitRegion( + baseLines.slice(bi, anc.base), + oursLines.slice(ai, anc.a), + theirsLines.slice(ti, anc.b), + out, conflicts, oursLabel, theirsLabel, + ); + out.push(baseLines[anc.base]); + bi = anc.base + 1; + ai = anc.a + 1; + ti = anc.b + 1; + } + // Trailing region past the last anchor. + emitRegion( + baseLines.slice(bi), + oursLines.slice(ai), + theirsLines.slice(ti), + out, conflicts, oursLabel, theirsLabel, + ); + + return { + merged: assemble(out, hadTrailingNewline), + hasConflicts: conflicts.length > 0, + conflicts, + }; +} + +/** True iff the text contains any of the three diff3 conflict marker lines. + * Used by the UI to gate "Mark resolved" — once removed by the user, the + * file can be re-committed. */ +export function hasConflictMarkers(text: string): boolean { + return /^<{7} |^={7}$|^>{7} /m.test(text); +} + +export interface ParsedConflictRegion { + /** 1-based line number of the `<<<<<<<` marker. */ + startLine: number; + /** 1-based line number of the `=======` separator. */ + midLine: number; + /** 1-based line number of the `>>>>>>>` marker. */ + endLine: number; + ours: string[]; + theirs: string[]; + /** Raw text on the start marker after `<<<<<<<` (e.g. label). */ + oursLabel: string; + theirsLabel: string; +} + +/** + * Scan a file's text for diff3 conflict regions. Returns regions in order + * (top to bottom). Unterminated markers (a `<<<<<<<` without a matching + * `>>>>>>>`) are skipped — the parser only emits well-formed triplets. + * + * Used by the conflict editor to mark live regions and by the source- + * control panel to detect "still has markers" state. + */ +export function parseConflictRegions(text: string): ParsedConflictRegion[] { + const lines = text.split('\n'); + const out: ParsedConflictRegion[] = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const startMatch = /^<{7}\s?(.*)$/.exec(line); + if (!startMatch) { i++; continue; } + const oursLabel = startMatch[1] ?? ''; + const startLine = i + 1; + // Scan forward for the matching ======= and >>>>>>>. If we hit + // another <<<<<<< before either, the region is malformed and we + // skip past this start marker. + let mid = -1; + let end = -1; + let endLabel = ''; + for (let j = i + 1; j < lines.length; j++) { + if (/^<{7}\s?/.test(lines[j])) break; + if (mid < 0 && /^={7}$/.test(lines[j])) { mid = j; continue; } + const endMatch = /^>{7}\s?(.*)$/.exec(lines[j]); + if (endMatch && mid > 0) { + end = j; + endLabel = endMatch[1] ?? ''; + break; + } + } + if (mid > 0 && end > mid) { + out.push({ + startLine, + midLine: mid + 1, + endLine: end + 1, + ours: lines.slice(i + 1, mid), + theirs: lines.slice(mid + 1, end), + oursLabel, + theirsLabel: endLabel, + }); + i = end + 1; + } else { + i++; + } + } + return out; +} + +// ─── classification of a single anchor-bounded region ────────────────────── + +function emitRegion( + base: string[], + ours: string[], + theirs: string[], + out: string[], + conflicts: ConflictRegion[], + oursLabel: string, + theirsLabel: string, +): void { + const oursChanged = !arrayEq(base, ours); + const theirsChanged = !arrayEq(base, theirs); + + if (!oursChanged && !theirsChanged) { + for (const line of base) out.push(line); + return; + } + if (oursChanged && !theirsChanged) { + for (const line of ours) out.push(line); + return; + } + if (!oursChanged && theirsChanged) { + for (const line of theirs) out.push(line); + return; + } + // Both changed. + if (arrayEq(ours, theirs)) { + for (const line of ours) out.push(line); + return; + } + // True conflict — embed git-style markers. + const markerLine = out.length + 1; // 1-based line of the `<<<<<<<` + out.push(`${MARK_OURS} ${oursLabel}`); + for (const line of ours) out.push(line); + out.push(MARK_MID); + for (const line of theirs) out.push(line); + out.push(`${MARK_THEIRS} ${theirsLabel}`); + conflicts.push({ line: markerLine, ours, theirs, base }); +} + +// ─── line-pair LCS ───────────────────────────────────────────────────────── + +interface MatchPair { base: number; other: number; } + +function lcsPairs(base: string[], other: string[]): MatchPair[] { + const m = base.length; const n = other.length; + if (m === 0 || n === 0) return []; + const cols = n + 1; + const dp = new Int32Array((m + 1) * cols); + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + if (base[i] === other[j]) dp[i * cols + j] = dp[(i + 1) * cols + (j + 1)] + 1; + else dp[i * cols + j] = Math.max(dp[(i + 1) * cols + j], dp[i * cols + (j + 1)]); + } + } + const out: MatchPair[] = []; + let i = 0; let j = 0; + while (i < m && j < n) { + if (base[i] === other[j]) { out.push({ base: i, other: j }); i++; j++; } + else if (dp[(i + 1) * cols + j] >= dp[i * cols + (j + 1)]) i++; + else j++; + } + return out; +} + +// ─── helpers ─────────────────────────────────────────────────────────────── + +function arrayEq(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +interface SplitResult { lines: string[]; hadTrailingNewline: boolean; } + +function splitPreservingTrailingNewline(s: string): SplitResult { + if (s === '') return { lines: [], hadTrailingNewline: false }; + const hadTrailingNewline = s.endsWith('\n'); + let body = s; + if (hadTrailingNewline) body = body.slice(0, -1); + return { lines: body.split('\n'), hadTrailingNewline }; +} + +function assemble(lines: string[], trailingNewline: boolean): string { + if (lines.length === 0) return trailingNewline ? '\n' : ''; + return lines.join('\n') + (trailingNewline ? '\n' : ''); +} diff --git a/Playground/src/sharing/file-status.test.ts b/Playground/src/sharing/file-status.test.ts new file mode 100644 index 0000000..0c03ca2 --- /dev/null +++ b/Playground/src/sharing/file-status.test.ts @@ -0,0 +1,152 @@ +// Unit tests for file-status: the HashCache class + computeStatus, +// including cache-hit/cache-miss behavior. Uses MemoryWorkingTree from +// working-tree.ts as the fake working tree so we never touch OPFS. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HashCache, computeStatus, pathStatusHint, statusGlyph } from './file-status'; +import { gitBlobSha } from './hash'; +import { MemoryWorkingTree } from './working-tree'; + +const enc = new TextEncoder(); +const text = (s: string) => enc.encode(s); + +describe('HashCache', () => { + it('get/set roundtrips', () => { + const c = new HashCache(); + expect(c.get('a')).toBeUndefined(); + c.set('a', 'sha-a'); + expect(c.get('a')).toBe('sha-a'); + }); + it('invalidate drops one entry', () => { + const c = new HashCache(); + c.set('a', 'sha-a'); + c.set('b', 'sha-b'); + c.invalidate('a'); + expect(c.get('a')).toBeUndefined(); + expect(c.get('b')).toBe('sha-b'); + }); + it('invalidateAll wipes', () => { + const c = new HashCache(); + c.set('a', 'sha-a'); + c.set('b', 'sha-b'); + c.invalidateAll(); + expect(c.size()).toBe(0); + }); +}); + +describe('computeStatus (no cache)', () => { + let wt: MemoryWorkingTree; + beforeEach(() => { wt = new MemoryWorkingTree(); }); + + it('reports added/modified/deleted/unchanged correctly', async () => { + await wt.write('keep.txt', text('same')); + await wt.write('edit.txt', text('NEW')); + await wt.write('add.txt', text('extra')); + const keepHash = await gitBlobSha(text('same')); + const editBaseHash = await gitBlobSha(text('OLD')); + const goneHash = await gitBlobSha(text('removed content')); + const baseTree = { + 'keep.txt': keepHash, + 'edit.txt': editBaseHash, + 'gone.txt': goneHash, + }; + const out = await computeStatus(wt, baseTree); + const byPath = Object.fromEntries(out.map((e) => [e.path, e.status])); + expect(byPath['keep.txt']).toBe('unchanged'); + expect(byPath['edit.txt']).toBe('modified'); + expect(byPath['add.txt']).toBe('added'); + expect(byPath['gone.txt']).toBe('deleted'); + }); + + it('empty working tree + empty base → no entries', async () => { + expect(await computeStatus(wt, {})).toEqual([]); + }); +}); + +describe('computeStatus (with HashCache)', () => { + let wt: MemoryWorkingTree; + let cache: HashCache; + beforeEach(() => { wt = new MemoryWorkingTree(); cache = new HashCache(); }); + + it('first pass reads + hashes, second pass hits cache (no re-read)', async () => { + await wt.write('a.txt', text('one')); + await wt.write('b.txt', text('two')); + const aHash = await gitBlobSha(text('one')); + const bHash = await gitBlobSha(text('two')); + const base = { 'a.txt': aHash, 'b.txt': bHash }; + + // Spy on read to count IO. + const readSpy = vi.spyOn(wt, 'read'); + + await computeStatus(wt, base, cache); + expect(readSpy).toHaveBeenCalledTimes(2); + + readSpy.mockClear(); + await computeStatus(wt, base, cache); + // Second pass: cached → zero reads of the file bytes. + expect(readSpy).toHaveBeenCalledTimes(0); + }); + + it('invalidate(path) causes that one path to be re-read on the next pass', async () => { + await wt.write('a.txt', text('one')); + await wt.write('b.txt', text('two')); + await computeStatus(wt, {}, cache); // warm + + const readSpy = vi.spyOn(wt, 'read'); + cache.invalidate('a.txt'); + await computeStatus(wt, {}, cache); + expect(readSpy).toHaveBeenCalledTimes(1); + expect(readSpy).toHaveBeenCalledWith('a.txt'); + }); + + it('after edit + invalidate, status reflects new content', async () => { + await wt.write('a.txt', text('one')); + const baseHash = await gitBlobSha(text('one')); + const base = { 'a.txt': baseHash }; + + await computeStatus(wt, base, cache); // warm — status: unchanged + + // Simulate an external write that the caller knows about. + await wt.write('a.txt', text('TWO')); + cache.invalidate('a.txt'); + + const out = await computeStatus(wt, base, cache); + expect(out.find((e) => e.path === 'a.txt')?.status).toBe('modified'); + }); + + it('stale cache (no invalidate after write) reports stale status — caller bug, surfaced honestly', async () => { + // This test documents the contract: callers MUST invalidate when + // they write. If they don't, status reports last-known content. + await wt.write('a.txt', text('one')); + const base = { 'a.txt': await gitBlobSha(text('one')) }; + await computeStatus(wt, base, cache); + // Edit without invalidating. + await wt.write('a.txt', text('CHANGED')); + const out = await computeStatus(wt, base, cache); + // The cache still has the old hash → status comes back unchanged. + expect(out.find((e) => e.path === 'a.txt')?.status).toBe('unchanged'); + }); +}); + +describe('pathStatusHint', () => { + it('added: path in live, not in base', () => { + expect(pathStatusHint('a', new Set(['a']), {})).toBe('added'); + }); + it('deleted: path in base, not in live', () => { + expect(pathStatusHint('a', new Set(), { 'a': 'sha' })).toBe('deleted'); + }); + it('unchanged (conservative): in both — structural check only', () => { + // pathStatusHint doesn't hash; it can't tell modified from + // unchanged. The contract is "no false positives on modified". + expect(pathStatusHint('a', new Set(['a']), { 'a': 'any-sha' })).toBe('unchanged'); + }); +}); + +describe('statusGlyph', () => { + it('maps each status to its single-char glyph', () => { + expect(statusGlyph('added')).toBe('A'); + expect(statusGlyph('modified')).toBe('M'); + expect(statusGlyph('deleted')).toBe('D'); + expect(statusGlyph('unchanged')).toBe(''); + }); +}); diff --git a/Playground/src/sharing/file-status.ts b/Playground/src/sharing/file-status.ts new file mode 100644 index 0000000..4bb2f43 --- /dev/null +++ b/Playground/src/sharing/file-status.ts @@ -0,0 +1,110 @@ +// Per-file status (Added / Modified / Deleted / Unchanged) used both by the +// commit panel and by the workspace file-list badges. Computed by diffing +// the *current* working tree against the baseTree captured at last sync. +// +// Why this layer instead of Repo.stagedChanges? stagedChanges hashes every +// file on every call — the right primitive for an authoritative diff before +// a commit, but expensive when the file list re-renders on every keystroke. +// `quickStatus` skips hashing when size or mtime is unchanged; the badges +// don't need a cryptographic answer, just "did this file change." + +import { gitBlobSha } from './hash'; +import type { WorkingTree } from './working-tree'; + +export type FileStatus = 'added' | 'modified' | 'deleted' | 'unchanged'; + +export interface FileStatusEntry { + path: string; + status: FileStatus; +} + +/** + * Path → git-blob-sha cache. Computing a sha requires a full file read + + * sha-1 over the bytes, which on every autosave (every 600 ms typing burst) + * times every file in the project is the dominant cost of `computeStatus`. + * We cache shas keyed by path; callers are responsible for `invalidate(path)` + * on any write so the next status pass re-reads exactly the changed paths. + * + * The cache lives on the WorkingTree wrapper instance, *not* in a global, + * so each project's panel has its own — and so tests get a clean slate. + */ +export class HashCache { + private map = new Map(); + get(path: string): string | undefined { return this.map.get(path); } + set(path: string, sha: string): void { this.map.set(path, sha); } + invalidate(path: string): void { this.map.delete(path); } + invalidateAll(): void { this.map.clear(); } + /** For tests / instrumentation. */ + size(): number { return this.map.size; } +} + +/** + * Compute per-file status by comparing the live working tree against a known + * baseTree (path → content hash at last sync). + * + * Pass a `HashCache` to skip the read+hash for files whose content hasn't + * changed since the last call. Callers MUST invalidate the cache entry for + * any path they wrote since the last status pass — see the panel's + * `refreshStatusForFile` for the autosave hot path. + */ +export async function computeStatus( + wt: WorkingTree, + baseTree: Record, + cache?: HashCache, +): Promise { + const paths = await wt.list(); + const seen = new Set(); + const out: FileStatusEntry[] = []; + + for (const path of paths) { + seen.add(path); + let liveHash = cache?.get(path); + if (!liveHash) { + liveHash = await gitBlobSha(await wt.read(path)); + cache?.set(path, liveHash); + } + const baseHash = baseTree[path]; + if (baseHash === undefined) { + out.push({ path, status: 'added' }); + } else if (baseHash !== liveHash) { + out.push({ path, status: 'modified' }); + } else { + out.push({ path, status: 'unchanged' }); + } + } + for (const path of Object.keys(baseTree)) { + if (!seen.has(path)) out.push({ path, status: 'deleted' }); + } + + return out; +} + +/** + * Cheap status hint without hashing — purely structural ("is the path in + * baseTree?"). Used by the file-list badge renderer where a stale "modified" + * is fine; the commit panel re-hashes before showing the staged-changes list. + */ +export function pathStatusHint( + path: string, + livePaths: Set, + baseTree: Record, +): FileStatus { + const inLive = livePaths.has(path); + const inBase = path in baseTree; + if (inLive && !inBase) return 'added'; + if (!inLive && inBase) return 'deleted'; + // Live + base → we can't tell from structure alone whether content + // changed. Conservative: report 'unchanged' here; commit panel will + // upgrade to 'modified' after hashing. + return 'unchanged'; +} + +/** Single-char glyph for badge rendering. */ +export function statusGlyph(s: FileStatus): string { + switch (s) { + case 'added': return 'A'; + case 'modified': return 'M'; + case 'deleted': return 'D'; + case 'unchanged': return ''; + } +} diff --git a/Playground/src/sharing/git-types.ts b/Playground/src/sharing/git-types.ts new file mode 100644 index 0000000..9f2fa9a --- /dev/null +++ b/Playground/src/sharing/git-types.ts @@ -0,0 +1,64 @@ +// Git-shaped types the engine and adapter speak in. Replaces the old +// manifest format (which existed to give us "commits" on top of an +// arbitrary key-value store). With real git as the backing store, we don't +// need our own commit serialization — git's own objects ARE the format. +// +// Path conventions: forward-slash-delimited project-relative strings. + +export interface GitTreeEntry { + /** SHA-1 of `blob ${size}\0${bytes}` — what git itself uses as the blob's id. */ + blobSha: string; + /** File size in bytes. Informational; the engine compares by blobSha. */ + size?: number; + /** Git file mode. We only write `100644` (regular file); reads ignore. */ + mode?: '100644' | '100755' | '120000'; +} + +/** Flat path → entry map at one commit's tree. The git Trees API recursive + * mode returns this shape directly (after path-flattening). */ +export type GitTree = Record; + +export interface GitCommitMeta { + sha: string; + /** Parent commit SHAs. Root commit has []. v1 commits have one parent; + * a merge commit (not yet emitted by our flow) would have two. */ + parents: string[]; + treeSha: string; + message: string; + author: string; + /** ISO-8601 UTC string. */ + time: string; +} + +export interface TreeDiff { + added: string[]; + modified: string[]; + deleted: string[]; +} + +/** Path-level diff: paths in `next` not in `base` → added; same path, + * different blobSha → modified; in `base` not in `next` → deleted. */ +export function diffGitTrees(base: GitTree, next: GitTree): TreeDiff { + const added: string[] = []; + const modified: string[] = []; + const deleted: string[] = []; + for (const p of Object.keys(next)) { + if (!(p in base)) added.push(p); + else if (base[p].blobSha !== next[p].blobSha) modified.push(p); + } + for (const p of Object.keys(base)) { + if (!(p in next)) deleted.push(p); + } + added.sort(); modified.sort(); deleted.sort(); + return { added, modified, deleted }; +} + +/** Extract path → blobSha map (drops size/mode info). Useful for the sync + * index's `baseTree` which only cares about identity. */ +export function flattenTreeToBlobShas(tree: GitTree): Record { + const out: Record = {}; + for (const [path, entry] of Object.entries(tree)) { + out[path] = entry.blobSha; + } + return out; +} diff --git a/Playground/src/sharing/github-adapter.test.ts b/Playground/src/sharing/github-adapter.test.ts new file mode 100644 index 0000000..9ffdf55 --- /dev/null +++ b/Playground/src/sharing/github-adapter.test.ts @@ -0,0 +1,349 @@ +// GitHubAdapter unit tests against a mocked fetch. Run offline, never touch +// github.com. Each test programs the mock with the responses the Git Data +// API would return and asserts the adapter both made the right call and +// reacted to the response correctly. +// +// Wire-protocol coverage: +// - branchHead, getCommit, getTree (two-step), getBlob (base64 round-trip) +// - createBlob, createTree (with base_tree), createCommit +// - updateBranch fast-forward, 422 / 409 → HeadConflictError, 404 → POST refs +// - listCommits +// - empty-branch handling (branchHead 404 → null) + +import { beforeEach, describe, expect, it } from 'vitest'; +import { HeadConflictError } from './adapter'; +import { GitHubAdapter, GitHubApiError } from './github-adapter'; + +const OWNER = 'alice'; +const REPO = 'project'; +const TOKEN = 'ghp_test_token'; + +interface RecordedRequest { + url: string; + method: string; + headers: Record; + body: unknown; +} + +type Responder = (req: RecordedRequest) => Response | Promise; + +class FetchMock { + requests: RecordedRequest[] = []; + private handlers: Array<{ match: (req: RecordedRequest) => boolean; respond: Responder }> = []; + + on(matcher: string | RegExp | ((req: RecordedRequest) => boolean), respond: Responder): this { + const match = typeof matcher === 'function' + ? matcher + : typeof matcher === 'string' + ? (req: RecordedRequest) => req.url === matcher + : (req: RecordedRequest) => matcher.test(req.url); + this.handlers.push({ match, respond }); + return this; + } + + asFetch(): typeof fetch { + return (async (input: RequestInfo | URL, init: RequestInit = {}) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const headers: Record = {}; + const rawHeaders = init.headers ?? {}; + if (rawHeaders instanceof Headers) { + rawHeaders.forEach((v, k) => { headers[k.toLowerCase()] = v; }); + } else if (Array.isArray(rawHeaders)) { + for (const [k, v] of rawHeaders) headers[k.toLowerCase()] = v; + } else { + for (const [k, v] of Object.entries(rawHeaders)) headers[k.toLowerCase()] = String(v); + } + const req: RecordedRequest = { + url, + method: (init.method ?? 'GET').toUpperCase(), + headers, + body: init.body, + }; + this.requests.push(req); + for (const h of this.handlers) { + if (h.match(req)) return h.respond(req); + } + return new Response(JSON.stringify({ message: `unmocked: ${req.method} ${req.url}` }), { + status: 599, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + } +} + +function jsonResp(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +function adapterWith(fetchMock: FetchMock): GitHubAdapter { + return new GitHubAdapter({ + owner: OWNER, + repo: REPO, + token: TOKEN, + fetchImpl: fetchMock.asFetch(), + }); +} + +function b64Bytes(bytes: Uint8Array): string { + if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64'); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} +function bytesFromB64(s: string): Uint8Array { + if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(s, 'base64')); + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// ─── read path ────────────────────────────────────────────────────────────── + +describe('GitHubAdapter: read path', () => { + let fm: FetchMock; + let a: GitHubAdapter; + beforeEach(() => { fm = new FetchMock(); a = adapterWith(fm); }); + + it('branchHead returns the commit sha for main', async () => { + fm.on(/\/branches\/main$/, () => jsonResp({ commit: { sha: 'abc123' } })); + expect(await a.branchHead()).toBe('abc123'); + }); + + it('branchHead returns null on 404 (empty repo / branch not yet created)', async () => { + fm.on(/\/branches\/main$/, () => jsonResp({ message: 'Branch not found' }, 404)); + expect(await a.branchHead()).toBeNull(); + }); + + it('branchHead sends If-None-Match after the first call and treats 304 as "unchanged"', async () => { + // First call: 200 with ETag. Second call: must send If-None-Match + // with the captured ETag; we reply 304 (no body) and the adapter + // returns the cached sha without re-parsing. + let calls = 0; + fm.on(/\/branches\/main$/, (req) => { + calls++; + if (calls === 1) { + expect(req.headers['if-none-match']).toBeUndefined(); + return jsonResp({ commit: { sha: 'abc123' } }, 200, { ETag: '"etag-v1"' }); + } + expect(req.headers['if-none-match']).toBe('"etag-v1"'); + return new Response(null, { status: 304 }); + }); + expect(await a.branchHead()).toBe('abc123'); + expect(await a.branchHead()).toBe('abc123'); // served from cache + expect(calls).toBe(2); + }); + + it('branchHead refreshes the cached sha when 200 (ETag changed)', async () => { + let calls = 0; + fm.on(/\/branches\/main$/, () => { + calls++; + if (calls === 1) return jsonResp({ commit: { sha: 'abc123' } }, 200, { ETag: '"etag-v1"' }); + return jsonResp({ commit: { sha: 'def456' } }, 200, { ETag: '"etag-v2"' }); + }); + expect(await a.branchHead()).toBe('abc123'); + expect(await a.branchHead()).toBe('def456'); + }); + + it('getCommit normalizes parents to a sha array and exposes treeSha', async () => { + fm.on(/\/git\/commits\/c1$/, () => jsonResp({ + sha: 'c1', + tree: { sha: 't1' }, + parents: [{ sha: 'p1' }], + message: 'hello', + author: { name: 'alice', email: 'a@x', date: '2026-05-28T00:00:00Z' }, + })); + const c = await a.getCommit('c1'); + expect(c.parents).toEqual(['p1']); + expect(c.treeSha).toBe('t1'); + expect(c.author).toBe('alice'); + expect(c.message).toBe('hello'); + }); + + it('getTree does the two-step commit → tree resolution', async () => { + fm.on(/\/git\/commits\/c1$/, () => jsonResp({ + sha: 'c1', tree: { sha: 't1' }, parents: [], message: '', author: { name: 'a', email: 'a@x', date: '2026-01-01' }, + })); + fm.on(/\/git\/trees\/t1/, () => jsonResp({ + tree: [ + { path: 'src.fbasic', sha: 'b-src', mode: '100644', type: 'blob', size: 12 }, + { path: 'assets', sha: 't-assets', mode: '040000', type: 'tree' }, + { path: 'assets/hero.png', sha: 'b-hero', mode: '100644', type: 'blob', size: 4 }, + ], + })); + const out = await a.getTree('c1'); + // Tree-type entries are skipped — only blob paths survive. + expect(Object.keys(out).sort()).toEqual(['assets/hero.png', 'src.fbasic']); + expect(out['src.fbasic'].blobSha).toBe('b-src'); + expect(out['assets/hero.png'].blobSha).toBe('b-hero'); + }); + + it('getTree throws when the Trees API truncates (correctness safeguard)', async () => { + fm.on(/\/git\/commits\/c1$/, () => jsonResp({ + sha: 'c1', tree: { sha: 't1' }, parents: [], message: '', author: { name: 'a', email: 'a@x', date: '2026-01-01' }, + })); + fm.on(/\/git\/trees\/t1/, () => jsonResp({ tree: [], truncated: true })); + await expect(a.getTree('c1')).rejects.toThrow(/truncated/); + }); + + it('getBlob decodes base64 to raw bytes', async () => { + const original = new Uint8Array([10, 20, 30, 40, 50]); + fm.on(/\/git\/blobs\/b1$/, () => jsonResp({ content: b64Bytes(original), encoding: 'base64' })); + const out = await a.getBlob('b1'); + expect([...out]).toEqual([...original]); + }); +}); + +// ─── write path ───────────────────────────────────────────────────────────── + +describe('GitHubAdapter: write path', () => { + let fm: FetchMock; + let a: GitHubAdapter; + beforeEach(() => { fm = new FetchMock(); a = adapterWith(fm); }); + + it('createBlob POSTs base64-encoded bytes and returns the sha', async () => { + fm.on(/\/git\/blobs$/, (req) => { + expect(req.method).toBe('POST'); + const body = JSON.parse(req.body as string); + expect(body.encoding).toBe('base64'); + const decoded = bytesFromB64(body.content); + expect([...decoded]).toEqual([1, 2, 3]); + return jsonResp({ sha: 'b-new' }, 201); + }); + const out = await a.createBlob(new Uint8Array([1, 2, 3])); + expect(out.sha).toBe('b-new'); + }); + + it('createTree sends entries (including null-sha deletions) + base_tree', async () => { + fm.on(/\/git\/trees$/, (req) => { + expect(req.method).toBe('POST'); + const body = JSON.parse(req.body as string); + expect(body.base_tree).toBe('t-base'); + expect(body.tree).toEqual([ + { path: 'a.txt', mode: '100644', type: 'blob', sha: 'b-a' }, + { path: 'gone.txt', mode: '100644', type: 'blob', sha: null }, + ]); + return jsonResp({ sha: 't-new' }, 201); + }); + const out = await a.createTree({ + baseTreeSha: 't-base', + entries: [ + { path: 'a.txt', blobSha: 'b-a' }, + { path: 'gone.txt', blobSha: null }, + ], + }); + expect(out.sha).toBe('t-new'); + }); + + it('createCommit POSTs message + tree + parents and returns the sha', async () => { + fm.on(/\/git\/commits$/, (req) => { + const body = JSON.parse(req.body as string); + expect(body.message).toBe('hello'); + expect(body.tree).toBe('t1'); + expect(body.parents).toEqual(['p1']); + return jsonResp({ sha: 'c-new' }, 201); + }); + const out = await a.createCommit({ message: 'hello', treeSha: 't1', parents: ['p1'] }); + expect(out.sha).toBe('c-new'); + }); + + it('updateBranch PATCHes the ref with force:false', async () => { + fm.on(/\/git\/refs\/heads\/main$/, (req) => { + expect(req.method).toBe('PATCH'); + const body = JSON.parse(req.body as string); + expect(body).toEqual({ sha: 'c-new', force: false }); + return jsonResp({ ref: 'refs/heads/main', object: { sha: 'c-new' } }); + }); + await a.updateBranch('c-new'); + }); + + it('updateBranch translates 422 (non-fast-forward) into HeadConflictError', async () => { + let patchCalls = 0; + fm.on(/\/git\/refs\/heads\/main$/, (req) => { + if (req.method === 'PATCH') { + patchCalls++; + return jsonResp({ message: 'Update is not a fast forward' }, 422); + } + return jsonResp({}, 599); + }); + // The error path also re-reads branchHead for the actual sha. + fm.on(/\/branches\/main$/, () => jsonResp({ commit: { sha: 'actual-head' } })); + const err = await a.updateBranch('mine').catch((e) => e); + expect(err).toBeInstanceOf(HeadConflictError); + expect((err as HeadConflictError).actual).toBe('actual-head'); + expect(patchCalls).toBe(1); + }); + + it('updateBranch falls back to POST when the ref does not exist yet (404 on PATCH)', async () => { + let posted = false; + fm.on((req) => req.method === 'PATCH' && /\/git\/refs\/heads\/main$/.test(req.url), + () => jsonResp({ message: 'Reference does not exist' }, 404)); + fm.on((req) => req.method === 'POST' && /\/git\/refs$/.test(req.url), (req) => { + const body = JSON.parse(req.body as string); + expect(body.ref).toBe('refs/heads/main'); + expect(body.sha).toBe('c-new'); + posted = true; + return jsonResp({}, 201); + }); + await a.updateBranch('c-new'); + expect(posted).toBe(true); + }); +}); + +// ─── listCommits ──────────────────────────────────────────────────────────── + +describe('GitHubAdapter: listCommits', () => { + let fm: FetchMock; + let a: GitHubAdapter; + beforeEach(() => { fm = new FetchMock(); a = adapterWith(fm); }); + + it('parses the commits-feed shape into GitCommitMeta', async () => { + fm.on(/\/commits\?/, () => jsonResp([ + { + sha: 'c2', parents: [{ sha: 'c1' }], + commit: { + message: 'two', + tree: { sha: 't2' }, + author: { name: 'alice', date: '2026-05-28T01:00:00Z' }, + }, + }, + { + sha: 'c1', parents: [], + commit: { + message: 'one', + tree: { sha: 't1' }, + author: { name: 'alice', date: '2026-05-28T00:00:00Z' }, + }, + }, + ])); + const out = await a.listCommits({ start: 'c2', limit: 10 }); + expect(out.map((c) => ({ sha: c.sha, message: c.message }))).toEqual([ + { sha: 'c2', message: 'two' }, + { sha: 'c1', message: 'one' }, + ]); + expect(out[1].parents).toEqual([]); + }); + + it('returns [] on 409 (empty repo, no commits yet)', async () => { + fm.on(/\/commits\?/, () => jsonResp({ message: 'Git Repository is empty.' }, 409)); + expect(await a.listCommits()).toEqual([]); + }); +}); + +// ─── error surface ────────────────────────────────────────────────────────── + +describe('GitHubApiError', () => { + it('captures status and body and surfaces 401 distinctly from CAS', async () => { + const fm = new FetchMock(); + fm.on(/\/branches\/main$/, () => jsonResp({ message: 'Bad credentials' }, 401)); + const a = adapterWith(fm); + const err = await a.branchHead().catch((e) => e); + expect(err).toBeInstanceOf(GitHubApiError); + expect((err as GitHubApiError).status).toBe(401); + expect(JSON.stringify((err as GitHubApiError).body)).toContain('Bad credentials'); + expect((err as GitHubApiError).message).toContain('Bad credentials'); + }); +}); diff --git a/Playground/src/sharing/github-adapter.ts b/Playground/src/sharing/github-adapter.ts new file mode 100644 index 0000000..c461f49 --- /dev/null +++ b/Playground/src/sharing/github-adapter.ts @@ -0,0 +1,440 @@ +// GitHub GitAdapter — speaks the Git Data API directly. Replaces the prior +// "blobs-as-base64-files-under-objects/" Contents-API approach. +// +// What this means in practice: +// - User project files live at their natural paths in the repo +// (`src.fbasic`, `assets/hero.png`, …) — github.com renders the repo +// like any normal project. +// - Each commit is a real git commit, visible in the commits feed, with +// real parent/author/message metadata. +// - The "tree at commit" is just a git tree object; we walk it with the +// recursive Trees API in one round-trip. +// - CAS comes from the fast-forward rule on `PATCH refs/heads/{branch}`: +// if the branch moved between our read and write, the FF check fails +// and we surface HeadConflictError. No expected-old-sha parameter +// needed — the new commit's parent IS the expectation. +// +// Endpoints used: +// GET /branches/{branch} branchHead +// GET /git/commits/{sha} getCommit +// GET /git/trees/{treeSha}?recursive=1 getTree (after a getCommit hop) +// GET /git/blobs/{sha} getBlob (returns base64) +// POST /git/blobs createBlob +// POST /git/trees createTree +// POST /git/commits createCommit +// PATCH /git/refs/heads/{branch} updateBranch (FF-checked) +// GET /commits?sha=...&per_page=... listCommits + +import { HeadConflictError, type GitAdapter } from './adapter'; +import type { GitCommitMeta, GitTree } from './git-types'; +import { getLogger } from '../log-bus'; + +const adapterLog = getLogger('github-adapter'); + +interface TreeApiResponse { + tree: Array<{ path: string; sha: string; mode: string; type: string; size?: number }>; + truncated?: boolean; +} + +const API = 'https://api.github.com'; + +export interface GitHubAdapterOptions { + owner: string; + repo: string; + token: string; + branch?: string; // default 'main' + fetchImpl?: typeof fetch; // injectable for tests +} + +export interface CreateRepoOptions { + name: string; + description?: string; + private?: boolean; + token: string; + fetchImpl?: typeof fetch; +} + +export class GitHubApiError extends Error { + constructor(public status: number, public body: unknown, message: string) { + super(`GitHub ${status}: ${message}`); + this.name = 'GitHubApiError'; + } + static async from(r: Response): Promise { + const text = await r.text().catch(() => ''); + let body: unknown = text; + try { body = JSON.parse(text); } catch { /* keep as text */ } + const msg = (body && typeof body === 'object' && 'message' in body) + ? String((body as { message: unknown }).message) + : r.statusText || 'unknown error'; + return new GitHubApiError(r.status, body, msg); + } +} + +export class GitHubAdapter implements GitAdapter { + private readonly owner: string; + private readonly repo: string; + private readonly branch: string; + private token: string; + private readonly fetchImpl: typeof fetch; + + // Conditional-request bookkeeping for the polled branchHead endpoint. + // GitHub honors `If-None-Match` on most read endpoints and replies 304 + // (empty body, no quota cost on the primary rate limit's most-restrictive + // pool) when nothing has changed. Caching here turns a 5–30s poll into + // essentially-free network when the branch is idle. + private lastBranchEtag: string | null = null; + private lastBranchSha: string | null = null; + + constructor(opts: GitHubAdapterOptions) { + this.owner = opts.owner; + this.repo = opts.repo; + this.branch = opts.branch ?? 'main'; + this.token = opts.token; + this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis); + } + + // ─── setup (not part of the interface) ────────────────────────────────── + + /** Create a brand-new repo via `POST /user/repos` (auto_init=true so the + * branch exists out of the gate), returning a ready adapter. */ + static async createRepo(opts: CreateRepoOptions): Promise { + const fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis); + const body = await apiRequest(fetchImpl, opts.token, `${API}/user/repos`, { + method: 'POST', + body: JSON.stringify({ + name: opts.name, + description: opts.description ?? 'Fade playground project', + private: opts.private ?? false, + auto_init: true, + }), + }) as { owner: { login: string }; name: string; default_branch: string }; + return new GitHubAdapter({ + owner: body.owner.login, + repo: body.name, + branch: body.default_branch, + token: opts.token, + fetchImpl, + }); + } + + /** Open an existing repo. (No setup work; ready immediately.) */ + static open(opts: GitHubAdapterOptions): GitHubAdapter { + return new GitHubAdapter(opts); + } + + /** Owner/repo/branch the adapter is bound to. Useful for UI display. */ + info(): { owner: string; repo: string; branch: string } { + return { owner: this.owner, repo: this.repo, branch: this.branch }; + } + + // ─── read path ────────────────────────────────────────────────────────── + + async branchHead(): Promise { + // Use a raw fetch here (not `this.api`) so we can handle 304 without + // it being treated as an error. The conditional header is omitted + // on the first call and on cache invalidation. + const url = `${API}/repos/${this.owner}/${this.repo}/branches/${encodePathSeg(this.branch)}`; + const headers: Record = { + Authorization: `Bearer ${this.token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (this.lastBranchEtag) headers['If-None-Match'] = this.lastBranchEtag; + const r = await this.fetchImpl(url, { headers }); + if (r.status === 304) { + // Nothing changed since the last call — return the cached sha + // without touching the body. The ETag stays the same too. + return this.lastBranchSha; + } + if (r.status === 404) { + this.lastBranchEtag = null; + this.lastBranchSha = null; + return null; + } + if (!r.ok) throw await GitHubApiError.from(r); + const etag = r.headers.get('ETag'); + if (etag) this.lastBranchEtag = etag; + const body = await r.json() as { commit: { sha: string } }; + this.lastBranchSha = body.commit.sha; + return this.lastBranchSha; + } + + async getCommit(sha: string): Promise { + const c = await this.api(`/repos/${this.owner}/${this.repo}/git/commits/${sha}`) as { + sha: string; + tree: { sha: string }; + parents: Array<{ sha: string }>; + message: string; + author: { name: string; email: string; date: string }; + }; + return { + sha: c.sha, + parents: c.parents.map((p) => p.sha), + treeSha: c.tree.sha, + message: c.message, + author: c.author.name, + time: c.author.date, + }; + } + + async getTree(commitSha: string): Promise { + // Two-step: commit object → tree sha → recursive tree fetch. The + // Trees endpoint accepts a tree SHA or a ref name but NOT a commit + // SHA — passing a commit SHA returns an empty tree silently, which + // was a real bug in the prior implementation. + const commit = await this.getCommit(commitSha); + let t: TreeApiResponse; + try { + t = await this.api( + `/repos/${this.owner}/${this.repo}/git/trees/${commit.treeSha}?recursive=1`, + ) as TreeApiResponse; + } catch (e) { + // Observed in the wild on fresh repos: getCommit returns a + // tree.sha that the Trees endpoint 404s on for a brief window + // (data-API eventual consistency vs the commits endpoint). + // Retry once with the branch name as the ref — the Trees API + // resolves refs internally and tends to be more up-to-date than + // raw tree-sha lookups during this window. + // + // This only helps when the branch head IS this commit; if the + // user is fetching an older commit's tree, the retry would + // return the wrong data, so we only retry when the commit + // matches the current branch head. + if (e instanceof GitHubApiError && e.status === 404) { + const branchHeadSha = await this.branchHead().catch(() => null); + if (branchHeadSha === commitSha) { + adapterLog.warn( + `Trees API 404 on tree ${commit.treeSha.slice(0, 8)} (commit ${commitSha.slice(0, 8)}); retrying via branch ref "${this.branch}"`, + ); + t = await this.api( + `/repos/${this.owner}/${this.repo}/git/trees/${encodePathSeg(this.branch)}?recursive=1`, + ) as TreeApiResponse; + } else { + throw e; + } + } else { + throw e; + } + } + if (t.truncated) { + // Surface loudly — silently truncating would orphan reachable + // entries in any code path that relies on completeness. + throw new Error('git tree truncated; repo exceeded the Trees API single-call cap (100k entries / 7 MB)'); + } + const out: GitTree = {}; + for (const e of t.tree) { + if (e.type !== 'blob') continue; // skip subtree directory entries + out[e.path] = { + blobSha: e.sha, + mode: e.mode as '100644' | '100755' | '120000', + size: e.size, + }; + } + return out; + } + + async getBlob(blobSha: string): Promise { + const r = await this.api(`/repos/${this.owner}/${this.repo}/git/blobs/${blobSha}`) as { + content: string; + encoding: string; + }; + if (r.encoding !== 'base64') { + throw new Error(`unexpected blob encoding ${r.encoding}`); + } + return base64ToBytes(r.content); + } + + // ─── write path ───────────────────────────────────────────────────────── + + async createBlob(bytes: Uint8Array): Promise<{ sha: string }> { + const r = await this.api(`/repos/${this.owner}/${this.repo}/git/blobs`, { + method: 'POST', + body: JSON.stringify({ + content: bytesToBase64(bytes), + encoding: 'base64', + }), + }) as { sha: string }; + return { sha: r.sha }; + } + + async createTree(opts: { + baseTreeSha?: string; + entries: Array<{ path: string; blobSha: string | null }>; + }): Promise<{ sha: string }> { + const body: Record = { + tree: opts.entries.map((e) => ({ + path: e.path, + mode: '100644', + type: 'blob', + // null SHA means "remove this path from the tree" — git + // recognizes this in the Trees API. + sha: e.blobSha, + })), + }; + if (opts.baseTreeSha) body.base_tree = opts.baseTreeSha; + const r = await this.api(`/repos/${this.owner}/${this.repo}/git/trees`, { + method: 'POST', + body: JSON.stringify(body), + }) as { sha: string }; + return { sha: r.sha }; + } + + async createCommit(opts: { + message: string; + treeSha: string; + parents: string[]; + author?: { name: string; email: string }; + }): Promise<{ sha: string }> { + const body: Record = { + message: opts.message, + tree: opts.treeSha, + parents: opts.parents, + }; + if (opts.author) body.author = opts.author; + const r = await this.api(`/repos/${this.owner}/${this.repo}/git/commits`, { + method: 'POST', + body: JSON.stringify(body), + }) as { sha: string }; + return { sha: r.sha }; + } + + async updateBranch(commitSha: string): Promise { + try { + await this.api(`/repos/${this.owner}/${this.repo}/git/refs/heads/${encodePathSeg(this.branch)}`, { + method: 'PATCH', + body: JSON.stringify({ sha: commitSha, force: false }), + }); + } catch (e) { + // GitHub returns 422 with message "Update is not a fast forward" + // (or similar) when our new commit's parent isn't an ancestor of + // the branch's current head. Translate to HeadConflictError so + // the Repo engine knows to do pull-before-commit. + if (e instanceof GitHubApiError && (e.status === 422 || e.status === 409)) { + // Re-fetch the current branch head so the error carries the + // actual remote state — useful for the panel's UI. + let actual: string | null = null; + try { actual = await this.branchHead(); } catch { /* ignore */ } + throw new HeadConflictError(null, actual); + } + // The ref might not exist yet (initial commit on a brand-new repo + // without auto_init). In that case create the ref instead of + // patching. + if (e instanceof GitHubApiError && e.status === 404) { + await this.createBranchRef(commitSha); + return; + } + throw e; + } + } + + /** First-time branch creation (when PATCH ref returns 404). */ + private async createBranchRef(commitSha: string): Promise { + await this.api(`/repos/${this.owner}/${this.repo}/git/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/heads/${this.branch}`, sha: commitSha }), + }); + } + + // ─── log ──────────────────────────────────────────────────────────────── + + async listCommits(opts: { start?: string; limit?: number } = {}): Promise { + const params = new URLSearchParams(); + if (opts.start) params.set('sha', opts.start); + params.set('per_page', String(Math.min(opts.limit ?? 30, 100))); + try { + const arr = await this.api(`/repos/${this.owner}/${this.repo}/commits?${params}`) as Array<{ + sha: string; + parents: Array<{ sha: string }>; + commit: { + message: string; + tree: { sha: string }; + author: { name: string; date: string }; + }; + }>; + return arr.map((c) => ({ + sha: c.sha, + parents: c.parents.map((p) => p.sha), + treeSha: c.commit.tree.sha, + message: c.commit.message, + author: c.commit.author.name, + time: c.commit.author.date, + })); + } catch (e) { + // Empty repo or branch — return an empty log instead of throwing. + if (e instanceof GitHubApiError && (e.status === 404 || e.status === 409)) return []; + throw e; + } + } + + // ─── helpers ──────────────────────────────────────────────────────────── + + private async api(path: string, init: RequestInit = {}): Promise { + const url = path.startsWith('http') ? path : `${API}${path}`; + const headers: Record = { + Authorization: `Bearer ${this.token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (init.body && typeof init.body === 'string') headers['Content-Type'] = 'application/json'; + Object.assign(headers, init.headers ?? {}); + const method = init.method ?? 'GET'; + const r = await this.fetchImpl(url, { ...init, headers }); + if (!r.ok) { + const err = await GitHubApiError.from(r); + // Surface every API failure on the LogBus so the user can see it + // in the Logs panel. The thrown error still bubbles to the + // caller for control flow. + adapterLog.error(`${method} ${url.replace(API, '')} → ${r.status} ${err.message}`); + throw err; + } + if (r.status === 204) return null; + return await r.json(); + } +} + +async function apiRequest( + fetchImpl: typeof fetch, + token: string, + url: string, + init: RequestInit, +): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (init.body && typeof init.body === 'string') headers['Content-Type'] = 'application/json'; + Object.assign(headers, init.headers ?? {}); + const r = await fetchImpl(url, { ...init, headers }); + if (!r.ok) throw await GitHubApiError.from(r); + if (r.status === 204) return null; + return await r.json(); +} + +// Branch names with slashes (e.g. "feature/x") need URL-encoding for the +// `{branch}` path slot. Other special chars are rare but we encode anyway. +function encodePathSeg(s: string): string { + return encodeURIComponent(s); +} + +function bytesToBase64(bytes: Uint8Array): string { + if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64'); + let bin = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]); + } + return btoa(bin); +} + +function base64ToBytes(b64: string): Uint8Array { + const clean = b64.replace(/\s+/g, ''); + if (typeof Buffer !== 'undefined') { + const buf = Buffer.from(clean, 'base64'); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + const bin = atob(clean); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/Playground/src/sharing/github-auth-config.ts b/Playground/src/sharing/github-auth-config.ts new file mode 100644 index 0000000..60abf20 --- /dev/null +++ b/Playground/src/sharing/github-auth-config.ts @@ -0,0 +1,43 @@ +// GitHub auth — deployment-specific constants. +// +// Two values: the OAuth proxy worker URL and the GitHub App client ID. +// Both are public information (the client_id is shown to every user +// during the device-flow sign-in; the worker URL is a static target +// for browser fetches), so checking them into the repo is fine. +// +// To redeploy with different values: edit here, rebuild. There's no +// runtime override — keeping the auth surface trivially auditable is +// worth more than the configurability of env vars. + +/** Stateless CORS proxy in front of GitHub's device-flow endpoints. + * See ../../../oauth-proxy/ for the worker source. The proxy only + * relays `/login/device/code` and `/login/oauth/access_token`; it + * holds no credentials and stores no state. */ +export const OAUTH_PROXY_BASE_URL = 'https://fade-oauth-proxy.cdhannaphone.workers.dev'; + +/** Client ID for the OAuth/GitHub App backing the device flow. Public + * — anyone signing in sees this in the device-flow request. Found + * on the App's settings page in GitHub. The prefix tells you which + * kind it is: + * - `Ov23…` → OAuth App (requires `GITHUB_OAUTH_SCOPE` below) + * - `Iv23…` → GitHub App (scope is ignored; permissions are + * configured on the App itself) */ +export const GITHUB_APP_CLIENT_ID = 'Ov23libpUHDbA7vmFwgH'; + +/** OAuth scope requested at sign-in. Empty string for GitHub Apps + * (their permissions live on the App, not the token). For OAuth + * Apps, `repo` is the minimum the Collaboration features need — + * it's what `gh auth login` requests by default for repo access. + * Without it, the token can read `/user` (so sign-in appears to + * succeed) but every write call to /repos/* returns 403 "not + * accessible". */ +export const GITHUB_OAUTH_SCOPE = 'repo'; + +/** URL the device-flow client POSTs to for an initial device code. + * Composed here so tests and alternate deployments can override the + * base. */ +export const DEVICE_CODE_URL = `${OAUTH_PROXY_BASE_URL}/login/device/code`; + +/** URL the device-flow client polls for the access token, and the + * same URL used to redeem refresh tokens for new access tokens. */ +export const TOKEN_URL = `${OAUTH_PROXY_BASE_URL}/login/oauth/access_token`; diff --git a/Playground/src/sharing/github-auth.test.ts b/Playground/src/sharing/github-auth.test.ts new file mode 100644 index 0000000..101a689 --- /dev/null +++ b/Playground/src/sharing/github-auth.test.ts @@ -0,0 +1,295 @@ +// github-auth.ts unit tests — fetch and sleep are both injected so polling +// runs synchronously without burning real wall time. We assert the protocol +// (correct URLs, correct bodies, correct backoff on slow_down, correct error +// translations) and the happy path returning an access token. + +import { describe, expect, it } from 'vitest'; +import { + DeviceFlowError, + pollForToken, + requestDeviceCode, + signInWithDeviceFlow, + validateToken, +} from './github-auth'; + +const CLIENT_ID = 'Iv23liTestClient'; +// Tests pin their own endpoint URLs so they don't depend on the +// production proxy in github-auth-config. Every call site that +// reaches the network passes `deviceCodeUrl` / `tokenUrl` explicitly. +const DEVICE_CODE_URL = 'https://test.example/login/device/code'; +const TOKEN_URL = 'https://test.example/login/oauth/access_token'; + +function jsonResp(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +// A tiny scriptable fetch mock: each call shifts the next handler off a queue, +// or — if a handler is registered for a URL prefix — returns that. Lets tests +// program "first poll: pending, second poll: ok" without regex juggling. +interface Recorded { url: string; method: string; body: unknown } + +class Scripted { + public requests: Recorded[] = []; + private next: Array<(req: Recorded) => Response | Promise> = []; + private byUrl = new Map Response | Promise>(); + + onNext(respond: (req: Recorded) => Response | Promise): this { + this.next.push(respond); + return this; + } + onUrl(url: string, respond: (req: Recorded) => Response | Promise): this { + this.byUrl.set(url, respond); + return this; + } + asFetch(): typeof fetch { + return (async (input: RequestInfo | URL, init: RequestInit = {}) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const req: Recorded = { url, method: (init.method ?? 'GET').toUpperCase(), body: init.body }; + this.requests.push(req); + const byUrl = this.byUrl.get(url); + if (byUrl) return byUrl(req); + const queued = this.next.shift(); + if (queued) return queued(req); + return new Response(`unmocked: ${req.method} ${url}`, { status: 599 }); + }) as typeof fetch; + } +} + +// Test sleep that never sleeps but tracks how long was "asked for". +function makeFakeSleep() { + const calls: number[] = []; + return { + sleep: (ms: number) => { calls.push(ms); return Promise.resolve(); }, + calls, + }; +} + +// ─── requestDeviceCode ────────────────────────────────────────────────────── + +describe('requestDeviceCode', () => { + it('POSTs client_id + scope and maps the response fields', async () => { + const fm = new Scripted(); + fm.onUrl(DEVICE_CODE_URL, (req) => { + expect(req.method).toBe('POST'); + const body = JSON.parse(req.body as string); + expect(body).toEqual({ client_id: CLIENT_ID, scope: 'repo' }); + return jsonResp({ + device_code: 'dev-1234', + user_code: 'WDJB-MJHT', + verification_uri: 'https://github.com/login/device', + verification_uri_complete: 'https://github.com/login/device?user_code=WDJB-MJHT', + expires_in: 900, + interval: 5, + }); + }); + const prompt = await requestDeviceCode({ + clientId: CLIENT_ID, + scope: 'repo', + fetchImpl: fm.asFetch(), + deviceCodeUrl: DEVICE_CODE_URL, + }); + expect(prompt).toEqual({ + userCode: 'WDJB-MJHT', + verificationUri: 'https://github.com/login/device', + verificationUriComplete: 'https://github.com/login/device?user_code=WDJB-MJHT', + deviceCode: 'dev-1234', + expiresIn: 900, + interval: 5, + }); + }); + + it('omits scope when not provided (GitHub Apps ignore it; permissions are App-level)', async () => { + const fm = new Scripted(); + let observed: unknown; + fm.onUrl(DEVICE_CODE_URL, (req) => { + observed = JSON.parse(req.body as string); + return jsonResp({ device_code: 'd', user_code: 'u', verification_uri: 'v', expires_in: 0, interval: 5 }); + }); + await requestDeviceCode({ + clientId: CLIENT_ID, + fetchImpl: fm.asFetch(), + deviceCodeUrl: DEVICE_CODE_URL, + }); + expect(observed).toEqual({ client_id: CLIENT_ID }); + }); +}); + +// ─── pollForToken ─────────────────────────────────────────────────────────── + +describe('pollForToken', () => { + it('returns the access token on a success response', async () => { + const fm = new Scripted(); + fm.onUrl(TOKEN_URL, () => + jsonResp({ + access_token: 'ghu_xyz', + token_type: 'bearer', + scope: 'repo', + expires_in: 28800, + refresh_token: 'ghr_abc', + refresh_token_expires_in: 15_724_800, + })); + const { sleep, calls } = makeFakeSleep(); + const t = await pollForToken({ + clientId: CLIENT_ID, deviceCode: 'd', interval: 5, + fetchImpl: fm.asFetch(), sleepImpl: sleep, + tokenUrl: TOKEN_URL, + }); + // Returns the full TokenSet now, not just the access token. + expect(t.accessToken).toBe('ghu_xyz'); + expect(t.refreshToken).toBe('ghr_abc'); + expect(t.expiresIn).toBe(28800); + expect(t.refreshTokenExpiresIn).toBe(15_724_800); + expect(t.scope).toBe('repo'); + expect(t.tokenType).toBe('bearer'); + // Sleeps once (before the first poll) at interval * 1000. + expect(calls).toEqual([5000]); + }); + + it('keeps polling on authorization_pending and eventually succeeds', async () => { + const fm = new Scripted(); + fm.onNext(() => jsonResp({ error: 'authorization_pending' })); + fm.onNext(() => jsonResp({ error: 'authorization_pending' })); + fm.onNext(() => jsonResp({ access_token: 'ghu_finally' })); + const { sleep, calls } = makeFakeSleep(); + const t = await pollForToken({ + clientId: CLIENT_ID, deviceCode: 'd', interval: 5, + fetchImpl: fm.asFetch(), sleepImpl: sleep, + tokenUrl: TOKEN_URL, + }); + expect(t.accessToken).toBe('ghu_finally'); + expect(calls).toEqual([5000, 5000, 5000]); // three sleeps, three polls + }); + + it('backs off by +5s when the server says slow_down', async () => { + const fm = new Scripted(); + fm.onNext(() => jsonResp({ error: 'slow_down' })); + fm.onNext(() => jsonResp({ error: 'slow_down' })); + fm.onNext(() => jsonResp({ access_token: 'ok' })); + const { sleep, calls } = makeFakeSleep(); + await pollForToken({ + clientId: CLIENT_ID, deviceCode: 'd', interval: 5, + fetchImpl: fm.asFetch(), sleepImpl: sleep, + tokenUrl: TOKEN_URL, + }); + // initial=5, +5 after first slow_down=10, +5 after second=15 + expect(calls).toEqual([5000, 10000, 15000]); + }); + + it('throws DeviceFlowError(expired_token)', async () => { + const fm = new Scripted(); + fm.onNext(() => jsonResp({ error: 'expired_token' })); + const err = await pollForToken({ + clientId: CLIENT_ID, deviceCode: 'd', interval: 1, + fetchImpl: fm.asFetch(), sleepImpl: makeFakeSleep().sleep, + tokenUrl: TOKEN_URL, + }).catch((e) => e); + expect(err).toBeInstanceOf(DeviceFlowError); + expect((err as DeviceFlowError).code).toBe('expired_token'); + }); + + it('throws DeviceFlowError(access_denied) when the user denies', async () => { + const fm = new Scripted(); + fm.onNext(() => jsonResp({ error: 'access_denied' })); + const err = await pollForToken({ + clientId: CLIENT_ID, deviceCode: 'd', interval: 1, + fetchImpl: fm.asFetch(), sleepImpl: makeFakeSleep().sleep, + tokenUrl: TOKEN_URL, + }).catch((e) => e); + expect(err).toBeInstanceOf(DeviceFlowError); + expect((err as DeviceFlowError).code).toBe('access_denied'); + }); + + it('throws DeviceFlowError(unsupported_grant_type) when the App lacks device-flow enablement', async () => { + const fm = new Scripted(); + fm.onNext(() => jsonResp({ error: 'unsupported_grant_type' })); + const err = await pollForToken({ + clientId: CLIENT_ID, deviceCode: 'd', interval: 1, + fetchImpl: fm.asFetch(), sleepImpl: makeFakeSleep().sleep, + tokenUrl: TOKEN_URL, + }).catch((e) => e); + expect(err).toBeInstanceOf(DeviceFlowError); + expect((err as DeviceFlowError).code).toBe('unsupported_grant_type'); + // Message should hint at the actual fix (the App setting). + expect((err as DeviceFlowError).message).toMatch(/device flow/i); + }); + + it('respects an AbortSignal between polls', async () => { + const fm = new Scripted(); + fm.onNext(() => jsonResp({ error: 'authorization_pending' })); + const controller = new AbortController(); + const { sleep } = makeFakeSleep(); + // Wrap sleep so it aborts mid-flight on the second iteration. + let n = 0; + const racingSleep = async (ms: number) => { + await sleep(ms); + if (++n === 1) controller.abort(new Error('user-canceled')); + }; + const err = await pollForToken({ + clientId: CLIENT_ID, deviceCode: 'd', interval: 1, + fetchImpl: fm.asFetch(), sleepImpl: racingSleep, signal: controller.signal, + tokenUrl: TOKEN_URL, + }).catch((e) => e); + expect((err as Error).message).toBe('user-canceled'); + }); +}); + +// ─── signInWithDeviceFlow (the one-call helper) ───────────────────────────── + +describe('signInWithDeviceFlow', () => { + it('fires onPrompt with the device code, then resolves with the TokenSet', async () => { + const fm = new Scripted(); + fm.onUrl(DEVICE_CODE_URL, () => jsonResp({ + device_code: 'd', user_code: 'AAAA-BBBB', + verification_uri: 'https://github.com/login/device', + expires_in: 900, interval: 5, + })); + fm.onUrl(TOKEN_URL, () => jsonResp({ access_token: 'ghu_combined' })); + let observedPrompt: { userCode: string; verificationUri: string } | null = null; + const result = await signInWithDeviceFlow({ + clientId: CLIENT_ID, + fetchImpl: fm.asFetch(), + sleepImpl: makeFakeSleep().sleep, + deviceCodeUrl: DEVICE_CODE_URL, + tokenUrl: TOKEN_URL, + onPrompt: (p) => { observedPrompt = p; }, + }); + expect(result.accessToken).toBe('ghu_combined'); + // Cast through unknown to bypass TS's "always null" inference for + // closure-assigned vars; we know the callback ran. + const got = observedPrompt as unknown as { userCode: string; verificationUri: string } | null; + expect(got?.userCode).toBe('AAAA-BBBB'); + expect(got?.verificationUri).toBe('https://github.com/login/device'); + }); +}); + +// ─── validateToken ────────────────────────────────────────────────────────── + +describe('validateToken', () => { + it('returns login + id + scopes from /user on success', async () => { + const fm = new Scripted(); + fm.onUrl('https://api.github.com/user', () => { + // Token must be sent as a bearer header (matches the adapter convention). + return jsonResp({ login: 'alice', id: 42 }, 200, { + 'X-OAuth-Scopes': 'repo, read:user', + }); + }); + const out = await validateToken('ghu_token', fm.asFetch()); + expect(out).toEqual({ login: 'alice', id: 42, scopes: ['repo', 'read:user'] }); + }); + + it('throws on 401', async () => { + const fm = new Scripted(); + fm.onUrl('https://api.github.com/user', () => jsonResp({ message: 'Bad credentials' }, 401)); + await expect(validateToken('nope', fm.asFetch())).rejects.toThrow(/401/); + }); + + it('returns an empty scopes array when the header is missing (e.g. fine-grained PATs)', async () => { + const fm = new Scripted(); + fm.onUrl('https://api.github.com/user', () => jsonResp({ login: 'alice', id: 1 })); + const out = await validateToken('ghu_fine', fm.asFetch()); + expect(out.scopes).toEqual([]); + }); +}); diff --git a/Playground/src/sharing/github-auth.ts b/Playground/src/sharing/github-auth.ts new file mode 100644 index 0000000..23a4f6e --- /dev/null +++ b/Playground/src/sharing/github-auth.ts @@ -0,0 +1,298 @@ +// GitHub OAuth — Device Flow. +// +// Why device flow: GitHub doesn't let a browser SPA complete the +// standard authorization-code flow (the token-exchange endpoint still +// requires `client_secret` even with PKCE, per GitHub's docs on +// client_secret being "Required" at /login/oauth/access_token). The +// device flow accepts only `client_id` at every step — the same +// mechanism `gh auth login` uses. The only catch: GitHub's +// /login/* endpoints don't send CORS headers, so a browser fetch +// gets refused before the request leaves the user agent. We work +// around that with a stateless CORS proxy (see ../../../oauth-proxy/) +// that this module's default URLs point at. +// +// Shape: +// 1. `requestDeviceCode` → server returns user_code, verification URL, +// device_code (opaque), expiry, polling interval. +// 2. App shows the user the user_code + opens the verification URL. +// 3. App polls `pollForToken` until success/denial/expiry. +// 4. On success: access_token (`ghu_*`) + refresh_token (`ghr_*`). +// 5. When access_token expires (~8h default), `refreshAccessToken` +// exchanges the refresh_token for a fresh pair. + +import { DEVICE_CODE_URL, TOKEN_URL } from './github-auth-config'; + +const USER_URL = 'https://api.github.com/user'; + +export interface DeviceCodePrompt { + userCode: string; // e.g. "WDJB-MJHT" — shown to the user + verificationUri: string; // canonical URL the user opens + verificationUriComplete?: string; // pre-filled variant if GitHub returns it + deviceCode: string; // opaque, passed back to pollForToken + expiresIn: number; // seconds until the code expires + interval: number; // seconds between polls (server-recommended) +} + +/** Full GitHub-App user-access-token response. Access tokens are + * short-lived (default 8h); refresh tokens last 6 months. Both + * arrive together from the token endpoint. */ +export interface TokenSet { + accessToken: string; // `ghu_*` — the bearer for API calls + refreshToken?: string; // `ghr_*` — exchange for a new access token. Absent for tokens that don't expire (legacy OAuth-App tokens, fine-grained PATs). + expiresIn?: number; // seconds until `accessToken` expires + refreshTokenExpiresIn?: number; // seconds until `refreshToken` expires + scope?: string; // space-separated scope list (mostly empty for GitHub Apps) + tokenType?: string; // 'bearer' typically +} + +export interface RequestDeviceCodeOptions { + clientId: string; + /** Optional space-separated scope list. GitHub Apps ignore this + * (permissions are App-level). OAuth Apps use it. */ + scope?: string; + fetchImpl?: typeof fetch; + /** Override the device-code endpoint. Defaults to the proxy URL + * from github-auth-config; tests can pin to a mock URL. */ + deviceCodeUrl?: string; +} + +export interface PollForTokenOptions { + clientId: string; + deviceCode: string; + interval: number; // seconds (will auto-back-off on slow_down) + fetchImpl?: typeof fetch; + sleepImpl?: (ms: number) => Promise; + signal?: AbortSignal; + /** Override the token endpoint. Defaults to the proxy URL from + * github-auth-config; tests can pin to a mock URL. */ + tokenUrl?: string; +} + +export interface SignInWithDeviceFlowOptions extends RequestDeviceCodeOptions { + onPrompt: (prompt: DeviceCodePrompt) => void; + sleepImpl?: (ms: number) => Promise; + signal?: AbortSignal; + tokenUrl?: string; +} + +export interface RefreshAccessTokenOptions { + clientId: string; + refreshToken: string; + fetchImpl?: typeof fetch; + tokenUrl?: string; +} + +export class DeviceFlowError extends Error { + constructor( + public code: + | 'access_denied' + | 'expired_token' + | 'unsupported_grant_type' + | 'incorrect_client_credentials' + | 'bad_refresh_token' + | 'unknown', + message: string, + ) { + super(message); + this.name = 'DeviceFlowError'; + } +} + +// ─── step 1: ask for a device code ────────────────────────────────────────── + +export async function requestDeviceCode(opts: RequestDeviceCodeOptions): Promise { + const fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis); + const url = opts.deviceCodeUrl ?? DEVICE_CODE_URL; + const body: Record = { client_id: opts.clientId }; + if (opts.scope) body.scope = opts.scope; + const r = await fetchImpl(url, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`device code request failed: ${r.status} ${r.statusText}`); + const payload = await r.json() as { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; + interval: number; + }; + return { + userCode: payload.user_code, + verificationUri: payload.verification_uri, + verificationUriComplete: payload.verification_uri_complete, + deviceCode: payload.device_code, + expiresIn: payload.expires_in, + interval: payload.interval, + }; +} + +// ─── step 2: poll until the user finishes authorizing ─────────────────────── + +export async function pollForToken(opts: PollForTokenOptions): Promise { + const fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis); + const sleep = opts.sleepImpl ?? defaultSleep; + const url = opts.tokenUrl ?? TOKEN_URL; + let interval = opts.interval; + + for (;;) { + throwIfAborted(opts.signal); + await sleep(interval * 1000); + throwIfAborted(opts.signal); + + const r = await fetchImpl(url, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: opts.clientId, + device_code: opts.deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }); + // GitHub returns 200 with an error payload for in-progress / failed + // states — we have to inspect the body, not just the status code. + const body = await r.json() as TokenResponse; + if (body.access_token) return parseTokenSet(body); + // Errors that aren't "keep polling" cases bubble out via switch. + translatePollError(body); + // slow_down requested — bump interval. authorization_pending → + // keep going at the current rate. Both fall through to the next + // loop iteration; everything else throws above. + if (body.error === 'slow_down') interval += 5; + } +} + +// ─── refresh: trade ghr_* for a new (ghu_*, ghr_*) pair ───────────────────── + +export async function refreshAccessToken(opts: RefreshAccessTokenOptions): Promise { + const fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis); + const url = opts.tokenUrl ?? TOKEN_URL; + const r = await fetchImpl(url, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: opts.clientId, + grant_type: 'refresh_token', + refresh_token: opts.refreshToken, + }), + }); + const body = await r.json() as TokenResponse; + if (body.access_token) return parseTokenSet(body); + if (body.error === 'bad_refresh_token' || body.error === 'unauthorized_client') { + throw new DeviceFlowError('bad_refresh_token', body.error_description ?? 'refresh token rejected'); + } + throw new DeviceFlowError('unknown', `refresh failed: ${body.error ?? 'no error field'} ${body.error_description ?? ''}`); +} + +// ─── one-call helper ──────────────────────────────────────────────────────── + +export async function signInWithDeviceFlow(opts: SignInWithDeviceFlowOptions): Promise { + const prompt = await requestDeviceCode(opts); + opts.onPrompt(prompt); + return pollForToken({ + clientId: opts.clientId, + deviceCode: prompt.deviceCode, + interval: prompt.interval, + fetchImpl: opts.fetchImpl, + sleepImpl: opts.sleepImpl, + signal: opts.signal, + tokenUrl: opts.tokenUrl, + }); +} + +// ─── token validation ─────────────────────────────────────────────────────── + +export interface ValidatedToken { + login: string; + id: number; + scopes: string[]; // from the X-OAuth-Scopes header +} + +/** + * Sanity-check a token by calling GET /user. Works for any token type + * (PAT, OAuth user-access, GitHub-App user-access). api.github.com sets + * proper CORS headers so this call doesn't need the proxy. + */ +export async function validateToken(token: string, fetchImpl?: typeof fetch): Promise { + const f = fetchImpl ?? fetch.bind(globalThis); + const r = await f(USER_URL, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (!r.ok) { + const text = await r.text().catch(() => ''); + throw new Error(`token validation failed: ${r.status} ${text.slice(0, 200)}`); + } + const body = await r.json() as { login: string; id: number }; + const scopesHeader = r.headers.get('X-OAuth-Scopes') ?? ''; + const scopes = scopesHeader.split(',').map((s) => s.trim()).filter(Boolean); + return { login: body.login, id: body.id, scopes }; +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +interface TokenResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; + refresh_token_expires_in?: number; + scope?: string; + token_type?: string; + error?: string; + error_description?: string; +} + +function parseTokenSet(body: TokenResponse): TokenSet { + return { + accessToken: body.access_token!, + refreshToken: body.refresh_token, + expiresIn: body.expires_in, + refreshTokenExpiresIn: body.refresh_token_expires_in, + scope: body.scope, + tokenType: body.token_type, + }; +} + +/** Translate the not-yet-authorized / hard-fail variants of the token + * endpoint response into either "keep looping" (no throw) or a + * DeviceFlowError. The success-with-token case is handled before + * this is called. */ +function translatePollError(body: TokenResponse): void { + switch (body.error) { + case 'authorization_pending': + case 'slow_down': + return; + case 'expired_token': + throw new DeviceFlowError('expired_token', 'device code expired before the user authorized'); + case 'access_denied': + throw new DeviceFlowError('access_denied', 'user denied authorization'); + case 'unsupported_grant_type': + throw new DeviceFlowError( + 'unsupported_grant_type', + 'device flow not enabled on this App — turn it on in the App\'s "Identifying and authorizing users" settings', + ); + case 'incorrect_client_credentials': + throw new DeviceFlowError('incorrect_client_credentials', 'client_id is wrong'); + default: + throw new DeviceFlowError( + 'unknown', + `unexpected token response: ${body.error ?? 'no error field'} ${body.error_description ?? ''}`, + ); + } +} + +function defaultSleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + const reason = signal.reason ?? new DOMException('aborted', 'AbortError'); + throw reason; + } +} diff --git a/Playground/src/sharing/hash.test.ts b/Playground/src/sharing/hash.test.ts new file mode 100644 index 0000000..4144721 --- /dev/null +++ b/Playground/src/sharing/hash.test.ts @@ -0,0 +1,58 @@ +// Hash module unit tests. WebCrypto is available natively in Node 18+, so +// these run against the real implementation — no mocks. + +import { describe, expect, it } from 'vitest'; +import { gitBlobSha, sha256, sha256Hex, shardOf, toHex } from './hash'; + +describe('hash', () => { + it('matches the well-known SHA-256 vector for "abc"', async () => { + // Standard test vector from FIPS 180-4 §A.1. + const input = new TextEncoder().encode('abc'); + const got = await sha256Hex(input); + expect(got).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + }); + + it('matches the well-known SHA-256 vector for the empty input', async () => { + const got = await sha256Hex(new Uint8Array()); + expect(got).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); + + it('returns identical hex for identical bytes (determinism)', async () => { + const a = new TextEncoder().encode('hello playground'); + const b = new TextEncoder().encode('hello playground'); + expect(await sha256Hex(a)).toBe(await sha256Hex(b)); + }); + + it('returns different hex for different bytes', async () => { + const a = new TextEncoder().encode('hello'); + const b = new TextEncoder().encode('hello!'); + expect(await sha256Hex(a)).not.toBe(await sha256Hex(b)); + }); + + it('toHex round-trips for known fixed bytes', () => { + const bytes = new Uint8Array([0x00, 0x0f, 0xab, 0xff]); + expect(toHex(bytes)).toBe('000fabff'); + }); + + it('sha256 returns 32 bytes', async () => { + const out = await sha256(new TextEncoder().encode('anything')); + expect(out.length).toBe(32); + }); + + it('shardOf returns the first two hex characters', () => { + expect(shardOf('ab12cd34')).toBe('ab'); + expect(shardOf('00ff')).toBe('00'); + }); + + it('gitBlobSha matches git\'s sha1("blob N\\0content") for empty input', async () => { + // git hash-object < /dev/null → e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 + const got = await gitBlobSha(new Uint8Array()); + expect(got).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'); + }); + + it('gitBlobSha matches git\'s sha for "hello\\n"', async () => { + // echo hello | git hash-object --stdin → ce013625030ba8dba906f756967f9e9ca394464a + const got = await gitBlobSha(new TextEncoder().encode('hello\n')); + expect(got).toBe('ce013625030ba8dba906f756967f9e9ca394464a'); + }); +}); diff --git a/Playground/src/sharing/hash.ts b/Playground/src/sharing/hash.ts new file mode 100644 index 0000000..03a05d2 --- /dev/null +++ b/Playground/src/sharing/hash.ts @@ -0,0 +1,59 @@ +// SHA-256 via WebCrypto. Used for both blob names and commit ids. WebCrypto is +// available natively in the browser and in Node 18+, so this module needs no +// polyfill or library dependency. +// +// All hashes are lowercase hex. The first two characters are also used as the +// shard prefix for blobs on the remote (`objects//`) — see sharing.md. + +const HEX = '0123456789abcdef'; + +export async function sha256(bytes: Uint8Array): Promise { + // Defensive copy. Two reasons: (1) callers can mutate the input buffer + // after the call without affecting the digest, and (2) the project's + // lib.d.ts includes SharedArrayBuffer (for the vm-worker's prompt$ + // plumbing — see main.ts:906), which makes raw Uint8Array + // fail crypto.subtle.digest's BufferSource constraint. Allocating a fresh + // Uint8Array guarantees a plain ArrayBuffer backing. + const copy = new Uint8Array(bytes); + const buf = await crypto.subtle.digest('SHA-256', copy.buffer as ArrayBuffer); + return new Uint8Array(buf); +} + +export function toHex(bytes: Uint8Array): string { + let out = ''; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + out += HEX[(b >> 4) & 0xf] + HEX[b & 0xf]; + } + return out; +} + +export async function sha256Hex(bytes: Uint8Array): Promise { + return toHex(await sha256(bytes)); +} + +// Shard prefix: first 2 hex chars. Used for blob folder bucketing on the remote. +export function shardOf(hash: string): string { + return hash.slice(0, 2); +} + +/** + * Compute the git blob SHA-1 for a byte string. This is the same id git + * itself uses: `sha1("blob " + bytes.length + "\0" + bytes)`. Lets us + * compare local file bytes against git's blob sha without uploading to find + * out — used by file-status (A/M/D) and by conflict detection. + * + * SHA-1 is broken for adversarial collision resistance but fine for our + * use case: we're matching content, not authenticating it. Git itself + * still uses SHA-1 for object addressing. + */ +export async function gitBlobSha(bytes: Uint8Array): Promise { + const header = new TextEncoder().encode(`blob ${bytes.length}\0`); + const combined = new Uint8Array(header.length + bytes.length); + combined.set(header, 0); + combined.set(bytes, header.length); + // Defensive copy via .buffer cast — same lib.d.ts quirk hash.ts already + // documents (SharedArrayBuffer leak in the Uint8Array generic). + const digest = await crypto.subtle.digest('SHA-1', combined.buffer as ArrayBuffer); + return toHex(new Uint8Array(digest)); +} diff --git a/Playground/src/sharing/history-panel.ts b/Playground/src/sharing/history-panel.ts new file mode 100644 index 0000000..580f6af --- /dev/null +++ b/Playground/src/sharing/history-panel.ts @@ -0,0 +1,477 @@ +// Recent History dockview panel. Subscribes to a CollaborationController +// and renders the commit log with per-commit file-level diffs + a Restore +// action. Lives in its own tab (next to Logs / Output / etc.) so it can +// expand horizontally — long commit messages and file lists no longer +// fight the narrow Collaboration panel's column. +// +// Per-commit expansion is local UI state — multiple history panels (in +// different windows) would each track their own expansions. Diff and tree +// data are cached centrally inside the collaboration panel and served +// through the controller, so opening the same commit in multiple panels +// only fetches once. + +import type { CollaborationController, SharingCommitInfo } from './collaboration-panel'; +import type { TreeDiff } from './git-types'; +import type { LocalSave } from './local-saves'; + +const CSS_PREFIX = 'fade-hist'; +const STYLE_ID = `${CSS_PREFIX}-styles`; + +export interface HistoryPanelOptions { + container: HTMLElement; + controller: CollaborationController; +} + +export interface HistoryPanelHandle { + dispose(): void; +} + +export function mountHistoryPanel(opts: HistoryPanelOptions): HistoryPanelHandle { + injectStylesOnce(); + const root = opts.container; + root.classList.add(`${CSS_PREFIX}-root`); + root.replaceChildren(); + + let commits: SharingCommitInfo[] = opts.controller.getRecentCommits(); + let saves: LocalSave[] = opts.controller.getPendingSaves(); + const expanded = new Set(); + const diffs = new Map(); + const loading = new Set(); + /** Same caches but keyed by save id rather than commit sha. Saves + * diffs are computed against the prior save or baseTree. */ + const saveDiffs = new Map(); + const saveLoading = new Set(); + + function render() { + root.replaceChildren(); + const header = el('div', `${CSS_PREFIX}-header`); + const title = el('div', `${CSS_PREFIX}-title`); + title.textContent = 'History'; + const info = opts.controller.getRepoInfo(); + const meta = el('div', `${CSS_PREFIX}-meta`); + meta.textContent = info ? `${info.owner}/${info.name} · ${info.branch}` : '(not connected)'; + header.append(title, meta); + root.append(header); + + // Local saves section — rendered first, above the published divider. + // These never touch the remote until the user clicks Publish. + if (saves.length > 0) { + const localH = el('h4', `${CSS_PREFIX}-section-h`); + localH.textContent = 'Local saves'; + root.append(localH); + root.append(p( + `${saves.length} unpublished checkpoint${saves.length === 1 ? '' : 's'}. Publish to roll them up into one commit on the remote.`, + 'dim', + )); + const sList = el('ol', `${CSS_PREFIX}-list`); + saves.forEach((s, i) => sList.append(renderSave(s, i))); + root.append(sList); + // Divider — visual + textual separation between local and published. + const divider = el('div', `${CSS_PREFIX}-divider`); + divider.textContent = '— published below —'; + root.append(divider); + } + + // Remote commits section. + const pubH = el('h4', `${CSS_PREFIX}-section-h`); + pubH.textContent = 'Published commits'; + root.append(pubH); + if (commits.length === 0) { + root.append(p('No published history yet. Publish from the Source Control panel to populate.', 'dim')); + return; + } + const list = el('ol', `${CSS_PREFIX}-list`); + for (const c of commits) { + list.append(renderCommit(c)); + } + root.append(list); + } + + function renderSave(s: LocalSave, idx: number): HTMLElement { + const li = document.createElement('li'); + li.className = `${CSS_PREFIX}-li ${CSS_PREFIX}-li-save`; + const isExpanded = expanded.has(`save:${s.id}`); + + const headerRow = el('div', `${CSS_PREFIX}-row`); + headerRow.style.cursor = 'pointer'; + const caret = el('span', `${CSS_PREFIX}-caret`); + caret.textContent = isExpanded ? '▼' : '▶'; + const badge = el('span', `${CSS_PREFIX}-savebadge`); + badge.textContent = `save #${saves.length - idx}`; + const msg = el('span', `${CSS_PREFIX}-msg`); + msg.textContent = s.message; + const metaSpan = el('span', `${CSS_PREFIX}-row-meta`); + metaSpan.textContent = ` · ${s.time.slice(0, 16).replace('T', ' ')}`; + headerRow.append(caret, text(' '), badge, text(' '), msg, metaSpan); + headerRow.onclick = () => { void toggleSaveExpansion(s.id); }; + li.append(headerRow); + + if (isExpanded) { + const detail = el('div', `${CSS_PREFIX}-detail`); + const d = saveDiffs.get(s.id); + if (saveLoading.has(s.id) && !d) { + detail.append(p('Loading…', 'dim')); + } else if (!d) { + detail.append(p('(diff unavailable)', 'dim')); + } else if (d.added.length === 0 && d.modified.length === 0 && d.deleted.length === 0) { + detail.append(p('(no changes captured)', 'dim')); + } else { + const flist = el('ul', `${CSS_PREFIX}-diff`); + const addRow = (path: string, kind: 'added' | 'modified' | 'deleted') => { + const r = document.createElement('li'); + r.className = `${CSS_PREFIX}-diff-row ${CSS_PREFIX}-diff-${kind}`; + const g = el('span', `${CSS_PREFIX}-diff-glyph`); + g.textContent = kind === 'added' ? 'A' : kind === 'modified' ? 'M' : 'D'; + const ps = el('span', `${CSS_PREFIX}-diff-path`); + ps.textContent = path; + r.append(g, ps); + const diffBtn = document.createElement('button'); + diffBtn.className = `${CSS_PREFIX}-diff-btn`; + diffBtn.type = 'button'; + diffBtn.textContent = 'diff'; + diffBtn.title = `Open a read-only diff of ${path} (predecessor → this save).`; + diffBtn.onclick = (ev) => { + ev.stopPropagation(); + void opts.controller.openDiffViewer({ kind: 'save', saveId: s.id, path }); + }; + r.append(diffBtn); + flist.append(r); + }; + for (const path of d.added) addRow(path, 'added'); + for (const path of d.modified) addRow(path, 'modified'); + for (const path of d.deleted) addRow(path, 'deleted'); + detail.append(flist); + } + const actions = el('div', `${CSS_PREFIX}-actions`); + const revertBtn = document.createElement('button'); + revertBtn.className = `${CSS_PREFIX}-btn`; + revertBtn.textContent = 'Revert to this'; + revertBtn.type = 'button'; + revertBtn.onclick = () => { void opts.controller.revertToLocalSave(s.id); }; + const dropBtn = document.createElement('button'); + dropBtn.className = `${CSS_PREFIX}-btn`; + dropBtn.textContent = 'Drop'; + dropBtn.type = 'button'; + dropBtn.onclick = () => { + if (!confirm(`Drop save "${s.message}"? Only removes the snapshot — working tree is untouched.`)) return; + opts.controller.dropLocalSave(s.id); + }; + actions.append(revertBtn, dropBtn); + detail.append(actions); + li.append(detail); + } + return li; + } + + async function toggleSaveExpansion(id: string) { + const key = `save:${id}`; + if (expanded.has(key)) { + expanded.delete(key); + render(); + return; + } + expanded.add(key); + render(); + if (saveDiffs.has(id) || saveLoading.has(id)) return; + saveLoading.add(id); + try { + const d = await opts.controller.getSaveDiff(id); + if (d) saveDiffs.set(id, d); + } catch { /* swallow — UI shows "(diff unavailable)" */ } + finally { + saveLoading.delete(id); + render(); + } + } + + function renderCommit(c: SharingCommitInfo): HTMLElement { + const info = opts.controller.getRepoInfo(); + const li = document.createElement('li'); + const isExpanded = expanded.has(c.id); + + const headerRow = el('div', `${CSS_PREFIX}-row`); + headerRow.style.cursor = 'pointer'; + const caret = el('span', `${CSS_PREFIX}-caret`); + caret.textContent = isExpanded ? '▼' : '▶'; + // GitHub commit link (only when connected — otherwise plain text). + let idEl: HTMLElement; + if (info) { + const a = document.createElement('a'); + a.className = `${CSS_PREFIX}-id ${CSS_PREFIX}-id-link`; + a.href = `https://github.com/${info.owner}/${info.name}/commit/${c.id}`; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = c.id.slice(0, 8); + a.title = `Open commit ${c.id} on github.com`; + a.addEventListener('click', (e) => e.stopPropagation()); + idEl = a; + } else { + idEl = el('span', `${CSS_PREFIX}-id`); + idEl.textContent = c.id.slice(0, 8); + } + const msg = el('span', `${CSS_PREFIX}-msg`); + msg.textContent = c.message.split('\n')[0]; + const metaSpan = el('span', `${CSS_PREFIX}-row-meta`); + metaSpan.textContent = ` · ${c.author} · ${c.time.slice(0, 16).replace('T', ' ')}`; + headerRow.append(caret, text(' '), idEl, text(' '), msg, metaSpan); + headerRow.onclick = () => { void toggleExpansion(c.id); }; + li.append(headerRow); + + if (isExpanded) { + li.append(renderDetail(c)); + } + return li; + } + + function renderDetail(c: SharingCommitInfo): HTMLElement { + const detail = el('div', `${CSS_PREFIX}-detail`); + const d = diffs.get(c.id); + if (loading.has(c.id) && !d) { + detail.append(p('Loading…', 'dim')); + } else if (!d) { + detail.append(p('(diff unavailable)', 'dim')); + } else if (d.added.length === 0 && d.modified.length === 0 && d.deleted.length === 0) { + detail.append(p('(no changes)', 'dim')); + } else { + const flist = el('ul', `${CSS_PREFIX}-diff`); + const addRow = (path: string, kind: 'added' | 'modified' | 'deleted') => { + const r = document.createElement('li'); + r.className = `${CSS_PREFIX}-diff-row ${CSS_PREFIX}-diff-${kind}`; + const g = el('span', `${CSS_PREFIX}-diff-glyph`); + g.textContent = kind === 'added' ? 'A' : kind === 'modified' ? 'M' : 'D'; + const ps = el('span', `${CSS_PREFIX}-diff-path`); + ps.textContent = path; + r.append(g, ps); + const diffBtn = document.createElement('button'); + diffBtn.className = `${CSS_PREFIX}-diff-btn`; + diffBtn.type = 'button'; + diffBtn.textContent = 'diff'; + diffBtn.title = `Open a read-only diff of ${path} (parent → this commit).`; + diffBtn.onclick = (ev) => { + ev.stopPropagation(); + void opts.controller.openDiffViewer({ kind: 'commit', commitSha: c.id, path }); + }; + r.append(diffBtn); + flist.append(r); + }; + for (const path of d.added) addRow(path, 'added'); + for (const path of d.modified) addRow(path, 'modified'); + for (const path of d.deleted) addRow(path, 'deleted'); + detail.append(flist); + } + // Restore action — except on the tip commit (no-op). + const tip = commits[0]; + if (tip && c.id === tip.id) { + detail.append(p('(current tip — already checked out)', 'dim')); + } else { + const restore = document.createElement('button'); + restore.className = `${CSS_PREFIX}-btn`; + restore.textContent = 'Restore as new commit'; + restore.type = 'button'; + restore.onclick = () => { void opts.controller.restoreCommit(c.id); }; + const actions = el('div', `${CSS_PREFIX}-actions`); + actions.append(restore); + detail.append(actions); + } + return detail; + } + + async function toggleExpansion(sha: string) { + if (expanded.has(sha)) { + expanded.delete(sha); + render(); + return; + } + expanded.add(sha); + render(); + if (diffs.has(sha) || loading.has(sha)) return; + loading.add(sha); + try { + const d = await opts.controller.getCommitDiff(sha); + if (d) diffs.set(sha, d); + } catch { + // controller already routes failures to the LogBus; we just + // surface "(diff unavailable)" in the row. + } finally { + loading.delete(sha); + render(); + } + } + + const unsub = opts.controller.onHistoryChange((list) => { + commits = list; + render(); + }); + const unsubSaves = opts.controller.onSavesChange((list) => { + saves = list; + // Drop cached diffs for saves that no longer exist (dropped / + // published-cleared) so we don't accumulate stale entries. + const live = new Set(list.map((s) => s.id)); + for (const id of [...saveDiffs.keys()]) if (!live.has(id)) saveDiffs.delete(id); + for (const id of [...expanded]) { + if (id.startsWith('save:') && !live.has(id.slice('save:'.length))) { + expanded.delete(id); + } + } + render(); + }); + + render(); + + return { + dispose() { + unsub(); + unsubSaves(); + root.replaceChildren(); + root.classList.remove(`${CSS_PREFIX}-root`); + }, + }; +} + +// ─── DOM helpers ──────────────────────────────────────────────────────────── + +function el(tag: K, cls: string): HTMLElementTagNameMap[K] { + const n = document.createElement(tag); + n.className = cls; + return n; +} + +function text(s: string): Text { + return document.createTextNode(s); +} + +function p(s: string, modifier?: 'dim'): HTMLElement { + const n = el('p', `${CSS_PREFIX}-p` + (modifier === 'dim' ? ` ${CSS_PREFIX}-dim` : '')); + n.textContent = s; + return n; +} + +function injectStylesOnce(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` +.${CSS_PREFIX}-root { + display: flex; flex-direction: column; + height: 100%; box-sizing: border-box; + padding: 8px 10px; + overflow-y: auto; + color: var(--vscode-foreground, #ddd); + font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; +} +.${CSS_PREFIX}-header { + display: flex; align-items: baseline; gap: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--vscode-panel-border, #333); + margin-bottom: 8px; +} +.${CSS_PREFIX}-title { font-size: 13px; font-weight: 600; } +.${CSS_PREFIX}-meta { font-size: 12px; opacity: 0.6; font-family: ui-monospace, monospace; } +.${CSS_PREFIX}-p { margin: 0; opacity: 0.9; } +.${CSS_PREFIX}-dim { opacity: 0.55; font-size: 12px; } +.${CSS_PREFIX}-list { + list-style: none; padding: 0; margin: 0; + display: flex; flex-direction: column; gap: 2px; +} +.${CSS_PREFIX}-list li { + padding: 2px 4px; + border-left: 2px solid var(--vscode-panel-border, #444); + padding-left: 8px; +} +.${CSS_PREFIX}-row { + display: flex; align-items: baseline; gap: 4px; + padding: 2px 0; + user-select: none; + font-size: 12px; +} +.${CSS_PREFIX}-row:hover { + background: rgba(255,255,255,0.04); + border-radius: 3px; +} +.${CSS_PREFIX}-caret { + width: 12px; font-size: 9px; + color: var(--vscode-icon-foreground, #888); + flex-shrink: 0; +} +.${CSS_PREFIX}-id { + font-family: ui-monospace, monospace; + color: var(--vscode-textLink-foreground, #4da6ff); +} +.${CSS_PREFIX}-id-link { + text-decoration: none; + padding: 0 2px; border-radius: 2px; +} +.${CSS_PREFIX}-id-link:hover { + text-decoration: underline; + background: rgba(77,166,255,0.1); +} +.${CSS_PREFIX}-id-link::after { + content: ' ↗'; font-size: 9px; opacity: 0.6; +} +.${CSS_PREFIX}-row-meta { opacity: 0.55; font-size: 11px; } +.${CSS_PREFIX}-detail { + margin: 4px 0 8px 14px; + padding: 6px 10px; + background: rgba(255,255,255,0.03); + border-radius: 4px; + display: flex; flex-direction: column; gap: 6px; +} +.${CSS_PREFIX}-diff { + list-style: none; padding: 0; margin: 0; + display: flex; flex-direction: column; gap: 1px; +} +.${CSS_PREFIX}-diff-row { + display: flex; align-items: center; gap: 8px; + padding: 1px 4px; font-family: ui-monospace, monospace; font-size: 11px; +} +.${CSS_PREFIX}-diff-glyph { width: 14px; text-align: center; font-weight: 700; } +.${CSS_PREFIX}-diff-added .${CSS_PREFIX}-diff-glyph { color: #6e6; } +.${CSS_PREFIX}-diff-modified .${CSS_PREFIX}-diff-glyph { color: #fc6; } +.${CSS_PREFIX}-diff-deleted .${CSS_PREFIX}-diff-glyph { color: #f88; } +.${CSS_PREFIX}-diff-path { word-break: break-all; flex: 1 1 auto; min-width: 0; } +.${CSS_PREFIX}-diff-row { display: flex; align-items: center; gap: 8px; } +.${CSS_PREFIX}-diff-btn { + appearance: none; border: 1px solid var(--vscode-panel-border, #555); + background: transparent; color: inherit; cursor: pointer; + padding: 0 6px; border-radius: 3px; + font: inherit; font-size: 10px; line-height: 16px; + opacity: 0.55; flex-shrink: 0; +} +.${CSS_PREFIX}-diff-row:hover .${CSS_PREFIX}-diff-btn { opacity: 1; } +.${CSS_PREFIX}-diff-btn:hover { background: rgba(77,166,255,0.1); border-color: rgba(77,166,255,0.4); } +.${CSS_PREFIX}-actions { display: flex; } +.${CSS_PREFIX}-btn { + appearance: none; border: 1px solid var(--vscode-panel-border, #555); + background: transparent; color: inherit; cursor: pointer; + padding: 3px 10px; border-radius: 4px; + font: inherit; font-size: 11px; +} +.${CSS_PREFIX}-btn:hover { filter: brightness(1.2); } +.${CSS_PREFIX}-section-h { + margin: 6px 0 4px; + font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.04em; + opacity: 0.7; +} +.${CSS_PREFIX}-divider { + margin: 10px 0 6px; + text-align: center; + font-size: 10px; opacity: 0.45; + text-transform: uppercase; letter-spacing: 0.08em; + border-top: 1px dashed var(--vscode-panel-border, #444); + padding-top: 4px; +} +.${CSS_PREFIX}-li-save { + border-left-color: #b88aff !important; +} +.${CSS_PREFIX}-savebadge { + font-family: ui-monospace, monospace; + font-size: 10px; + padding: 0 4px; + border-radius: 3px; + background: rgba(184,138,255,0.15); + color: #b88aff; +} +`; + document.head.appendChild(style); +} diff --git a/Playground/src/sharing/index.ts b/Playground/src/sharing/index.ts new file mode 100644 index 0000000..12d1dbe --- /dev/null +++ b/Playground/src/sharing/index.ts @@ -0,0 +1,117 @@ +// Public entry point for the sharing module. Centralizes the surface so +// callers (main.ts wiring + the collaboration panel + tests) import from +// one place. + +export { sha256, sha256Hex, gitBlobSha, toHex, shardOf } from './hash'; +export { + diffGitTrees, + flattenTreeToBlobShas, + type GitTree, + type GitTreeEntry, + type GitCommitMeta, + type TreeDiff, +} from './git-types'; +export { + HeadConflictError, + type GitAdapter, +} from './adapter'; +export { MockAdapter } from './mock-adapter'; +export { MemoryWorkingTree, type WorkingTree } from './working-tree'; +export { + Repo, + type CommitOptions, + type FastForwardResult, + type RepoOptions, + type SyncedHead, +} from './repo'; +export { + GitHubAdapter, + GitHubApiError, + type CreateRepoOptions, + type GitHubAdapterOptions, +} from './github-adapter'; +export { + DeviceFlowError, + pollForToken, + requestDeviceCode, + signInWithDeviceFlow, + validateToken, + type DeviceCodePrompt, + type PollForTokenOptions, + type RequestDeviceCodeOptions, + type SignInWithDeviceFlowOptions, + type ValidatedToken, +} from './github-auth'; +export { + SessionTokenStore, + MemoryTokenStore, + isAccessExpired, + isRefreshUsable, + tokenSetToStored, + type TokenStore, + type StoredTokenSet, +} from './token-store'; +export { + openSignInDialog, + type SignInDialogOptions, +} from './auth-ui'; +export { OpfsWorkingTree, isHiddenFromCommits, type OpfsWorkspaceLike } from './opfs-working-tree'; +export { lineDiff, type LineDiffMark, type LineDiffResult } from './line-diff'; +export { attachGutter, type AttachGutterOptions, type GutterHandle } from './monaco-gutter'; +export { + diff3Merge, + hasConflictMarkers, + parseConflictRegions, + type Diff3Result, + type Diff3Options, + type ConflictRegion, + type ParsedConflictRegion, +} from './diff3'; +export { + mountConflictEditor, + type ConflictEditorOptions, + type ConflictEditorHandle, +} from './conflict-editor'; +export { + loadSyncIndex, + saveSyncIndex, + clearSyncIndex, + isConnected, + type ProjectSyncIndex, +} from './sync-index'; +export { + computeStatus, + pathStatusHint, + statusGlyph, + type FileStatus, + type FileStatusEntry, +} from './file-status'; +export { + mountCollaboration, + type SharingCommitInfo, + type CollaborationController, + type CollaborationOptions, +} from './collaboration-panel'; +export { + createDiffViewer, + type DiffViewerParams, + type DiffViewerComponent, +} from './diff-viewer'; +export { + mountHistoryPanel, + type HistoryPanelOptions, + type HistoryPanelHandle, +} from './history-panel'; +export { + loadSaves, + createSave, + revertToSave, + clearSaves, + dropSave, + upgradeSave, + MemorySaveStorage, + type LocalSave, + type SaveStorage, + type SaveWorkspaceLike, +} from './local-saves'; +export { HashCache } from './file-status'; diff --git a/Playground/src/sharing/line-diff.test.ts b/Playground/src/sharing/line-diff.test.ts new file mode 100644 index 0000000..8db457a --- /dev/null +++ b/Playground/src/sharing/line-diff.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { lineDiff, lineDiffTriState } from './line-diff'; + +describe('lineDiff', () => { + it('identical input → no marks, no deletions', () => { + const r = lineDiff('a\nb\nc\n', 'a\nb\nc\n'); + expect(r.marks).toEqual([]); + expect(r.deletions).toEqual([]); + }); + + it('pure addition at end', () => { + const r = lineDiff('a\nb\n', 'a\nb\nc\nd\n'); + expect(r.marks).toEqual([ + { line: 3, kind: 'added' }, + { line: 4, kind: 'added' }, + ]); + expect(r.deletions).toEqual([]); + }); + + it('pure addition at start', () => { + const r = lineDiff('b\nc\n', 'a\nb\nc\n'); + expect(r.marks).toEqual([{ line: 1, kind: 'added' }]); + }); + + it('pure deletion at end → recorded as deletion', () => { + const r = lineDiff('a\nb\nc\n', 'a\n'); + expect(r.marks).toEqual([]); + expect(r.deletions).toEqual([{ line: 1, count: 2 }]); + }); + + it('pure deletion at start → deletion before line 1', () => { + const r = lineDiff('a\nb\nc\n', 'c\n'); + expect(r.deletions).toEqual([{ line: 0, count: 2 }]); + }); + + it('single-line modification → mark as modified', () => { + const r = lineDiff('a\nb\nc\n', 'a\nB\nc\n'); + expect(r.marks).toEqual([{ line: 2, kind: 'modified' }]); + expect(r.deletions).toEqual([]); + }); + + it('replace 2 lines with 1 → modified mark, no separate deletion', () => { + const r = lineDiff('a\nx\ny\nb\n', 'a\nz\nb\n'); + expect(r.marks).toEqual([{ line: 2, kind: 'modified' }]); + expect(r.deletions).toEqual([]); + }); + + it('replace 1 line with 2 → both new lines marked modified', () => { + const r = lineDiff('a\nx\nb\n', 'a\ny\nz\nb\n'); + expect(r.marks).toEqual([ + { line: 2, kind: 'modified' }, + { line: 3, kind: 'modified' }, + ]); + }); + + it('empty base → every current line is added', () => { + const r = lineDiff('', 'a\nb\nc\n'); + expect(r.marks).toEqual([ + { line: 1, kind: 'added' }, + { line: 2, kind: 'added' }, + { line: 3, kind: 'added' }, + ]); + }); + + it('empty current → single deletion record covering all base lines', () => { + const r = lineDiff('a\nb\nc\n', ''); + expect(r.deletions).toEqual([{ line: 0, count: 3 }]); + }); + + it('addition followed by deletion in separate regions', () => { + // a, b, c → a, NEW, c (line b deleted between c... wait) + // base: a b c + // current: a b X + const r = lineDiff('a\nb\nc\n', 'a\nb\nX\n'); + expect(r.marks).toEqual([{ line: 3, kind: 'modified' }]); + }); +}); + +describe('lineDiffTriState', () => { + it('no saves yet (savedRef === publishedRef) → all changes are "unsaved"', () => { + const pub = 'a\nb\nc\n'; + const r = lineDiffTriState(pub, pub, 'a\nX\nc\n'); + expect(r.unsavedLines).toEqual([2]); + expect(r.savedLines).toEqual([]); + }); + + it('saved change only (current === savedRef, differs from publishedRef)', () => { + // User edited line 2 → saved → no further edits. + const r = lineDiffTriState( + 'a\nb\nc\n', // published + 'a\nS\nc\n', // saved + 'a\nS\nc\n', // current matches save + ); + expect(r.unsavedLines).toEqual([]); + expect(r.savedLines).toEqual([2]); + }); + + it('unsaved change on top of a saved change (different lines)', () => { + // Published: a b c d + // Saved: a S c d (line 2 modified, saved) + // Current: a S c U (line 4 modified since save, NOT saved) + const r = lineDiffTriState( + 'a\nb\nc\nd\n', + 'a\nS\nc\nd\n', + 'a\nS\nc\nU\n', + ); + expect(r.unsavedLines).toEqual([4]); + expect(r.savedLines).toEqual([2]); + }); + + it('unsaved edit on top of a saved edit on the SAME line → unsaved wins', () => { + // Published: a b c + // Saved: a S c + // Current: a U c (re-edited the saved line; now unsaved) + const r = lineDiffTriState( + 'a\nb\nc\n', + 'a\nS\nc\n', + 'a\nU\nc\n', + ); + // Line 2 differs from both saved and published. It's "unsaved" + // and NOT additionally counted as "saved" (set difference). + expect(r.unsavedLines).toEqual([2]); + expect(r.savedLines).toEqual([]); + }); + + it('saved deletion (lines removed in save, current matches save)', () => { + const r = lineDiffTriState( + 'a\nb\nc\n', // published + 'a\nc\n', // saved — b removed + 'a\nc\n', // current matches save + ); + expect(r.unsavedLines).toEqual([]); + expect(r.unsavedDeletions).toEqual([]); + expect(r.savedDeletions).toEqual([{ line: 1, count: 1 }]); + }); + + it('unsaved deletion stacked on saved edit', () => { + // Published: a b c d + // Saved: a S c d (line 2 modified b→S, saved) + // Current: a S d (user deleted line c since save) + const r = lineDiffTriState( + 'a\nb\nc\nd\n', + 'a\nS\nc\nd\n', + 'a\nS\nd\n', + ); + // Saved-modified line 2 still tracked, deletion of c is unsaved. + expect(r.savedLines).toEqual([2]); + expect(r.unsavedDeletions).toEqual([{ line: 2, count: 1 }]); + }); + + it('matches `lineDiff` semantics when savedRef === publishedRef', () => { + const pub = 'a\nb\nc\n'; + const cur = 'a\nB\nc\nD\n'; + const tri = lineDiffTriState(pub, pub, cur); + const single = lineDiff(pub, cur); + expect(tri.unsavedLines).toEqual(single.marks.map((m) => m.line)); + expect(tri.savedLines).toEqual([]); + }); +}); diff --git a/Playground/src/sharing/line-diff.ts b/Playground/src/sharing/line-diff.ts new file mode 100644 index 0000000..f61db60 --- /dev/null +++ b/Playground/src/sharing/line-diff.ts @@ -0,0 +1,192 @@ +// Line-level diff used by the gutter decorator. +// +// LCS-based (O(N*M) time + memory). Good enough for playground-sized files +// — Myers' O((N+M)*D) refinement would be cheaper for large edits but isn't +// worth the complexity at this scale. The output is shaped for direct +// translation into Monaco decoration ranges. +// +// Output convention: +// - `marks[i].line` is a 1-based line in the *current* text. Only changed +// lines appear; unchanged lines are absent. +// - `deletions[i].line` is "lines were deleted just after this current +// line." 0 = before line 1. +// +// "Modified" is a paired delete+add at the same position; we surface it as +// a single mark on the added line rather than separate add+delete markers, +// matching how VS Code's gutter renders inline diffs. + +export interface LineDiffMark { + /** 1-based line in the current (live) text. */ + line: number; + /** What happened to this line. */ + kind: 'added' | 'modified'; +} + +export interface LineDiffDeletion { + /** 1-based line in current AFTER which the deletion sits. 0 = before line 1. */ + line: number; + /** How many lines were removed. */ + count: number; +} + +export interface LineDiffResult { + marks: LineDiffMark[]; + deletions: LineDiffDeletion[]; +} + +export function lineDiff(baseText: string, currentText: string): LineDiffResult { + const a = splitLines(baseText); + const b = splitLines(currentText); + const m = a.length; + const n = b.length; + + if (m === 0 && n === 0) return { marks: [], deletions: [] }; + if (m === 0) { + return { marks: b.map((_, i) => ({ line: i + 1, kind: 'added' })), deletions: [] }; + } + if (n === 0) { + return { marks: [], deletions: [{ line: 0, count: m }] }; + } + + // LCS length DP — lcs[i][j] = LCS length of a[i..] and b[j..]. + // Stored row-major in a flat Int32Array for cache friendliness. + const cols = n + 1; + const lcs = new Int32Array((m + 1) * cols); + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + if (a[i] === b[j]) { + lcs[i * cols + j] = lcs[(i + 1) * cols + (j + 1)] + 1; + } else { + lcs[i * cols + j] = Math.max(lcs[(i + 1) * cols + j], lcs[i * cols + (j + 1)]); + } + } + } + + // Walk the table top-down to emit the shortest edit script. + type Op = 'eq' | 'add' | 'del'; + const ops: Op[] = []; + let i = 0; let j = 0; + while (i < m && j < n) { + if (a[i] === b[j]) { ops.push('eq'); i++; j++; } + else if (lcs[(i + 1) * cols + j] >= lcs[i * cols + (j + 1)]) { ops.push('del'); i++; } + else { ops.push('add'); j++; } + } + while (i < m) { ops.push('del'); i++; } + while (j < n) { ops.push('add'); j++; } + + // Group consecutive non-eq ops into a single "change region" and decide + // per region whether to surface as add/modified/delete. A region with + // both `del` and `add` is a modification; pure-add is addition; pure-del + // is a deletion marker. (A region that's ALL del + ALL add could also be + // "N-for-M replace" — we mark the adds as 'modified'.) + const marks: LineDiffMark[] = []; + const deletions: LineDiffDeletion[] = []; + let curLine = 0; + let regionDels = 0; + let regionAdds: number[] = []; + + function flushRegion() { + if (regionDels === 0 && regionAdds.length === 0) return; + const kind: 'added' | 'modified' = regionDels > 0 ? 'modified' : 'added'; + for (const ln of regionAdds) marks.push({ line: ln, kind }); + if (regionDels > 0 && regionAdds.length === 0) { + // Pure deletion — anchor it after the last unchanged current line. + deletions.push({ line: curLine, count: regionDels }); + } + regionDels = 0; + regionAdds = []; + } + + for (const op of ops) { + if (op === 'eq') { + flushRegion(); + curLine++; + } else if (op === 'add') { + curLine++; + regionAdds.push(curLine); + } else { // del + regionDels++; + } + } + flushRegion(); + + return { marks, deletions }; +} + +function splitLines(s: string): string[] { + if (s === '') return []; + const arr = s.split('\n'); + // Drop a phantom trailing empty entry produced by a final newline, so we + // don't attribute changes to a line that doesn't visually exist. + if (arr.length > 0 && arr[arr.length - 1] === '') arr.pop(); + return arr; +} + +// ─── tri-state diff ──────────────────────────────────────────────────────── + +export interface LineTriDiff { + /** Lines (1-based, in `current`) that differ from `savedRef` — the + * "unsaved changes" the user has typed since their last local save. */ + unsavedLines: number[]; + /** Lines that match `savedRef` but differ from `publishedRef` — i.e., + * the saved-but-not-yet-published baseline. These show as a distinct + * colour in the gutter so the user can see "this line is in a save + * that hasn't been pushed yet." */ + savedLines: number[]; + /** Deletion anchors (lines in current after which deletions sit) + * relative to `savedRef`. Always counted as "unsaved" deletions + * because that's the most-recent reference. */ + unsavedDeletions: LineDiffDeletion[]; + /** Deletion anchors relative to `publishedRef` that didn't also + * appear in `unsavedDeletions` — i.e., deletions captured in a save + * but not yet published. */ + savedDeletions: LineDiffDeletion[]; +} + +/** + * Three-way diff of `current` against TWO reference texts. Used by the + * gutter to render distinct decorations for unsaved edits vs + * saved-but-unpublished edits. + * + * Conventions: + * - `savedRef` = the latest local save's content (or `publishedRef` + * when there's no save yet — pass identical strings). + * - `publishedRef` = the last published commit's content for this path. + * + * Algorithm: run `lineDiff` twice and partition the result. A line that + * differs from `savedRef` is "unsaved"; a line that's unchanged vs + * `savedRef` but differs from `publishedRef` is "saved." Same for + * deletions, using set-difference on the anchor line numbers. + */ +export function lineDiffTriState( + publishedRef: string, + savedRef: string, + current: string, +): LineTriDiff { + const unsaved = lineDiff(savedRef, current); + const unsavedLineSet = new Set(unsaved.marks.map((m) => m.line)); + const unsavedDelAnchors = new Set(unsaved.deletions.map((d) => d.line)); + + const savedLines: number[] = []; + const savedDeletions: LineDiffDeletion[] = []; + + // Only compute the published-side diff when it's actually different + // from savedRef — saves the LCS work when there are no local saves + // (i.e., savedRef === publishedRef). + if (publishedRef !== savedRef) { + const published = lineDiff(publishedRef, current); + for (const m of published.marks) { + if (!unsavedLineSet.has(m.line)) savedLines.push(m.line); + } + for (const d of published.deletions) { + if (!unsavedDelAnchors.has(d.line)) savedDeletions.push(d); + } + } + + return { + unsavedLines: unsaved.marks.map((m) => m.line), + savedLines, + unsavedDeletions: unsaved.deletions, + savedDeletions, + }; +} diff --git a/Playground/src/sharing/local-saves.test.ts b/Playground/src/sharing/local-saves.test.ts new file mode 100644 index 0000000..9d67ca0 --- /dev/null +++ b/Playground/src/sharing/local-saves.test.ts @@ -0,0 +1,298 @@ +// Unit tests for the local-saves snapshot store. Storage is injected as +// a MemorySaveStorage so the tests don't need a browser localStorage. +// Workspace is a tiny in-memory fake that satisfies SaveWorkspaceLike. + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + MemorySaveStorage, + clearSaves, + createSave, + dropSave, + loadSaves, + revertToSave, + upgradeSave, + type LocalSave, + type SaveWorkspaceLike, +} from './local-saves'; +import { gitBlobSha } from './hash'; + +class MockWorkspace implements SaveWorkspaceLike { + files = new Map(); + async list(): Promise { return [...this.files.keys()].sort(); } + async readBytes(name: string): Promise { + const b = this.files.get(name); + if (!b) throw new Error(`not found: ${name}`); + return new Uint8Array(b); + } + async writeBytes(name: string, bytes: Uint8Array): Promise { + this.files.set(name, new Uint8Array(bytes)); + } + async delete(name: string): Promise { + this.files.delete(name); + } +} + +const enc = new TextEncoder(); +const text = (s: string) => enc.encode(s); +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +describe('createSave', () => { + let storage: MemorySaveStorage; + let ws: MockWorkspace; + beforeEach(() => { storage = new MemorySaveStorage(); ws = new MockWorkspace(); }); + + it('snapshots every workspace file into the save', async () => { + await ws.writeBytes('a.txt', text('hello')); + await ws.writeBytes('b.txt', text('world')); + const save = await createSave('p', ws, 'first', storage); + expect(Object.keys(save.files).sort()).toEqual(['a.txt', 'b.txt']); + // base64 decode each and verify content roundtripped. + const aBytes = Buffer.from(save.files['a.txt'], 'base64'); + expect(decode(aBytes)).toBe('hello'); + }); + + it('populates treeHashes that match gitBlobSha of the bytes', async () => { + await ws.writeBytes('a.txt', text('hello')); + const save = await createSave('p', ws, 'first', storage); + expect(save.treeHashes).toBeDefined(); + expect(save.treeHashes!['a.txt']).toBe(await gitBlobSha(text('hello'))); + }); + + it('filters out *.fade-conflict.* scratch files', async () => { + await ws.writeBytes('a.txt', text('hello')); + await ws.writeBytes('a.txt.fade-conflict.abc123', text('remote version')); + const save = await createSave('p', ws, 'first', storage); + expect(Object.keys(save.files)).toEqual(['a.txt']); + expect(save.treeHashes!['a.txt.fade-conflict.abc123']).toBeUndefined(); + }); + + it('stores newest-first and persists across reloads', async () => { + await ws.writeBytes('a.txt', text('v1')); + await createSave('p', ws, 'first', storage); + await ws.writeBytes('a.txt', text('v2')); + await createSave('p', ws, 'second', storage); + const reloaded = await loadSaves('p', storage); + expect(reloaded.map((s) => s.message)).toEqual(['second', 'first']); + }); + + it('keys are scoped per project', async () => { + await ws.writeBytes('a.txt', text('shared')); + await createSave('alpha', ws, 'in alpha', storage); + await createSave('beta', ws, 'in beta', storage); + expect((await loadSaves('alpha', storage)).map((s) => s.message)).toEqual(['in alpha']); + expect((await loadSaves('beta', storage)).map((s) => s.message)).toEqual(['in beta']); + }); + + it('LRU-trims to MAX_SAVES_PER_PROJECT', async () => { + await ws.writeBytes('a.txt', text('x')); + // Create 12 saves; only 10 should remain. + for (let i = 0; i < 12; i++) { + await createSave('p', ws, `save ${i}`, storage); + } + const all = await loadSaves('p', storage); + expect(all.length).toBe(10); + // Newest first → last save kept, oldest dropped. + expect(all[0].message).toBe('save 11'); + expect(all[all.length - 1].message).toBe('save 2'); + }); +}); + +describe('loadSaves', () => { + it('returns [] when nothing stored', async () => { + const storage = new MemorySaveStorage(); + expect(await loadSaves('p', storage)).toEqual([]); + }); + + it('returns [] when JSON is corrupt (graceful fallback)', async () => { + const storage = new MemorySaveStorage(); + await storage.setItem('fade-sharing:saves-v1:p', '{not-valid-json'); + expect(await loadSaves('p', storage)).toEqual([]); + }); +}); + +describe('revertToSave', () => { + let storage: MemorySaveStorage; + let ws: MockWorkspace; + beforeEach(() => { storage = new MemorySaveStorage(); ws = new MockWorkspace(); }); + + it('overwrites changed files back to the saved content', async () => { + await ws.writeBytes('a.txt', text('saved')); + const save = await createSave('p', ws, 'baseline', storage); + await ws.writeBytes('a.txt', text('edited!')); + await revertToSave(ws, save); + expect(decode(await ws.readBytes('a.txt'))).toBe('saved'); + }); + + it('deletes files that exist in the workspace but not the save', async () => { + await ws.writeBytes('a.txt', text('one')); + const save = await createSave('p', ws, 'baseline', storage); + // Now add a file that wasn't in the save. + await ws.writeBytes('extra.txt', text('added since save')); + await revertToSave(ws, save); + expect(await ws.list()).toEqual(['a.txt']); + }); + + it('leaves .fade-conflict.* scratch files untouched (not in the save, not deleted)', async () => { + await ws.writeBytes('a.txt', text('one')); + const save = await createSave('p', ws, 'baseline', storage); + // Scratch conflict copy appears (hidden from saves). + await ws.writeBytes('a.txt.fade-conflict.deadbeef', text('remote')); + await revertToSave(ws, save); + // Both should be present: a.txt from the save, conflict-copy left alone. + expect((await ws.list()).sort()).toEqual(['a.txt', 'a.txt.fade-conflict.deadbeef']); + }); +}); + +describe('dropSave', () => { + it('removes only the targeted save by id', async () => { + const storage = new MemorySaveStorage(); + const ws = new MockWorkspace(); + await ws.writeBytes('a.txt', text('x')); + const s1 = await createSave('p', ws, 'first', storage); + const s2 = await createSave('p', ws, 'second', storage); + await dropSave('p', s1.id, storage); + const left = await loadSaves('p', storage); + expect(left.map((s) => s.id)).toEqual([s2.id]); + }); + + it('no-op when id is unknown', async () => { + const storage = new MemorySaveStorage(); + const ws = new MockWorkspace(); + await ws.writeBytes('a.txt', text('x')); + await createSave('p', ws, 'first', storage); + await dropSave('p', 'never-existed', storage); + expect((await loadSaves('p', storage)).length).toBe(1); + }); +}); + +describe('clearSaves', () => { + it('wipes the entire chain for one project', async () => { + const storage = new MemorySaveStorage(); + const ws = new MockWorkspace(); + await ws.writeBytes('a.txt', text('x')); + await createSave('p', ws, 'a', storage); + await createSave('p', ws, 'b', storage); + await clearSaves('p', storage); + expect(await loadSaves('p', storage)).toEqual([]); + }); + + it('leaves other projects intact', async () => { + const storage = new MemorySaveStorage(); + const ws = new MockWorkspace(); + await ws.writeBytes('a.txt', text('x')); + await createSave('alpha', ws, 'in alpha', storage); + await createSave('beta', ws, 'in beta', storage); + await clearSaves('alpha', storage); + expect(await loadSaves('alpha', storage)).toEqual([]); + expect((await loadSaves('beta', storage)).map((s) => s.message)).toEqual(['in beta']); + }); +}); + +describe('upgradeSave', () => { + it('back-fills treeHashes from base64 file contents', async () => { + const legacy: LocalSave = { + id: 'legacy-1', + message: 'pre-treehashes save', + time: '2026-05-28T00:00:00Z', + files: { + 'a.txt': Buffer.from('legacy content').toString('base64'), + }, + // no treeHashes + }; + const upgraded = await upgradeSave(legacy); + expect(upgraded.treeHashes).toBeDefined(); + expect(upgraded.treeHashes!['a.txt']).toBe(await gitBlobSha(text('legacy content'))); + }); + + it('returns the same record unchanged when treeHashes already present', async () => { + const save: LocalSave = { + id: 's', + message: 'm', + time: 't', + files: { 'a': 'YWJj' }, + treeHashes: { 'a': 'cached-sha' }, + }; + const out = await upgradeSave(save); + expect(out.treeHashes!['a']).toBe('cached-sha'); + }); +}); + +describe('round-trip integration', () => { + it('save → edit → revertToSave restores exact bytes', async () => { + const storage = new MemorySaveStorage(); + const ws = new MockWorkspace(); + await ws.writeBytes('src.fbasic', text('print "v1"')); + await ws.writeBytes('fade.json', text('{"v":1}')); + const save = await createSave('p', ws, 'baseline', storage); + + // Heavy edits. + await ws.writeBytes('src.fbasic', text('print "totally different"')); + await ws.writeBytes('fade.json', text('{"v":2}')); + await ws.writeBytes('new.txt', text('added since save')); + + await revertToSave(ws, save); + + const list = (await ws.list()).sort(); + expect(list).toEqual(['fade.json', 'src.fbasic']); + expect(decode(await ws.readBytes('src.fbasic'))).toBe('print "v1"'); + expect(decode(await ws.readBytes('fade.json'))).toBe('{"v":1}'); + }); + + it('after createSave, treeHashes matches gitBlobSha of workspace bytes', async () => { + const storage = new MemorySaveStorage(); + const ws = new MockWorkspace(); + await ws.writeBytes('a.txt', text('alpha')); + await ws.writeBytes('b.txt', text('beta')); + const save = await createSave('p', ws, 'snap', storage); + for (const path of await ws.list()) { + const bytes = await ws.readBytes(path); + expect(save.treeHashes![path]).toBe(await gitBlobSha(bytes)); + } + }); + + // Models the exact panel flow that drives "did Save clear the unsaved + // chip?": modify a tracked file, snapshot via createSave, reload via + // loadSaves, then run computeStatus against the loaded save's + // treeHashes. A stale HashCache (the bug fixed in main.ts' + // flushPendingSaves) would surface here as a still-'modified' status. + it('modify → createSave → loadSaves → computeStatus reports unchanged (no stale cache)', async () => { + const { computeStatus, HashCache } = await import('./file-status'); + const { MemoryWorkingTree } = await import('./working-tree'); + const storage = new MemorySaveStorage(); + const wt = new MemoryWorkingTree(); + const cache = new HashCache(); + + await wt.write('main.fbasic', text('print "old"')); + const baseTree = { 'main.fbasic': await gitBlobSha(text('print "old"')) }; + + // First pass: warm the cache against the baseTree — file matches base. + const first = await computeStatus(wt, baseTree, cache); + expect(first.find((e) => e.path === 'main.fbasic')?.status).toBe('unchanged'); + + // User edits the file (external write — caller MUST invalidate). + await wt.write('main.fbasic', text('print "new"')); + cache.invalidate('main.fbasic'); + + const modified = await computeStatus(wt, baseTree, cache); + expect(modified.find((e) => e.path === 'main.fbasic')?.status).toBe('modified'); + + // User clicks Save: createSave reads via SaveWorkspaceLike — adapt + // the WorkingTree to that interface for the snapshot call. + const wsForSave: SaveWorkspaceLike = { + list: () => wt.list(), + readBytes: (p) => wt.read(p), + writeBytes: (p, b) => wt.write(p, b), + delete: (p) => wt.delete(p), + }; + await createSave('p', wsForSave, 'first', storage); + + // Reload from storage exactly the way refreshSaves does, then use + // the new save's treeHashes as the reference for computeStatus. + const loaded = await loadSaves('p', storage); + expect(loaded.length).toBe(1); + expect(loaded[0].treeHashes!['main.fbasic']).toBeDefined(); + + const afterSave = await computeStatus(wt, loaded[0].treeHashes!, cache); + expect(afterSave.find((e) => e.path === 'main.fbasic')?.status).toBe('unchanged'); + }); +}); diff --git a/Playground/src/sharing/local-saves.ts b/Playground/src/sharing/local-saves.ts new file mode 100644 index 0000000..7d4c6f7 --- /dev/null +++ b/Playground/src/sharing/local-saves.ts @@ -0,0 +1,295 @@ +// Local save store — accrued snapshots the user can take WITHOUT pushing +// to GitHub. Storage is injectable; production uses OPFS (gigabytes of +// capacity) instead of localStorage (~5MB ceiling that base64-encoded +// binary assets blow through trivially). +// +// Mental model: +// - "Save" = local checkpoint. Hit save often. Multiple saves accrue. +// - "Publish" = push to remote. Squashes all accrued work into ONE +// remote commit so the public history stays clean. +// - "Save & publish" = save then immediately publish. +// +// Storage is injected so tests can pass a memory-backed implementation +// (`MemorySaveStorage`). Each save record includes `treeHashes` (path → +// git blob sha) so the panel can answer "did the working tree diverge +// from my latest save?" without decoding the base64-encoded file +// contents on every status refresh. + +import { gitBlobSha } from './hash'; + +const SAVES_KEY_PREFIX = 'fade-sharing:saves-v1:'; +const MAX_SAVES_PER_PROJECT = 10; + +export interface LocalSave { + /** Stable id — `-`. Used for revert targeting + UI keys. */ + id: string; + message: string; + /** ISO-8601 timestamp at save time. */ + time: string; + /** path → base64-encoded file bytes. Includes every file in the + * working tree at save time (excluding `.fade-conflict.*` scratch). */ + files: Record; + /** path → git blob sha at save time. Lets the panel compare against + * the live working tree without decoding `files` on every refresh. + * Always populated by `createSave`; legacy saves without this field + * are upgraded via `upgradeSave`. */ + treeHashes?: Record; +} + +/** Minimal interface needed to read / write the working tree. The + * playground's `OpfsWorkspace` satisfies this without modification. */ +export interface SaveWorkspaceLike { + list(): Promise; + readBytes(name: string): Promise; + writeBytes(name: string, bytes: Uint8Array): Promise; + delete(name: string): Promise; +} + +/** Storage backend — async because the OPFS-backed implementation can't + * do anything synchronously. Tests pass `MemorySaveStorage`. */ +export interface SaveStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +} + +/** Convenience in-memory storage that satisfies `SaveStorage`. Test + * helper; not used in production. */ +export class MemorySaveStorage implements SaveStorage { + private map = new Map(); + async getItem(key: string): Promise { return this.map.get(key) ?? null; } + async setItem(key: string, value: string): Promise { this.map.set(key, value); } + async removeItem(key: string): Promise { this.map.delete(key); } + /** Test affordance. */ + snapshot(): Record { + return Object.fromEntries(this.map); + } +} + +/** OPFS-backed save storage. One file per key under + * `/fade-saves/.json`. OPFS quotas are + * typically gigabytes (browser-managed), so a 5MB binary asset + * base64-bloated to 7MB no longer blows up the save chain like it + * did under localStorage's ~5MB limit. */ +class OpfsSaveStorage implements SaveStorage { + private dirPromise: Promise | null = null; + + private async dir(): Promise { + if (!this.dirPromise) { + this.dirPromise = (async () => { + const root = await navigator.storage.getDirectory(); + return await root.getDirectoryHandle('fade-saves', { create: true }); + })(); + } + return this.dirPromise; + } + + private fileName(key: string): string { + // Mirror localStorage's flat namespace — collapse path-unfriendly + // chars (the key already starts with our prefix + project name). + return key.replace(/[^A-Za-z0-9._-]/g, '_') + '.json'; + } + + async getItem(key: string): Promise { + try { + const fh = await (await this.dir()).getFileHandle(this.fileName(key)); + const file = await fh.getFile(); + return await file.text(); + } catch { + // File missing or any other error → treat as absent. Saves are + // a best-effort store; we never want a missing file to break + // the panel. + return null; + } + } + + async setItem(key: string, value: string): Promise { + const fh = await (await this.dir()).getFileHandle(this.fileName(key), { create: true }); + const w = await fh.createWritable(); + await w.write(value); + await w.close(); + } + + async removeItem(key: string): Promise { + try { + await (await this.dir()).removeEntry(this.fileName(key)); + } catch { /* already gone */ } + } +} + +/** Stub used when OPFS isn't available (Node test runner, pre-OPFS + * browsers). Drops writes on the floor; tests inject their own. */ +class NullSaveStorage implements SaveStorage { + async getItem(): Promise { return null; } + async setItem(): Promise { /* nop */ } + async removeItem(): Promise { /* nop */ } +} + +let _defaultStorage: SaveStorage | null = null; +/** Resolve the production storage backend. Lazy because OPFS isn't a + * cheap probe (it triggers a permissions check on some browsers). */ +export function defaultSaveStorage(): SaveStorage { + if (_defaultStorage) return _defaultStorage; + const hasOpfs = typeof navigator !== 'undefined' + && typeof navigator.storage !== 'undefined' + && typeof navigator.storage.getDirectory === 'function'; + _defaultStorage = hasOpfs ? new OpfsSaveStorage() : new NullSaveStorage(); + return _defaultStorage; +} + +/** One-shot migration from the legacy localStorage backend. Called by + * the panel on first load — copies any existing localStorage save + * chains into OPFS, then clears them from localStorage so a future + * storage-quota recovery doesn't trip the user up again. Safe to call + * multiple times; subsequent calls are no-ops once localStorage is + * empty of save keys. */ +export async function migrateLegacyLocalStorageSaves(storage: SaveStorage = defaultSaveStorage()): Promise { + if (typeof localStorage === 'undefined') return 0; + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(SAVES_KEY_PREFIX)) keys.push(k); + } + let moved = 0; + for (const k of keys) { + const raw = localStorage.getItem(k); + if (!raw) continue; + try { + await storage.setItem(k, raw); + localStorage.removeItem(k); + moved++; + } catch { + // Migration is best-effort. Leave the localStorage entry in + // place so the next attempt can retry. + } + } + return moved; +} + +/** Substrings that disqualify a path from save snapshots. Mirrors the + * filter the collaboration panel applies to commits — same idea: scratch + * conflict-copy files don't belong in a snapshot. */ +const HIDDEN_FROM_SAVES = ['.fade-conflict.'] as const; +function isHiddenFromSaves(path: string): boolean { + return HIDDEN_FROM_SAVES.some((needle) => path.includes(needle)); +} + +/** List the saves for `project`, newest first. Returns a defensive copy. */ +export async function loadSaves(project: string, storage: SaveStorage = defaultSaveStorage()): Promise { + try { + const raw = await storage.getItem(SAVES_KEY_PREFIX + project); + if (!raw) return []; + const arr = JSON.parse(raw) as LocalSave[]; + return Array.isArray(arr) ? [...arr] : []; + } catch { + return []; + } +} + +/** Replace the on-disk save list for `project`. Failures bubble up — + * OPFS quotas are huge so a failure here is exceptional and the + * caller (createSave) needs to know rather than silently dropping the + * user's snapshot like the old localStorage path did. */ +async function writeSaves(project: string, saves: LocalSave[], storage: SaveStorage): Promise { + await storage.setItem(SAVES_KEY_PREFIX + project, JSON.stringify(saves)); +} + +/** Snapshot the working tree to a new local save. Returns the created + * save record. Older saves beyond `MAX_SAVES_PER_PROJECT` are evicted + * (oldest-first). The save includes both base64 file contents AND a + * `treeHashes` map for fast comparison later. */ +export async function createSave( + project: string, + workspace: SaveWorkspaceLike, + message: string, + storage: SaveStorage = defaultSaveStorage(), +): Promise { + const paths = (await workspace.list()).filter((p) => !isHiddenFromSaves(p)); + const files: Record = {}; + const treeHashes: Record = {}; + for (const path of paths) { + const bytes = await workspace.readBytes(path); + files[path] = bytesToBase64(bytes); + treeHashes[path] = await gitBlobSha(bytes); + } + const save: LocalSave = { + id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + message, + time: new Date().toISOString(), + files, + treeHashes, + }; + const saves = await loadSaves(project, storage); + // Newest first. + saves.unshift(save); + while (saves.length > MAX_SAVES_PER_PROJECT) saves.pop(); + await writeSaves(project, saves, storage); + return save; +} + +/** Restore the working tree to match `save`. Writes every file the save + * contains and deletes any non-save files (except hidden ones like + * `.fade-conflict.*` which we leave alone). */ +export async function revertToSave( + workspace: SaveWorkspaceLike, + save: LocalSave, +): Promise { + const currentPaths = new Set(await workspace.list()); + for (const [path, b64] of Object.entries(save.files)) { + await workspace.writeBytes(path, base64ToBytes(b64)); + currentPaths.delete(path); + } + for (const path of currentPaths) { + if (isHiddenFromSaves(path)) continue; + try { await workspace.delete(path); } catch { /* fade.json guard etc — leave it */ } + } +} + +/** Wipe all saves for a project. Called after a successful Publish so the + * pending-saves chip resets. */ +export async function clearSaves(project: string, storage: SaveStorage = defaultSaveStorage()): Promise { + try { await storage.removeItem(SAVES_KEY_PREFIX + project); } catch { /* ignore */ } +} + +/** Drop one specific save by id. No-op if not found. */ +export async function dropSave(project: string, id: string, storage: SaveStorage = defaultSaveStorage()): Promise { + const saves = (await loadSaves(project, storage)).filter((s) => s.id !== id); + await writeSaves(project, saves, storage); +} + +/** Compute `treeHashes` for a save record that was written before the + * field existed. Returns a NEW save record with the field populated; + * caller is responsible for persisting if they want to skip the + * recomputation next time. */ +export async function upgradeSave(save: LocalSave): Promise { + if (save.treeHashes) return save; + const treeHashes: Record = {}; + for (const [path, b64] of Object.entries(save.files)) { + treeHashes[path] = await gitBlobSha(base64ToBytes(b64)); + } + return { ...save, treeHashes }; +} + +// ─── base64 helpers (mirror the adapter's) ────────────────────────────────── + +function bytesToBase64(bytes: Uint8Array): string { + if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64'); + let bin = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]); + } + return btoa(bin); +} + +function base64ToBytes(b64: string): Uint8Array { + const clean = b64.replace(/\s+/g, ''); + if (typeof Buffer !== 'undefined') { + const buf = Buffer.from(clean, 'base64'); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + const bin = atob(clean); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/Playground/src/sharing/mock-adapter.ts b/Playground/src/sharing/mock-adapter.ts new file mode 100644 index 0000000..34eb624 --- /dev/null +++ b/Playground/src/sharing/mock-adapter.ts @@ -0,0 +1,169 @@ +// In-memory `GitAdapter` for unit tests. Models git's three object kinds +// (blob / tree / commit) plus the branch ref, with all the same invariants +// the real backend enforces: +// +// - createBlob / createTree / createCommit are content-addressed (same +// inputs → same SHA, idempotent re-creation). +// - updateBranch enforces the fast-forward rule using the commit's own +// parent pointer — the new commit must descend from the current head, +// or HeadConflictError is thrown. +// +// SHAs here are *not* git's actual SHA-1 derivation — that would force +// canonical-byte serialization just to compute test ids. Instead we use the +// sha256 of a deterministic JSON encoding. Stable across runs, comparable, +// good enough since tests never cross between the mock and a real git +// process. + +import { HeadConflictError, type GitAdapter } from './adapter'; +import type { GitCommitMeta, GitTree } from './git-types'; +import { gitBlobSha, sha256Hex } from './hash'; + +interface MockCommit { + sha: string; + parents: string[]; + treeSha: string; + message: string; + author: string; + time: string; +} + +export class MockAdapter implements GitAdapter { + private blobs = new Map(); + private trees = new Map(); + private commits = new Map(); + private branchHeadSha: string | null = null; + private clock = 0; + + // ─── read path ────────────────────────────────────────────────────────── + + async branchHead(): Promise { + return this.branchHeadSha; + } + + async getCommit(sha: string): Promise { + const c = this.commits.get(sha); + if (!c) throw new Error(`commit not found: ${sha}`); + return { ...c }; + } + + async getTree(commitSha: string): Promise { + const c = this.commits.get(commitSha); + if (!c) throw new Error(`commit not found: ${commitSha}`); + const t = this.trees.get(c.treeSha); + if (!t) throw new Error(`tree not found: ${c.treeSha}`); + // Return a defensive copy so callers can mutate without poisoning storage. + return Object.fromEntries(Object.entries(t).map(([p, e]) => [p, { ...e }])); + } + + async getBlob(blobSha: string): Promise { + const b = this.blobs.get(blobSha); + if (!b) throw new Error(`blob not found: ${blobSha}`); + return new Uint8Array(b); + } + + // ─── write path ───────────────────────────────────────────────────────── + + async createBlob(bytes: Uint8Array): Promise<{ sha: string }> { + const sha = await gitBlobSha(bytes); + if (!this.blobs.has(sha)) this.blobs.set(sha, new Uint8Array(bytes)); + return { sha }; + } + + async createTree(opts: { + baseTreeSha?: string; + entries: Array<{ path: string; blobSha: string | null }>; + }): Promise<{ sha: string }> { + const base = opts.baseTreeSha ? this.trees.get(opts.baseTreeSha) : undefined; + const next: GitTree = base ? { ...base } : {}; + for (const e of opts.entries) { + if (e.blobSha === null) { + delete next[e.path]; + } else { + next[e.path] = { blobSha: e.blobSha, mode: '100644' }; + } + } + const sha = await canonicalSha(['tree', JSON.stringify(sortKeys(next))]); + if (!this.trees.has(sha)) this.trees.set(sha, next); + return { sha }; + } + + async createCommit(opts: { + message: string; + treeSha: string; + parents: string[]; + author?: { name: string; email: string }; + }): Promise<{ sha: string }> { + this.clock++; + const author = opts.author?.name ?? 'tester'; + const time = new Date(1700000000000 + this.clock * 1000).toISOString(); + const sha = await canonicalSha([ + 'commit', opts.treeSha, opts.parents.join(','), opts.message, author, time, + ]); + const commit: MockCommit = { + sha, + parents: [...opts.parents], + treeSha: opts.treeSha, + message: opts.message, + author, + time, + }; + if (!this.commits.has(sha)) this.commits.set(sha, commit); + return { sha }; + } + + async updateBranch(commitSha: string): Promise { + const c = this.commits.get(commitSha); + if (!c) throw new Error(`updateBranch: commit not in store: ${commitSha}`); + if (this.branchHeadSha === null) { + // First commit on the branch — must be a root commit (no parents). + if (c.parents.length > 0) { + throw new HeadConflictError(null, this.branchHeadSha); + } + this.branchHeadSha = commitSha; + return; + } + // FF check: new commit's parent must be the current head (single-line + // history). True git fast-forward would walk the parent chain to find + // an ancestor; v1 mock keeps it simple. + if (!c.parents.includes(this.branchHeadSha)) { + throw new HeadConflictError(this.branchHeadSha, this.branchHeadSha); + } + this.branchHeadSha = commitSha; + } + + // ─── log ──────────────────────────────────────────────────────────────── + + async listCommits(opts: { start?: string; limit?: number } = {}): Promise { + const limit = opts.limit ?? 50; + let cursor: string | undefined = opts.start ?? this.branchHeadSha ?? undefined; + const out: GitCommitMeta[] = []; + while (cursor && out.length < limit) { + const c = this.commits.get(cursor); + if (!c) break; + out.push({ ...c }); + cursor = c.parents[0]; + } + return out; + } + + // ─── test helpers (not part of the interface) ────────────────────────── + + blobCount(): number { return this.blobs.size; } + treeCount(): number { return this.trees.size; } + commitCount(): number { return this.commits.size; } + currentBranchHead(): string | null { return this.branchHeadSha; } +} + +// Same canonicalization recipe as the manifest used to do: sort keys, hash +// the bytes. We don't need git's actual SHA-1 derivation for tests — only +// that ids are stable and content-derived. +async function canonicalSha(parts: string[]): Promise { + const text = parts.join(''); + return await sha256Hex(new TextEncoder().encode(text)); +} + +function sortKeys>(obj: T): T { + const sorted: Record = {}; + for (const k of Object.keys(obj).sort()) sorted[k] = obj[k]; + return sorted as T; +} diff --git a/Playground/src/sharing/monaco-gutter.ts b/Playground/src/sharing/monaco-gutter.ts new file mode 100644 index 0000000..3661133 --- /dev/null +++ b/Playground/src/sharing/monaco-gutter.ts @@ -0,0 +1,179 @@ +// Monaco gutter decorator — paints per-line markers on the editor margin +// indicating which lines have been added or modified relative to the last +// synced base. Hooked per Monaco model in main.ts. +// +// Refresh triggers: +// - model content change (debounced; uses last-known base, no fetch) +// - external refresh (sharing status changed → maybe new base, refetch) +// - initial attach (one fetch) +// +// The base content is supplied by an injected `getBaseText` so this module +// stays free of GitHub-adapter coupling and is easy to test against a mock. + +import type * as monaco from 'monaco-editor'; +import { lineDiffTriState, type LineTriDiff } from './line-diff'; + +export interface AttachGutterOptions { + model: monaco.editor.ITextModel; + /** Text of this file at the user's latest local save. Null when the + * file isn't part of any save (added since publish). When null AND + * `getPublishedText` returns a value, the gutter falls back to + * treating that as the reference (current behaviour pre-saves). */ + getSavedText: () => Promise; + /** Text of this file at the last published commit. Null when the + * file isn't on the remote yet. */ + getPublishedText: () => Promise; + /** Subscribe to external refresh requests. Return value unsubscribes. */ + onShouldRefresh: (listener: () => void) => () => void; + /** Debounce ms before refetching reference texts after model change. + * Default 400. */ + debounceMs?: number; +} + +export interface GutterHandle { + dispose(): void; +} + +export function attachGutter(opts: AttachGutterOptions): GutterHandle { + let decorationIds: string[] = []; + let pendingTimer: number | undefined; + /** Last-known reference texts. Both nullable. When non-null they're + * the inputs to `lineDiffTriState`. */ + let lastSavedText: string | null = null; + let lastPublishedText: string | null = null; + let disposed = false; + + function applyDecorations() { + if (disposed) return; + // No reference at all → no decorations to show. Common for a file + // that's never been part of any save or publish. + if (lastSavedText === null && lastPublishedText === null) { + decorationIds = opts.model.deltaDecorations(decorationIds, []); + return; + } + // Fall back to a single-text reference when one side is missing — + // e.g. a file that exists in the latest save but not in the + // remote, or vice versa. + const savedRef = lastSavedText ?? lastPublishedText ?? ''; + const publishedRef = lastPublishedText ?? lastSavedText ?? ''; + const current = opts.model.getValue(); + const diff = lineDiffTriState(publishedRef, savedRef, current); + decorationIds = opts.model.deltaDecorations(decorationIds, buildDecorations(diff)); + } + + async function refetchAndRender() { + if (disposed) return; + try { + const [saved, published] = await Promise.all([ + opts.getSavedText(), + opts.getPublishedText(), + ]); + if (disposed) return; + lastSavedText = saved; + lastPublishedText = published; + applyDecorations(); + } catch { + lastSavedText = null; + lastPublishedText = null; + applyDecorations(); + } + } + + function scheduleRefetch() { + if (pendingTimer != null) clearTimeout(pendingTimer); + pendingTimer = window.setTimeout(() => { + pendingTimer = undefined; + void refetchAndRender(); + }, opts.debounceMs ?? 400); + } + + // Model content changes → cheap rerender with the last-known base. Skip + // a fetch; the post-save refresh path (onShouldRefresh) handles base + // staleness independently. + // + // The line-diff is O(N*M) over file size, so for ~1000-line files this + // is non-trivial per keystroke. Debounce to ~120 ms — well under one + // editor tick so it still feels live, but coalesces typing bursts. + let rerenderTimer: number | undefined; + const RERENDER_DEBOUNCE_MS = 120; + const contentSub = opts.model.onDidChangeContent(() => { + if (rerenderTimer != null) clearTimeout(rerenderTimer); + rerenderTimer = window.setTimeout(() => { + rerenderTimer = undefined; + applyDecorations(); + }, RERENDER_DEBOUNCE_MS); + }); + + const unsubRefresh = opts.onShouldRefresh(() => { scheduleRefetch(); }); + + // Initial render. + void refetchAndRender(); + + return { + dispose() { + disposed = true; + contentSub.dispose(); + unsubRefresh(); + if (pendingTimer != null) clearTimeout(pendingTimer); + if (rerenderTimer != null) clearTimeout(rerenderTimer); + opts.model.deltaDecorations(decorationIds, []); + decorationIds = []; + }, + }; +} + +function buildDecorations(diff: LineTriDiff): monaco.editor.IModelDeltaDecoration[] { + const out: monaco.editor.IModelDeltaDecoration[] = []; + // Unsaved edits — top priority colour (orange-ish). Live changes + // since the last save. + for (const line of diff.unsavedLines) { + out.push({ + range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 }, + options: { + isWholeLine: false, + linesDecorationsClassName: 'sharing-gutter sharing-gutter-unsaved', + overviewRuler: { color: '#ffb74d', position: 4 }, + }, + }); + } + // Saved-but-not-yet-published edits — distinct colour (purple-ish) + // so the user can see "this is captured in a save, waiting for + // Publish." + for (const line of diff.savedLines) { + out.push({ + range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 }, + options: { + isWholeLine: false, + linesDecorationsClassName: 'sharing-gutter sharing-gutter-saved', + overviewRuler: { color: '#b88aff', position: 4 }, + }, + }); + } + for (const d of diff.unsavedDeletions) { + const anchorLine = d.line === 0 ? 1 : d.line; + const cls = d.line === 0 + ? 'sharing-gutter sharing-gutter-deletion-above sharing-gutter-deletion-unsaved' + : 'sharing-gutter sharing-gutter-deletion-below sharing-gutter-deletion-unsaved'; + out.push({ + range: { startLineNumber: anchorLine, startColumn: 1, endLineNumber: anchorLine, endColumn: 1 }, + options: { + linesDecorationsClassName: cls, + overviewRuler: { color: '#f44336', position: 4 }, + }, + }); + } + for (const d of diff.savedDeletions) { + const anchorLine = d.line === 0 ? 1 : d.line; + const cls = d.line === 0 + ? 'sharing-gutter sharing-gutter-deletion-above sharing-gutter-deletion-saved' + : 'sharing-gutter sharing-gutter-deletion-below sharing-gutter-deletion-saved'; + out.push({ + range: { startLineNumber: anchorLine, startColumn: 1, endLineNumber: anchorLine, endColumn: 1 }, + options: { + linesDecorationsClassName: cls, + overviewRuler: { color: '#b88aff', position: 4 }, + }, + }); + } + return out; +} diff --git a/Playground/src/sharing/opfs-working-tree.ts b/Playground/src/sharing/opfs-working-tree.ts new file mode 100644 index 0000000..7609f60 --- /dev/null +++ b/Playground/src/sharing/opfs-working-tree.ts @@ -0,0 +1,61 @@ +// Bridges the playground's existing `OpfsWorkspace` (which the editor mutates +// directly) to the `WorkingTree` interface the Repo engine expects. Lets us +// share one set of file ops between the live editor and the +// snapshot/commit/checkout machinery. +// +// `OpfsWorkspace` uses text/bytes pairs (read/readBytes, write/writeBytes); +// the engine wants bytes uniformly. We pick `readBytes`/`writeBytes` since +// every file goes through this path regardless of text-vs-binary. + +import type { WorkingTree } from './working-tree'; + +/** Minimal slice of OpfsWorkspace used here — declared structurally so tests + * and non-OPFS callers can plug in something else. The real `OpfsWorkspace` + * satisfies this without any code changes. */ +export interface OpfsWorkspaceLike { + list(): Promise; + readBytes(name: string): Promise; + writeBytes(name: string, bytes: Uint8Array): Promise; + delete(name: string): Promise; +} + +/** Substrings that disqualify a path from being snapshotted. Used to keep + * scratch files (conflict copies, in particular) out of commits. */ +const HIDDEN_FROM_COMMITS = ['.fade-conflict.'] as const; + +export function isHiddenFromCommits(path: string): boolean { + return HIDDEN_FROM_COMMITS.some((needle) => path.includes(needle)); +} + +export class OpfsWorkingTree implements WorkingTree { + constructor(private readonly ws: OpfsWorkspaceLike) {} + + async list(): Promise { + const all = await this.ws.list(); + // Conflict-copy files written by the resolve-conflict flow must NOT + // be committed — they're per-machine scratch. Filtering here keeps + // the engine and the UI honest without each having to know. + return all.filter((p) => !isHiddenFromCommits(p)); + } + + async read(path: string): Promise { + return await this.ws.readBytes(path); + } + + async write(path: string, bytes: Uint8Array): Promise { + await this.ws.writeBytes(path, bytes); + } + + async delete(path: string): Promise { + await this.ws.delete(path); + } + + async has(path: string): Promise { + // OpfsWorkspace doesn't expose existence directly; list() is the + // canonical source. Callers (the engine) only invoke `has` during + // checkout's "should I overwrite an existing path?" decision, so a + // single list per checkout is acceptable cost. + const names = await this.ws.list(); + return names.includes(path); + } +} diff --git a/Playground/src/sharing/repo.test.ts b/Playground/src/sharing/repo.test.ts new file mode 100644 index 0000000..849e1cb --- /dev/null +++ b/Playground/src/sharing/repo.test.ts @@ -0,0 +1,234 @@ +// Repo engine integration tests, driven against the in-memory MockAdapter +// which models git's blob/tree/commit/ref shape exactly the way the real +// GitHubAdapter does. +// +// Coverage: +// - commit round-trip: snapshot → blobs → tree → commit → ref-advance +// - dedup: unchanged blobs aren't re-uploaded +// - checkout/clone reproduces the working tree from a commit's tree +// - log walks the commit chain +// - tryFastForward applies remote commits when the working tree is clean +// - tryFastForward refuses to clobber a dirty working tree +// - commit raises HeadConflictError when remote moved (pull-before-commit gate) +// - setSyncedHead rehydrates the engine across instance rebuilds + +import { beforeEach, describe, expect, it } from 'vitest'; +import { HeadConflictError } from './adapter'; +import { MockAdapter } from './mock-adapter'; +import { Repo } from './repo'; +import { MemoryWorkingTree, type WorkingTree } from './working-tree'; +import { gitBlobSha } from './hash'; + +const enc = new TextEncoder(); +const text = (s: string) => enc.encode(s); + +async function seed(wt: WorkingTree, files: Record) { + for (const [path, content] of Object.entries(files)) { + await wt.write(path, typeof content === 'string' ? text(content) : content); + } +} + +async function readText(wt: WorkingTree, path: string): Promise { + return new TextDecoder().decode(await wt.read(path)); +} + +const AUTHOR = { author: 'tester', message: 'change' }; + +describe('Repo: commit + checkout round-trip', () => { + let adapter: MockAdapter; + let repo: Repo; + let wt: MemoryWorkingTree; + + beforeEach(() => { + adapter = new MockAdapter(); + repo = new Repo(adapter); + wt = new MemoryWorkingTree(); + }); + + it('first commit creates a root commit with no parents', async () => { + await seed(wt, { 'main.fbasic': 'print "hi"', 'fade.json': '{}' }); + const c = await repo.commit(wt, { ...AUTHOR, message: 'init' }); + expect(c.parents).toEqual([]); + expect(c.message).toBe('init'); + // Each file becomes its own blob. + expect(adapter.blobCount()).toBe(2); + // Branch ref advanced to this commit. + expect(adapter.currentBranchHead()).toBe(c.sha); + }); + + it('round-trips bytes through a fresh checkout', async () => { + await seed(wt, { + 'src.fbasic': 'print 1', + 'assets/hero.png': new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + }); + const c = await repo.commit(wt, { ...AUTHOR, message: 'init' }); + + // Fresh repo / fresh working tree → checkout should reproduce both files. + const repo2 = new Repo(adapter); + const wt2 = new MemoryWorkingTree(); + await repo2.checkout(wt2, c.sha); + + expect(await readText(wt2, 'src.fbasic')).toBe('print 1'); + const png = await wt2.read('assets/hero.png'); + expect([...png]).toEqual([0x89, 0x50, 0x4e, 0x47]); + }); + + it('second commit dedups unchanged blobs (createBlob is idempotent)', async () => { + await seed(wt, { 'a.txt': 'AAA', 'b.txt': 'BBB' }); + await repo.commit(wt, { ...AUTHOR, message: 'init' }); + const blobsAfterFirst = adapter.blobCount(); + + // Only b.txt changes. a.txt's blob must NOT count as a new blob. + await wt.write('b.txt', text('BBB-edited')); + await repo.commit(wt, { ...AUTHOR, message: 'edit b' }); + + // One additional blob (the new b.txt content). a.txt's blob already + // present and idempotently re-stored by the adapter. + expect(adapter.blobCount()).toBe(blobsAfterFirst + 1); + }); + + it('handles file deletion: tree at HEAD omits removed paths', async () => { + await seed(wt, { 'keep.txt': 'k', 'gone.txt': 'g' }); + await repo.commit(wt, { ...AUTHOR, message: 'init' }); + await wt.delete('gone.txt'); + const c2 = await repo.commit(wt, { ...AUTHOR, message: 'rm gone' }); + + const tree = await adapter.getTree(c2.sha); + expect(Object.keys(tree).sort()).toEqual(['keep.txt']); + }); +}); + +describe('Repo: log walks the commit chain', () => { + it('returns commits newest-first from a given starting point', async () => { + const adapter = new MockAdapter(); + const repo = new Repo(adapter); + const wt = new MemoryWorkingTree(); + await seed(wt, { 'a.txt': '1' }); + const c1 = await repo.commit(wt, { ...AUTHOR, message: 'one' }); + await wt.write('a.txt', text('2')); + const c2 = await repo.commit(wt, { ...AUTHOR, message: 'two' }); + await wt.write('a.txt', text('3')); + const c3 = await repo.commit(wt, { ...AUTHOR, message: 'three' }); + + const log = await repo.log({ from: c3.sha, limit: 5 }); + expect(log.map((c) => c.message)).toEqual(['three', 'two', 'one']); + expect(log[0].sha).toBe(c3.sha); + expect(log[2].parents).toEqual([]); + void c1; void c2; + }); +}); + +describe('Repo: tryFastForward', () => { + it('applies a remote commit onto a clean working tree', async () => { + const adapter = new MockAdapter(); + const wt = new MemoryWorkingTree(); + + // "Remote" — author commits. + const author = new Repo(adapter); + await seed(wt, { 'a.txt': 'one' }); + const c1 = await author.commit(wt, { ...AUTHOR, message: 'init' }); + await wt.write('a.txt', text('two')); + const c2 = await author.commit(wt, { ...AUTHOR, message: 'edit' }); + + // "Local" — fresh Repo + WT seeded to the same starting state. + const local = new Repo(adapter); + const localWt = new MemoryWorkingTree(); + await local.checkout(localWt, c1.sha); + // local now matches commit 1; remote is at commit 2 → FF expected. + const result = await local.tryFastForward(localWt); + expect(result.applied).toBe(true); + expect(result.to).toBe(c2.sha); + expect(await readText(localWt, 'a.txt')).toBe('two'); + }); + + it('refuses to FF when the working tree is dirty', async () => { + const adapter = new MockAdapter(); + const wt = new MemoryWorkingTree(); + const author = new Repo(adapter); + await seed(wt, { 'a.txt': 'one' }); + const c1 = await author.commit(wt, { ...AUTHOR, message: 'init' }); + await wt.write('a.txt', text('two')); + await author.commit(wt, { ...AUTHOR, message: 'remote edit' }); + + const local = new Repo(adapter); + const localWt = new MemoryWorkingTree(); + await local.checkout(localWt, c1.sha); + // Dirty the working tree with a local change. + await localWt.write('a.txt', text('local-edit')); + + const result = await local.tryFastForward(localWt); + expect(result.applied).toBe(false); + expect(result.dirty).toBe(true); + }); +}); + +describe('Repo: commit raises HeadConflictError on race', () => { + it('two Repos sharing one MockAdapter — second commit collides', async () => { + const adapter = new MockAdapter(); + const sharedWt = new MemoryWorkingTree(); + await seed(sharedWt, { 'a.txt': 'one' }); + + const a = new Repo(adapter); + await a.commit(sharedWt, { ...AUTHOR, message: 'init' }); + + // Independent Repo instance — same backing store, but local syncedHead + // tracks something different. We force this by *not* calling + // refreshSyncedHead — `b` starts as if there were no prior commits. + const b = new Repo(adapter); + const bWt = new MemoryWorkingTree(); + await seed(bWt, { 'a.txt': 'different' }); + // b thinks it's emitting a root commit; updateBranch will reject + // because the branch already has `a`'s commit. + await expect(b.commit(bWt, { ...AUTHOR, message: 'b clobber' })).rejects.toBeInstanceOf(HeadConflictError); + }); +}); + +describe('Repo: setSyncedHead rehydration', () => { + it('lets a fresh Repo commit cleanly when given a synced state', async () => { + const adapter = new MockAdapter(); + const wt = new MemoryWorkingTree(); + await seed(wt, { 'a.txt': 'one' }); + const author = new Repo(adapter); + const c1 = await author.commit(wt, { ...AUTHOR, message: 'init' }); + + // Rebuild Repo from scratch — simulates the panel's `buildRepo`. + const fresh = new Repo(adapter); + const tree = await adapter.getTree(c1.sha); + fresh.setSyncedHead({ commitSha: c1.sha, treeSha: (await adapter.getCommit(c1.sha)).treeSha }, tree); + + // Now edit and commit through `fresh`. Without setSyncedHead this + // would error as a HeadConflict (its parent would be empty). + await wt.write('a.txt', text('two')); + const c2 = await fresh.commit(wt, { ...AUTHOR, message: 'edit' }); + expect(c2.parents).toEqual([c1.sha]); + }); +}); + +describe('Repo: stagedChanges', () => { + it('reports added / modified / deleted paths against syncedTree', async () => { + const adapter = new MockAdapter(); + const wt = new MemoryWorkingTree(); + await seed(wt, { 'kept.txt': 'k', 'edit.txt': 'old', 'gone.txt': 'g' }); + const repo = new Repo(adapter); + await repo.commit(wt, { ...AUTHOR, message: 'init' }); + + await wt.write('edit.txt', text('new')); + await wt.delete('gone.txt'); + await wt.write('fresh.txt', text('f')); + + const diff = await repo.stagedChanges(wt); + expect(diff.added).toEqual(['fresh.txt']); + expect(diff.modified).toEqual(['edit.txt']); + expect(diff.deleted).toEqual(['gone.txt']); + }); +}); + +describe('gitBlobSha integration', () => { + it('matches the blob sha createBlob returns', async () => { + const adapter = new MockAdapter(); + const bytes = text('hello playground'); + const local = await gitBlobSha(bytes); + const { sha } = await adapter.createBlob(bytes); + expect(sha).toBe(local); + }); +}); diff --git a/Playground/src/sharing/repo.ts b/Playground/src/sharing/repo.ts new file mode 100644 index 0000000..31018a5 --- /dev/null +++ b/Playground/src/sharing/repo.ts @@ -0,0 +1,304 @@ +// Repo — the playground-side engine that drives commit / checkout / +// fast-forward against any GitAdapter. +// +// Compared to the prior "fake-git on a key-value store" version, this one is +// noticeably smaller: there's no manifest format to serialize, no +// custom-commit-id to compute, no objects-directory to manage. The engine +// just orchestrates git's own primitives — createBlob → createTree → +// createCommit → updateBranch on writes; getCommit → getTree → getBlob on +// reads — and tracks the last-synced state so it can diff the working tree +// against it. + +import { type GitAdapter } from './adapter'; +import { diffGitTrees, flattenTreeToBlobShas, type GitCommitMeta, type GitTree, type TreeDiff } from './git-types'; +import { gitBlobSha } from './hash'; +import type { WorkingTree } from './working-tree'; + +export interface RepoOptions { + /** Override binary-classification (text vs binary by extension). Used at + * merge time — text gets 3-way, binary gets conflict copy. v1 doesn't + * expose a per-file merge path so this is currently unused, but kept on + * the interface for forward compatibility. */ + isBinary?: (path: string) => boolean; +} + +/** Granular progress callback fired by the engine at each phase of a long + * operation. Used by the collaboration panel to drive the busy banner + + * log stream. `current`/`total` are present only when meaningful (e.g. + * "uploading blob 3 of 12"). */ +export type ProgressFn = (event: ProgressEvent) => void; + +export type ProgressEvent = + | { phase: 'snapshot' } + | { phase: 'diff'; added: number; modified: number; deleted: number } + | { phase: 'blob-upload'; path: string; current: number; total: number } + | { phase: 'tree' } + | { phase: 'commit-object' } + | { phase: 'update-branch' } + | { phase: 'fetch-tree'; commitSha: string } + | { phase: 'blob-download'; path: string; current: number; total: number } + | { phase: 'apply'; path: string; current: number; total: number } + | { phase: 'delete'; path: string }; + +export interface CommitOptions { + message: string; + author: string; + onProgress?: ProgressFn; +} + +export interface CheckoutOptions { + onProgress?: ProgressFn; +} + +export interface FastForwardOptions { + onProgress?: ProgressFn; +} + +export interface FastForwardResult { + /** True if the working tree advanced. */ + applied: boolean; + /** Commit we moved from (the previously-synced HEAD), if any. */ + from?: string; + /** Commit we moved to (the new HEAD), if anything to apply. */ + to?: string; + /** True if the working tree had uncommitted changes and we refused to FF. + * Caller's job to surface this and route to merge (the panel does it). */ + dirty?: boolean; +} + +/** What the Repo tracks about its last-known sync point. The `treeSha` lets + * us pass `base_tree` to createTree, which only sends the *changed* entries + * instead of the full tree shape. */ +export interface SyncedHead { + commitSha: string; + treeSha: string; +} + +export class Repo { + private syncedHead: SyncedHead | null = null; + /** path → entry at the synced commit. Defensive copy of getTree's result. */ + private syncedTree: GitTree = {}; + + constructor(private adapter: GitAdapter, _opts: RepoOptions = {}) { + // _opts reserved for forward compatibility (isBinary, etc.) — kept on + // the constructor signature so callers can pass it now and the engine + // picks it up later without an API churn. + void _opts; + } + + // ─── synced state ──────────────────────────────────────────────────────── + + getSyncedHead(): SyncedHead | null { return this.syncedHead ? { ...this.syncedHead } : null; } + getSyncedTree(): GitTree { + return Object.fromEntries(Object.entries(this.syncedTree).map(([p, e]) => [p, { ...e }])); + } + + /** Inject the engine's view of the last sync point. Required after + * rebuilding a Repo from cached state — see the panel's `buildRepo`. */ + setSyncedHead(head: SyncedHead | null, tree: GitTree): void { + this.syncedHead = head ? { ...head } : null; + this.syncedTree = { ...tree }; + } + + /** Refetch the branch HEAD + its tree from the adapter. Equivalent to + * "reset to remote state, discarding our local view." Used to recover + * from stale-state confusion. */ + async refreshSyncedHead(): Promise { + const sha = await this.adapter.branchHead(); + if (!sha) { + this.syncedHead = null; + this.syncedTree = {}; + return null; + } + const commit = await this.adapter.getCommit(sha); + const tree = await this.adapter.getTree(sha); + this.syncedHead = { commitSha: sha, treeSha: commit.treeSha }; + this.syncedTree = tree; + return { ...this.syncedHead }; + } + + // ─── log / commit lookup ───────────────────────────────────────────────── + + async getCommit(sha: string): Promise { + return await this.adapter.getCommit(sha); + } + + async log(opts: { from?: string; limit?: number } = {}): Promise { + return await this.adapter.listCommits({ + start: opts.from ?? this.syncedHead?.commitSha, + limit: opts.limit ?? 30, + }); + } + + // ─── snapshot / diff ───────────────────────────────────────────────────── + + /** Hash every file in the working tree to a GitTree. Used by stagedChanges + * and as the first step of commit. */ + async snapshot(wt: WorkingTree): Promise { + const tree: GitTree = {}; + const paths = await wt.list(); + for (const p of paths) { + const bytes = await wt.read(p); + tree[p] = { blobSha: await gitBlobSha(bytes), size: bytes.length, mode: '100644' }; + } + return tree; + } + + async stagedChanges(wt: WorkingTree): Promise { + const live = await this.snapshot(wt); + return diffGitTrees(this.syncedTree, live); + } + + // ─── commit (the write path) ───────────────────────────────────────────── + + async commit(wt: WorkingTree, opts: CommitOptions): Promise { + const progress = opts.onProgress; + // 1. Snapshot + diff + progress?.({ phase: 'snapshot' }); + const live = await this.snapshot(wt); + const diff = diffGitTrees(this.syncedTree, live); + progress?.({ + phase: 'diff', + added: diff.added.length, + modified: diff.modified.length, + deleted: diff.deleted.length, + }); + if (diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0) { + throw new Error('nothing to commit'); + } + + // 2. Upload changed blobs (createBlob is content-addressed and + // idempotent, so duplicates against existing repo content are + // just a wasted byte — no correctness issue). + const entries: Array<{ path: string; blobSha: string | null }> = []; + const changedPaths = [...diff.added, ...diff.modified]; + for (let i = 0; i < changedPaths.length; i++) { + const path = changedPaths[i]; + progress?.({ phase: 'blob-upload', path, current: i + 1, total: changedPaths.length }); + const bytes = await wt.read(path); + const { sha } = await this.adapter.createBlob(bytes); + entries.push({ path, blobSha: sha }); + if (sha !== live[path].blobSha) live[path].blobSha = sha; + } + for (const path of diff.deleted) { + entries.push({ path, blobSha: null }); + } + + // 3. Build the new tree (use base_tree so we only resend the diff). + progress?.({ phase: 'tree' }); + const { sha: treeSha } = await this.adapter.createTree({ + baseTreeSha: this.syncedHead?.treeSha, + entries, + }); + + // 4. Create the commit object referencing the synced head as parent. + progress?.({ phase: 'commit-object' }); + const parents = this.syncedHead ? [this.syncedHead.commitSha] : []; + const { sha: commitSha } = await this.adapter.createCommit({ + message: opts.message, + treeSha, + parents, + author: { name: opts.author, email: `${opts.author}@fade-playground` }, + }); + + // 5. Move the branch ref. FF-checked; throws HeadConflictError on + // race (the new commit's parent isn't an ancestor of the current + // ref → updateBranch translates the 422 into HeadConflictError). + progress?.({ phase: 'update-branch' }); + await this.adapter.updateBranch(commitSha); + + // 6. Advance our synced state. + this.syncedHead = { commitSha, treeSha }; + this.syncedTree = { ...live }; + + return await this.adapter.getCommit(commitSha); + } + + // ─── checkout / materialize ────────────────────────────────────────────── + + /** Make the working tree match the tree of `targetCommitSha` (defaults to + * branch HEAD). Files in WT but not in target are deleted; files in + * target not in WT are written; files differing by blobSha are + * overwritten. Caller is responsible for surfacing local changes that + * would be lost — this method does *not* refuse to clobber. */ + async checkout(wt: WorkingTree, targetCommitSha?: string, opts: CheckoutOptions = {}): Promise { + const target = targetCommitSha ?? await this.adapter.branchHead(); + if (!target) throw new Error('checkout: no commit to check out'); + opts.onProgress?.({ phase: 'fetch-tree', commitSha: target }); + const commit = await this.adapter.getCommit(target); + const tree = await this.adapter.getTree(target); + await this.materialize(wt, tree, opts.onProgress); + this.syncedHead = { commitSha: target, treeSha: commit.treeSha }; + this.syncedTree = { ...tree }; + } + + private async materialize(wt: WorkingTree, tree: GitTree, onProgress?: ProgressFn): Promise { + const localPaths = new Set(await wt.list()); + const paths = Object.keys(tree); + for (let i = 0; i < paths.length; i++) { + const path = paths[i]; + const entry = tree[path]; + // Skip the fetch if local content already matches — saves a blob + // download on every file on every checkout. + if (localPaths.has(path)) { + const localBytes = await wt.read(path); + const localSha = await gitBlobSha(localBytes); + if (localSha === entry.blobSha) continue; + } + onProgress?.({ phase: 'blob-download', path, current: i + 1, total: paths.length }); + const bytes = await this.adapter.getBlob(entry.blobSha); + onProgress?.({ phase: 'apply', path, current: i + 1, total: paths.length }); + await wt.write(path, bytes); + } + // Drop any local file that's not in the target tree. + for (const path of localPaths) { + if (!(path in tree)) { + onProgress?.({ phase: 'delete', path }); + await wt.delete(path); + } + } + } + + // ─── pull / fast-forward ───────────────────────────────────────────────── + + async tryFastForward(wt: WorkingTree, opts: FastForwardOptions = {}): Promise { + const remoteSha = await this.adapter.branchHead(); + if (!remoteSha) return { applied: false }; + if (this.syncedHead && remoteSha === this.syncedHead.commitSha) return { applied: false }; + + // Refuse to clobber local changes. A "clean" working tree is one + // whose snapshot exactly equals syncedTree. + if (this.syncedHead) { + opts.onProgress?.({ phase: 'snapshot' }); + const live = await this.snapshot(wt); + const diff = diffGitTrees(this.syncedTree, live); + opts.onProgress?.({ + phase: 'diff', + added: diff.added.length, + modified: diff.modified.length, + deleted: diff.deleted.length, + }); + if (diff.added.length || diff.modified.length || diff.deleted.length) { + return { applied: false, dirty: true, from: this.syncedHead.commitSha, to: remoteSha }; + } + } + + opts.onProgress?.({ phase: 'fetch-tree', commitSha: remoteSha }); + const commit = await this.adapter.getCommit(remoteSha); + const tree = await this.adapter.getTree(remoteSha); + await this.materialize(wt, tree, opts.onProgress); + + const from = this.syncedHead?.commitSha; + this.syncedHead = { commitSha: remoteSha, treeSha: commit.treeSha }; + this.syncedTree = { ...tree }; + return { applied: true, from, to: remoteSha }; + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + /** Flatten the synced tree to a path → blobSha map. Used by the panel's + * sync-index serialization (it only persists ids, not metadata). */ + syncedTreeToBlobShas(): Record { + return flattenTreeToBlobShas(this.syncedTree); + } +} diff --git a/Playground/src/sharing/sync-index.ts b/Playground/src/sharing/sync-index.ts new file mode 100644 index 0000000..3c11f6b --- /dev/null +++ b/Playground/src/sharing/sync-index.ts @@ -0,0 +1,66 @@ +// Per-project sync state. Persisted to localStorage, keyed by project name. +// +// **Third-pivot edition**: simpler than before. With real git as the +// backing store, "our commit id" and "git commit sha" collapse into one +// value, and we no longer track a separate HEAD-file blob sha for CAS +// (CAS now comes from git's fast-forward rule on `updateBranch`). +// +// The key prefix is v3 so old (manifest-format) indexes are silently +// orphaned — the user just signs in and reconnects. + +const KEY_PREFIX = 'fade-sharing:project-v3:'; + +/** Persisted state for one workspace project. */ +export interface ProjectSyncIndex { + /** GitHub repo bound to this workspace, or null if not connected yet. */ + remoteRepo: { owner: string; name: string; branch: string } | null; + /** Git commit SHA we last materialized into the working tree. */ + syncedCommitSha: string | null; + /** Git tree SHA at that commit. Lets createTree use base_tree on next + * commit so we only send the *changed* entries. */ + syncedTreeSha: string | null; + /** path → git blob sha at the synced commit. Used by file-status and by + * the conflict-detection logic — both compare local git-blob-sha + * against these. */ + baseTree: Record; +} + +const EMPTY: ProjectSyncIndex = { + remoteRepo: null, + syncedCommitSha: null, + syncedTreeSha: null, + baseTree: {}, +}; + +export function loadSyncIndex(projectName: string): ProjectSyncIndex { + try { + const raw = localStorage.getItem(KEY_PREFIX + projectName); + if (!raw) return { ...EMPTY }; + const parsed = JSON.parse(raw) as Partial; + return { + remoteRepo: parsed.remoteRepo ?? null, + syncedCommitSha: parsed.syncedCommitSha ?? null, + syncedTreeSha: parsed.syncedTreeSha ?? null, + baseTree: parsed.baseTree ?? {}, + }; + } catch { + return { ...EMPTY }; + } +} + +export function saveSyncIndex(projectName: string, idx: ProjectSyncIndex): void { + try { + localStorage.setItem(KEY_PREFIX + projectName, JSON.stringify(idx)); + } catch { + // Quota or private mode — drop silently. The cache can always be rebuilt. + } +} + +export function clearSyncIndex(projectName: string): void { + try { localStorage.removeItem(KEY_PREFIX + projectName); } catch { /* ignore */ } +} + +/** Convenience: true iff the project has a remote repo bound. */ +export function isConnected(idx: ProjectSyncIndex): boolean { + return idx.remoteRepo !== null; +} diff --git a/Playground/src/sharing/token-store.ts b/Playground/src/sharing/token-store.ts new file mode 100644 index 0000000..f3b9c42 --- /dev/null +++ b/Playground/src/sharing/token-store.ts @@ -0,0 +1,138 @@ +// GitHub token persistence — full TokenSet on sessionStorage. +// +// Why not localStorage anymore: the previous design stored a long-lived +// PAT in localStorage. Reading that's still in JS scope, but at least +// sessionStorage limits the blast radius to one browser tab and clears +// on close. Combined with the device flow producing short-lived +// (~8h default) refreshable tokens, the practical risk shrinks +// substantially compared to "an immortal PAT in localStorage." +// +// Persistence layout (one JSON blob, single key): +// { +// accessToken: 'ghu_…', +// refreshToken: 'ghr_…', +// accessExpiresAt: 1730000000000, +// refreshExpiresAt: 1745000000000, +// scope: '', +// tokenType: 'bearer' +// } +// +// Legacy migration: an old `fade-playground:github-token` key in +// localStorage (raw PAT string) is read once on first `load()`, copied +// into the new shape with no refresh fields, and the old key removed. +// The migrated PAT keeps working until the user signs in fresh; at +// that point it's overwritten by the device-flow result. + +import type { TokenSet } from './github-auth'; + +export interface StoredTokenSet { + accessToken: string; + refreshToken?: string; + /** ms since epoch. Absent when the upstream response didn't include + * `expires_in` (legacy PATs, OAuth-App long-lived tokens). */ + accessExpiresAt?: number; + refreshExpiresAt?: number; + scope?: string; + tokenType?: string; +} + +export interface TokenStore { + load(): StoredTokenSet | null; + save(set: StoredTokenSet): void; + clear(): void; +} + +const NEW_KEY = 'fade-playground:github-token-set:v1'; +const LEGACY_PAT_KEY = 'fade-playground:github-token'; + +/** sessionStorage-backed store. Per-tab isolation, clears on tab close. */ +export class SessionTokenStore implements TokenStore { + constructor(private readonly key = NEW_KEY) {} + + load(): StoredTokenSet | null { + // One-time migration: if the legacy PAT key is present (from + // pre-device-flow versions), wrap it into the new shape and + // promote it. Reading & writing on every load would be + // wasteful, so we only do it when the new key is absent. + try { + const fresh = sessionStorage.getItem(this.key); + if (fresh) return JSON.parse(fresh) as StoredTokenSet; + } catch { /* parse error → fall through to migration */ } + + try { + if (typeof localStorage === 'undefined') return null; + const legacy = localStorage.getItem(LEGACY_PAT_KEY); + if (!legacy) return null; + const migrated: StoredTokenSet = { accessToken: legacy }; + try { + sessionStorage.setItem(this.key, JSON.stringify(migrated)); + } catch { /* sessionStorage unavailable — caller can re-auth */ } + try { localStorage.removeItem(LEGACY_PAT_KEY); } catch { /* ignore */ } + return migrated; + } catch { + return null; + } + } + + save(set: StoredTokenSet): void { + try { sessionStorage.setItem(this.key, JSON.stringify(set)); } + catch { /* private mode / quota — caller proceeds in-memory only */ } + } + + clear(): void { + try { sessionStorage.removeItem(this.key); } catch { /* ignore */ } + // Belt and suspenders: nuke the legacy key too in case migration + // partially completed previously. + try { localStorage.removeItem(LEGACY_PAT_KEY); } catch { /* ignore */ } + } +} + +/** Test / fallback shim. Lives entirely in memory. */ +export class MemoryTokenStore implements TokenStore { + private set: StoredTokenSet | null = null; + load(): StoredTokenSet | null { return this.set ? { ...this.set } : null; } + save(set: StoredTokenSet): void { this.set = { ...set }; } + clear(): void { this.set = null; } +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +/** Convert the device-flow response (TokenSet, relative expiries) into + * the persisted shape (absolute timestamps). `now` is injectable for + * tests. */ +export function tokenSetToStored(t: TokenSet, now: number = Date.now()): StoredTokenSet { + const stored: StoredTokenSet = { + accessToken: t.accessToken, + refreshToken: t.refreshToken, + scope: t.scope, + tokenType: t.tokenType, + }; + if (typeof t.expiresIn === 'number') { + stored.accessExpiresAt = now + t.expiresIn * 1000; + } + if (typeof t.refreshTokenExpiresIn === 'number') { + stored.refreshExpiresAt = now + t.refreshTokenExpiresIn * 1000; + } + return stored; +} + +/** True iff the stored access token will expire within `cushionMs` of + * `now`. Returns false when expiry is unknown (legacy PATs etc.) — we + * assume those don't expire from our side. */ +export function isAccessExpired( + stored: StoredTokenSet, + now: number = Date.now(), + cushionMs = 60_000, +): boolean { + if (stored.accessExpiresAt === undefined) return false; + return stored.accessExpiresAt - now <= cushionMs; +} + +/** True iff the refresh token is unusable (missing or expired). When + * this is true and `isAccessExpired` is also true, the user has to + * re-authenticate from scratch. */ +export function isRefreshUsable(stored: StoredTokenSet, now: number = Date.now()): boolean { + if (!stored.refreshToken) return false; + if (stored.refreshExpiresAt !== undefined && stored.refreshExpiresAt <= now) return false; + return true; +} diff --git a/Playground/src/sharing/working-tree.ts b/Playground/src/sharing/working-tree.ts new file mode 100644 index 0000000..ced741e --- /dev/null +++ b/Playground/src/sharing/working-tree.ts @@ -0,0 +1,45 @@ +// The "working tree" abstraction — the local set of files the Repo engine +// reads from on commit and writes into on checkout. We keep it deliberately +// minimal so the engine can be tested without OPFS (with MemoryWorkingTree) +// and later wired to OpfsWorkspace (with OpfsWorkingTree, written in the +// integration phase). +// +// Paths are forward-slash-delimited project-relative strings; the working +// tree is responsible for translating those into its native storage shape. + +export interface WorkingTree { + /** List every file path currently in the tree (no directories). */ + list(): Promise; + read(path: string): Promise; + write(path: string, bytes: Uint8Array): Promise; + delete(path: string): Promise; + has(path: string): Promise; +} + +// Defensive copies on the way in/out so tests can mutate buffers between +// calls without leaking into the tree's stored bytes. +export class MemoryWorkingTree implements WorkingTree { + private files = new Map(); + + async list(): Promise { + return [...this.files.keys()].sort(); + } + + async read(path: string): Promise { + const b = this.files.get(path); + if (!b) throw new Error(`working-tree: file not found: ${path}`); + return new Uint8Array(b); + } + + async write(path: string, bytes: Uint8Array): Promise { + this.files.set(path, new Uint8Array(bytes)); + } + + async delete(path: string): Promise { + this.files.delete(path); + } + + async has(path: string): Promise { + return this.files.has(path); + } +} diff --git a/Playground/tsconfig.tsbuildinfo b/Playground/tsconfig.tsbuildinfo index 1208690..141a09f 100644 --- a/Playground/tsconfig.tsbuildinfo +++ b/Playground/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/ai-chat.ts","./src/ai-worker.ts","./src/binary-preview.ts","./src/fade-config.ts","./src/help.ts","./src/languages.ts","./src/main.ts","./src/markdown-preview.ts","./src/monogame-host.ts","./src/xnb/xnb-previews.ts","./src/xnb/xnb-reader.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/ai-chat.ts","./src/ai-worker.ts","./src/binary-preview.ts","./src/fade-config.ts","./src/help.ts","./src/languages.ts","./src/log-bus.test.ts","./src/log-bus.ts","./src/logs-panel.ts","./src/main.ts","./src/markdown-preview.ts","./src/monogame-host.ts","./src/sharing/adapter.ts","./src/sharing/auth-ui.ts","./src/sharing/collaboration-panel.ts","./src/sharing/conflict-editor.ts","./src/sharing/diff-viewer.ts","./src/sharing/diff3.test.ts","./src/sharing/diff3.ts","./src/sharing/file-status.test.ts","./src/sharing/file-status.ts","./src/sharing/git-types.ts","./src/sharing/github-adapter.test.ts","./src/sharing/github-adapter.ts","./src/sharing/github-auth-config.ts","./src/sharing/github-auth.test.ts","./src/sharing/github-auth.ts","./src/sharing/hash.test.ts","./src/sharing/hash.ts","./src/sharing/history-panel.ts","./src/sharing/index.ts","./src/sharing/line-diff.test.ts","./src/sharing/line-diff.ts","./src/sharing/local-saves.test.ts","./src/sharing/local-saves.ts","./src/sharing/mock-adapter.ts","./src/sharing/monaco-gutter.ts","./src/sharing/opfs-working-tree.ts","./src/sharing/repo.test.ts","./src/sharing/repo.ts","./src/sharing/sync-index.ts","./src/sharing/token-store.ts","./src/sharing/working-tree.ts","./src/xnb/mgfx.ts","./src/xnb/xnb-previews.ts","./src/xnb/xnb-reader.ts"],"version":"5.9.3"} \ No newline at end of file From 067effe5c75cc77f59b86723f4e0d8ad4631dee1 Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Thu, 28 May 2026 20:24:59 -0400 Subject: [PATCH 28/30] folders --- Playground/index.html | 82 ++++- Playground/src/main.ts | 732 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 728 insertions(+), 86 deletions(-) diff --git a/Playground/index.html b/Playground/index.html index dc96d19..7b2cf3e 100644 --- a/Playground/index.html +++ b/Playground/index.html @@ -155,7 +155,6 @@ 0%, 100% { opacity: 0.4; } 50% { opacity: 0.85; } } - header h1 { font-size: 0.85rem; font-weight: 500; margin: 0; } #status { color: var(--fg-muted); font-size: 0.78rem; } .project-name { color: var(--fg); @@ -218,7 +217,10 @@ .view-menu { position: absolute; top: calc(100% + 4px); - right: 0; + /* Anchor to the left edge of the View button — the button + itself now sits on the left side of the header, so a + right-anchored dropdown would extend off-screen. */ + left: 0; z-index: 1000; min-width: 220px; background: var(--bg-3); @@ -381,11 +383,42 @@ align-items: center; gap: 0.35rem; } - #file-list li > span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; } + #file-list li > .file-label { flex: 1; overflow: hidden; text-overflow: ellipsis; } #file-list li:hover { background: var(--hover-bg); } #file-list li.active { background: #37373d; } - #file-list li.manifest > span:first-child { color: var(--fg); font-style: italic; } + #file-list li.manifest > .file-label { color: var(--fg); font-style: italic; } #file-list .file-lock { color: var(--fg-muted); font-size: 0.8rem; } + /* Tree rendering — folders have a clickable chevron; files have + an empty spacer at the same width so labels line up across + depths. Indent is driven by inline padding-left set per row + based on depth × 14px. */ + #file-list li.folder-row { user-select: none; } + #file-list li.folder-row > .folder-chevron { + display: inline-block; + width: 12px; + text-align: center; + font-size: 9px; + color: var(--fg-muted); + flex-shrink: 0; + } + #file-list li.folder-row > .codicon-folder, + #file-list li.folder-row > .codicon-folder-opened { + font-size: 14px; + color: #dcb67a; /* yellow-ish folder color, vs default fg */ + flex-shrink: 0; + } + #file-list li.file-row > .file-indent { + display: inline-block; + width: 12px; + flex-shrink: 0; + } + /* Drop-target highlight while dragging a file over a folder + (used in phase 4). Defined here so the styles are ready when + drag-drop lands. */ + #file-list li.drop-target { + background: rgba(77, 166, 255, 0.18); + box-shadow: inset 0 0 0 1px rgba(77, 166, 255, 0.5); + } /* Inline-create row: appears at the top of the file list when the user picks an extension from the New File dropdown. Auto-focus the input; Enter commits, Escape/blur cancels. */ @@ -457,6 +490,18 @@ background: var(--border-2); margin: 0.2rem 0; } + /* Context-header above the items, used when the menu was + opened against a specific folder so the user can see WHERE + the new item will land. */ + .source-badge-sep-label { + padding: 4px 12px 2px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + font-family: ui-monospace, monospace; + user-select: none; + } /* File tabs (top of editor panel) */ .tabs { @@ -2326,20 +2371,12 @@
-

Fade Playground

- + - - - - +
View
+ + + Run (⌘R) Stop Debug (⌘D) @@ -2399,6 +2446,7 @@

Fade Playground

    diff --git a/Playground/src/main.ts b/Playground/src/main.ts index ff53952..47b7c14 100644 --- a/Playground/src/main.ts +++ b/Playground/src/main.ts @@ -135,6 +135,7 @@ const viewResetLayoutBtn = document.getElementById('view-reset-layout') as HTMLB const viewSavedLayouts = document.getElementById('view-saved-layouts') as HTMLElement; const viewSemanticLayouts = document.getElementById('view-semantic-layouts') as HTMLElement; const newFileBtn = document.getElementById('new-file') as HTMLButtonElement; +const newFolderBtn = document.getElementById('new-folder') as HTMLButtonElement; const fileListEl = document.getElementById('file-list')!; const tabsEl = document.getElementById('tabs')!; const editorContainer = document.getElementById('editor')!; @@ -264,24 +265,81 @@ class OpfsWorkspace { await manifestW.close(); } - // File-level operations, scoped to the active project. + // ─── Path-aware helpers ────────────────────────────────────────────── + // Paths can include forward slashes; intermediate segments are OPFS + // subdirectories. `list()` walks the tree and returns flat paths + // (slashes included). All file ops accept either a flat name (legacy + // root-level file) or a slashed path; both go through `walkPath()` + // which produces the parent directory handle + leaf name. + + /** Split a path on `/`, navigate (or create) the intermediate + * directories, and return `{ parent, leaf }`. `create: true` makes + * missing dirs; `create: false` throws on missing. */ + private async walkPath( + path: string, + opts: { create: boolean }, + ): Promise<{ parent: FileSystemDirectoryHandle; leaf: string }> { + const segments = path.split('/').filter((s) => s.length > 0); + if (segments.length === 0) throw new Error('Empty path'); + const leaf = segments.pop()!; + let cur: FileSystemDirectoryHandle = this.dir; + for (const seg of segments) { + cur = await cur.getDirectoryHandle(seg, { create: opts.create }); + } + return { parent: cur, leaf }; + } + + /** Recursive file listing. Returns slashed paths for every file in + * the project, depth-first sorted. Directories are NOT returned; + * use `listEntries()` if you need the directory structure too. */ async list(): Promise { - const names: string[] = []; - for await (const entry of (this.dir as any).values()) { - if (entry.kind === 'file') names.push(entry.name); - } - names.sort(); - return names; + const out: string[] = []; + const walk = async (dir: FileSystemDirectoryHandle, prefix: string) => { + for await (const entry of (dir as any).values() as AsyncIterable) { + const childPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.kind === 'file') { + out.push(childPath); + } else if (entry.kind === 'directory') { + await walk(entry as FileSystemDirectoryHandle, childPath); + } + } + }; + await walk(this.dir, ''); + out.sort(); + return out; + } + + /** Tree-shaped listing: every file AND every directory, with kind + * + slashed path. Used by the file-list renderer to draw the + * expandable tree. */ + async listEntries(): Promise> { + const out: Array<{ path: string; kind: 'file' | 'directory' }> = []; + const walk = async (dir: FileSystemDirectoryHandle, prefix: string) => { + for await (const entry of (dir as any).values() as AsyncIterable) { + const childPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.kind === 'file') { + out.push({ path: childPath, kind: 'file' }); + } else if (entry.kind === 'directory') { + out.push({ path: childPath, kind: 'directory' }); + await walk(entry as FileSystemDirectoryHandle, childPath); + } + } + }; + await walk(this.dir, ''); + out.sort((a, b) => a.path.localeCompare(b.path)); + return out; } - async read(name: string): Promise { - const fh = await this.dir.getFileHandle(name); + async read(path: string): Promise { + const { parent, leaf } = await this.walkPath(path, { create: false }); + const fh = await parent.getFileHandle(leaf); const f = await fh.getFile(); return await f.text(); } - async write(name: string, content: string): Promise { - const fh = await this.dir.getFileHandle(name, { create: true }); + async write(path: string, content: string): Promise { + const { parent, leaf } = await this.walkPath(path, { create: true }); + const fh = await parent.getFileHandle(leaf, { create: true }); const w = await fh.createWritable(); await w.write(content); await w.close(); @@ -291,14 +349,16 @@ class OpfsWorkspace { // The text-based read/write above is kept intact; callers route through // one or the other based on the file extension. The underlying OPFS // handle is the same; only the decode/encode shape differs. - async readBytes(name: string): Promise { - const fh = await this.dir.getFileHandle(name); + async readBytes(path: string): Promise { + const { parent, leaf } = await this.walkPath(path, { create: false }); + const fh = await parent.getFileHandle(leaf); const f = await fh.getFile(); return new Uint8Array(await f.arrayBuffer()); } - async writeBytes(name: string, bytes: Uint8Array): Promise { - const fh = await this.dir.getFileHandle(name, { create: true }); + async writeBytes(path: string, bytes: Uint8Array): Promise { + const { parent, leaf } = await this.walkPath(path, { create: true }); + const fh = await parent.getFileHandle(leaf, { create: true }); const w = await fh.createWritable(); // Wrap in a Blob so the writable stream's union type doesn't // reject Uint8Array when SharedArrayBuffer is in @@ -307,41 +367,119 @@ class OpfsWorkspace { await w.close(); } - async delete(name: string): Promise { - if (name === FADE_JSON_NAME) { + async delete(path: string): Promise { + if (path === FADE_JSON_NAME) { throw new Error('fade.json is required and cannot be deleted.'); } - await this.dir.removeEntry(name); + const { parent, leaf } = await this.walkPath(path, { create: false }); + // recursive: true so deleting a folder cleans children too. For + // files it has no effect (kept for spec uniformity). + await parent.removeEntry(leaf, { recursive: true }); } - // OPFS has no atomic rename. Read → write under the new name → remove - // the old. If write fails partway, the old file is preserved (we only - // remove after the new file lands successfully). - async rename(oldName: string, newName: string): Promise { - if (oldName === FADE_JSON_NAME || newName === FADE_JSON_NAME) { + /** Create an empty directory. No-op if already exists. */ + async mkdir(path: string): Promise { + // walkPath with create:true gives us the leaf's parent; the leaf + // itself is the directory we want. + const { parent, leaf } = await this.walkPath(path, { create: true }); + await parent.getDirectoryHandle(leaf, { create: true }); + } + + /** True iff `path` exists and refers to a directory. */ + async isDirectory(path: string): Promise { + try { + const { parent, leaf } = await this.walkPath(path, { create: false }); + await parent.getDirectoryHandle(leaf); + return true; + } catch { + return false; + } + } + + /** True iff `path` exists (file or directory). */ + async exists(path: string): Promise { + try { + const { parent, leaf } = await this.walkPath(path, { create: false }); + try { await parent.getFileHandle(leaf); return true; } + catch { /* fall through to directory check */ } + try { await parent.getDirectoryHandle(leaf); return true; } + catch { return false; } + } catch { + return false; + } + } + + /** Move a file or directory. OPFS has no atomic rename. Files are + * copied + the source removed. Directories are walked recursively. + * `newPath === oldPath` is a no-op. Refuses to overwrite an + * existing destination. fade.json is locked. */ + async rename(oldPath: string, newPath: string): Promise { + if (oldPath === FADE_JSON_NAME || newPath === FADE_JSON_NAME) { throw new Error('fade.json is required and cannot be renamed.'); } - if (oldName === newName) return; - if (!/^[\w.\-]+$/.test(newName)) { - throw new Error('Invalid name. Letters, digits, dot, dash, underscore only.'); + if (oldPath === newPath) return; + // Validate every path segment. + for (const seg of newPath.split('/')) { + if (seg.length === 0) throw new Error('Invalid path (empty segment).'); + if (!/^[\w.\-]+$/.test(seg)) { + throw new Error(`Invalid path segment "${seg}". Letters, digits, dot, dash, underscore only.`); + } } - // Collision check. - let collision = false; - try { - await this.dir.getFileHandle(newName); - collision = true; - } catch { /* NotFoundError → free to proceed */ } - if (collision) throw new Error(`A file named "${newName}" already exists.`); - const content = await this.read(oldName); - await this.write(newName, content); - try { await this.dir.removeEntry(oldName); } + // Block moving a directory INTO itself or its own children. + if (newPath === oldPath || newPath.startsWith(oldPath + '/')) { + throw new Error(`Can't move "${oldPath}" into its own subtree.`); + } + if (await this.exists(newPath)) { + throw new Error(`"${newPath}" already exists.`); + } + const wasDir = await this.isDirectory(oldPath); + if (wasDir) { + // Recursively walk + copy + delete. Build a manifest first + // so a mid-flight error doesn't leave us with half-copied + // half-deleted state — we copy everything, then delete the + // source tree. + const entries: Array<{ from: string; to: string }> = []; + const walk = async (relFrom: string, relTo: string) => { + const fromHandle = await this.dirHandleAt(relFrom); + for await (const entry of (fromHandle as any).values() as AsyncIterable) { + const subFrom = `${relFrom}/${entry.name}`; + const subTo = `${relTo}/${entry.name}`; + if (entry.kind === 'file') { + entries.push({ from: subFrom, to: subTo }); + } else { + await walk(subFrom, subTo); + } + } + }; + await this.mkdir(newPath); + await walk(oldPath, newPath); + for (const { from, to } of entries) { + const bytes = await this.readBytes(from); + await this.writeBytes(to, bytes); + } + try { await this.delete(oldPath); } + catch (e) { console.warn('[fade] rename: failed to remove source dir', oldPath, e); } + return; + } + // File move. + const bytes = await this.readBytes(oldPath); + await this.writeBytes(newPath, bytes); + try { await this.delete(oldPath); } catch (e) { - // Best-effort cleanup. The new file already landed, so the - // rename is effectively complete even if we couldn't remove - // the source. - console.warn('[fade] rename: failed to remove old file', oldName, e); + console.warn('[fade] rename: failed to remove old file', oldPath, e); } } + + /** Resolve a directory handle at a slashed path (relative to the + * project root). Errors if any segment is missing. */ + private async dirHandleAt(path: string): Promise { + const segments = path.split('/').filter((s) => s.length > 0); + let cur: FileSystemDirectoryHandle = this.dir; + for (const seg of segments) { + cur = await cur.getDirectoryHandle(seg); + } + return cur; + } } // ─── Tabs + model management ──────────────────────────────────────────────── @@ -522,12 +660,17 @@ function closeTab(name: string) { function renderTabs() { tabsEl.innerHTML = ''; + // Tab labels show the basename so deeply-nested files don't blow + // out the tab strip; the full path is in the tooltip for + // disambiguation when multiple files share a name across folders. for (const [name, tab] of tabs) { + const basename = name.split('/').pop() ?? name; const el = document.createElement('div'); el.className = 'tab' + (name === activeName ? ' active' : ''); const label = document.createElement('span'); label.className = tab.dirty ? 'dirty' : ''; - label.textContent = (tab.dirty ? '● ' : '') + name; + label.textContent = (tab.dirty ? '● ' : '') + basename; + label.title = name; label.onclick = () => { activeName = name; if (editor) editor.setModel(tab.model); @@ -615,9 +758,37 @@ interface ProjectOps { revealSourceInManifest(name: string): Promise; renameFile(name: string): Promise; deleteFile(name: string): Promise; + /** Create an empty folder at the given path (root-relative, + * slashes allowed for nested creation). No-op if it exists. */ + createFolder(path: string): Promise; + /** Move a file or folder. Works with slashed paths; folders move + * recursively. Updates fade.json sources if the moved path is + * listed (or any descendant is listed, for folder moves). */ + renamePath(oldPath: string, newPath: string): Promise; + /** Inline-create a new file with the given extension. If + * `parentFolder` is set, the file lands inside it (and the + * folder is auto-expanded). Triggered from the folder right- + * click menu; the inline-edit row UI lives in the bootstrap + * closure, so this exposes it through the projectOps surface. */ + inlineCreateFile(ext: string, parentFolder?: string): void; + /** Inline-create a new folder, optionally nested inside + * `parentFolder`. Same pattern as `inlineCreateFile`. */ + inlineCreateFolder(parentFolder?: string): void; } let projectOps: ProjectOps | null = null; +/** File extensions offered in the "New …" menus (both root-area and + * folder-row right-clicks). Module-scoped so the folder-context menu + * — which lives at module scope, outside the bootstrap closure — + * can iterate the same list as the root-area menu and the new-file + * button. Add new types here and they'll appear in every menu. */ +const NEW_FILE_EXTENSIONS: ReadonlyArray<{ label: string; ext: string }> = [ + { label: 'Fade source (.fbasic)', ext: 'fbasic' }, + { label: 'Shader (.fx)', ext: 'fx' }, + { label: 'JSON (.json)', ext: 'json' }, + { label: 'Text (.txt)', ext: 'txt' }, +]; + // Source-control wiring: the panel mounts at bootstrap (after dockview is up) // and publishes a status map (path → A/M/D) via onStatusChange. We mirror it // here so renderFileList can render badges without coupling back into the @@ -630,15 +801,183 @@ let sharingPendingPull: Set = new Set(); * exists). Drives the red 'C' badge in the workspace file list. */ let sharingConflicts: { text: Set; binary: Set } = { text: new Set(), binary: new Set() }; +/** Collapsed folder paths. Renders skip files whose parent (at any + * level) is in this set. In-memory only — folder state resets on + * reload, which keeps the UI predictable across sessions. */ +const collapsedFolders = new Set(); + +/** MIME type for internal drag-drop. Distinct from `text/plain` so we + * never confuse a workspace move with an external drop (file content + * from the OS, a URL string, etc.). */ +const FADE_DRAG_MIME = 'application/x-fade-path'; + +/** Compute the destination path when `srcPath` is dropped onto + * `destFolder`. `destFolder === ''` means the workspace root. Returns + * null if the drop is a no-op (already in that folder) or invalid + * (would move into its own subtree). */ +function computeDropTarget(srcPath: string, destFolder: string): string | null { + const basename = srcPath.split('/').pop() ?? srcPath; + const target = destFolder ? `${destFolder}/${basename}` : basename; + if (target === srcPath) return null; + // Block moving a folder into itself or a descendant. + if (destFolder === srcPath || destFolder.startsWith(srcPath + '/')) return null; + return target; +} + +/** Clear the drop-target highlight from every row. Called on dragend, + * drop, and any time the user leaves a target without committing. */ +function clearDropHighlights() { + for (const el of fileListEl.querySelectorAll('.drop-target')) { + el.classList.remove('drop-target'); + } + fileListEl.classList.remove('drop-target'); +} + +/** Wire a row as a drag source. Sets the transfer data to the row's + * path so the drop handler knows what's being moved. fade.json is + * refused (it's pinned to root). */ +function wireDragSource(li: HTMLElement, path: string) { + if (path === FADE_JSON_NAME) return; + li.draggable = true; + li.addEventListener('dragstart', (e) => { + if (!e.dataTransfer) return; + e.dataTransfer.setData(FADE_DRAG_MIME, path); + e.dataTransfer.effectAllowed = 'move'; + }); + li.addEventListener('dragend', () => clearDropHighlights()); +} + +/** Wire a row (or the root) as a drop target. `destFolder === ''` for + * the root; otherwise the folder's path. Uses computeDropTarget to + * decide whether the drop is valid before highlighting. */ +function wireDropTarget(el: HTMLElement, destFolder: string) { + el.addEventListener('dragover', (e) => { + const srcPath = e.dataTransfer?.types.includes(FADE_DRAG_MIME) + ? '' // we'll read the actual value on drop; just check the type for dragover + : null; + if (srcPath === null) return; + // We can't read getData during dragover (browsers redact it for + // security). So we accept the drag if the type is right and + // let the drop handler do the real validation. + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + el.classList.add('drop-target'); + }); + el.addEventListener('dragleave', (e) => { + // dragleave fires when crossing child boundaries too. Only + // clear if the pointer actually left the element's bounds. + if (e.currentTarget === el && !(el as Node).contains(e.relatedTarget as Node)) { + el.classList.remove('drop-target'); + } + }); + el.addEventListener('drop', (e) => { + e.preventDefault(); + // Stop the event before it bubbles to the root drop handler + // (`fileListEl`) — otherwise dropping on a folder would also + // trigger a second move-to-root pass with an already-moved + // source. Safe to call on the root too; it has no parent + // listener of consequence. + e.stopPropagation(); + el.classList.remove('drop-target'); + clearDropHighlights(); + const srcPath = e.dataTransfer?.getData(FADE_DRAG_MIME); + if (!srcPath) return; + const target = computeDropTarget(srcPath, destFolder); + if (!target) return; + void projectOps?.renamePath(srcPath, target); + }); +} + +/** True iff any ancestor of `path` is in `collapsedFolders`. */ +function isHiddenByCollapsedAncestor(path: string): boolean { + // For a/b/c.fbasic, the ancestors are 'a' and 'a/b'. We don't check + // the full path itself — a collapsed folder still renders its own + // row, just not children. + const parts = path.split('/'); + if (parts.length < 2) return false; + let prefix = ''; + for (let i = 0; i < parts.length - 1; i++) { + prefix = prefix ? `${prefix}/${parts[i]}` : parts[i]; + if (collapsedFolders.has(prefix)) return true; + } + return false; +} + +/** Wire the file-list root as a drop target ONCE on first render. + * Drops here move the file to the workspace root. Using a flag (vs + * binding on every render) keeps duplicate listeners from stacking. */ +let fileListRootDropWired = false; + async function renderFileList(workspace: OpfsWorkspace) { - const names = await workspace.list(); + const entries = await workspace.listEntries(); fileListEl.innerHTML = ''; + if (!fileListRootDropWired) { + // Drop on empty space inside the file list → move to root. + // Folder rows stop propagation on their own drop handlers so + // a drop on a folder doesn't also fire here. + wireDropTarget(fileListEl, ''); + fileListRootDropWired = true; + } const sources = currentProjectRef?.sources ?? []; - for (const name of names) { + for (const entry of entries) { + const { path, kind } = entry; + // Skip rows whose parent folder is collapsed. The folder itself + // still renders (its row IS what the user expands/collapses). + if (isHiddenByCollapsedAncestor(path)) continue; + const depth = path.split('/').length - 1; const li = document.createElement('li'); - li.dataset.name = name; + li.dataset.name = path; + // Pad each row by depth * 14px so children sit visibly under + // their folder. The base padding (1rem) is preserved. + if (depth > 0) li.style.paddingLeft = `calc(1rem + ${depth * 14}px)`; + + if (kind === 'directory') { + // ── Folder row ──────────────────────────────────────────── + li.classList.add('folder-row'); + li.dataset.folder = path; + const collapsed = collapsedFolders.has(path); + const chevron = document.createElement('span'); + chevron.className = 'folder-chevron'; + chevron.textContent = collapsed ? '▶' : '▼'; + const icon = document.createElement('span'); + icon.className = collapsed + ? 'codicon codicon-folder' + : 'codicon codicon-folder-opened'; + const label = document.createElement('span'); + label.className = 'file-label'; + // Show only the basename — the indent + position in the + // tree communicate the parent. Tooltip has the full path + // for disambiguation. + label.textContent = path.split('/').pop() ?? path; + label.title = path; + li.append(chevron, icon, label); + li.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showFolderContextMenu(e.clientX, e.clientY, path); + }); + li.onclick = () => { + if (collapsedFolders.has(path)) collapsedFolders.delete(path); + else collapsedFolders.add(path); + void renderFileList(workspace); + }; + // Folders are both drag sources (move the folder) and drop + // targets (move items into this folder). + wireDragSource(li, path); + wireDropTarget(li, path); + fileListEl.append(li); + continue; + } + + // ── File row ────────────────────────────────────────────────── + li.classList.add('file-row'); + const name = path; // legacy code below uses `name` for both display + sources lookup + const indent = document.createElement('span'); + indent.className = 'file-indent'; + li.append(indent); const label = document.createElement('span'); - label.textContent = name; + label.className = 'file-label'; + label.textContent = path.split('/').pop() ?? path; + label.title = path; li.append(label); if (name === FADE_JSON_NAME) { // Visible cue that the manifest is locked from delete/rename @@ -749,6 +1088,10 @@ async function renderFileList(workspace: OpfsWorkspace) { void openFile(workspace, name); } }; + // File rows are drag sources only. Drop on a file isn't + // meaningful (we don't open arbitrary file-to-file drops); + // dropping on a folder OR the root area is how moves happen. + wireDragSource(li, name); fileListEl.append(li); } } @@ -825,6 +1168,80 @@ function closeAnyFileMenu() { } } +/** Right-click menu for a folder row. Smaller than the file menu — + * folders don't participate in fade.json sources or git status the + * same way. Rename (drag-drop preferred, but typed-rename available + * for awkward characters) + Delete. */ +function showFolderContextMenu(x: number, y: number, folderPath: string) { + closeAnyFileMenu(); + if (!projectOps) return; + const menu = document.createElement('div'); + menu.className = 'source-badge-menu'; + menu.dataset.menu = 'file-context'; + const addItem = (label: string, handler: () => void) => { + const item = document.createElement('button'); + item.className = 'source-badge-item'; + item.type = 'button'; + item.textContent = label; + item.onclick = (e) => { + e.stopPropagation(); + closeAnyFileMenu(); + handler(); + }; + menu.append(item); + }; + // Context header so the user can see WHERE the create actions + // will land — keeps "New JSON file" from feeling rootless when + // four folders share a context menu shape. + const ctx = document.createElement('div'); + ctx.className = 'source-badge-sep-label'; + ctx.textContent = `in ${folderPath}`; + menu.append(ctx); + // "Create inside this folder" — full extension list, same shape + // as the root-area menu. Mirrors NEW_FILE_EXTENSIONS so users + // never have to learn two different menu vocabularies. + addItem('New folder…', () => { + projectOps!.inlineCreateFolder(folderPath); + }); + const sepAfterFolder = document.createElement('div'); + sepAfterFolder.className = 'source-badge-sep'; + menu.append(sepAfterFolder); + for (const { label, ext } of NEW_FILE_EXTENSIONS) { + addItem(label, () => projectOps!.inlineCreateFile(ext, folderPath)); + } + // Separator between create-actions and modify-actions. + const sep = document.createElement('div'); + sep.className = 'source-badge-sep'; + menu.append(sep); + addItem('Rename folder…', () => { + const newPath = window.prompt(`Rename "${folderPath}" to:`, folderPath); + if (!newPath || newPath === folderPath) return; + void projectOps!.renamePath(folderPath, newPath.trim()) + .catch((e: unknown) => console.warn('[fade] rename folder failed', e)); + }); + addItem('Delete folder…', () => { + if (!confirm(`Delete "${folderPath}" and ALL its contents? This can't be undone.`)) return; + void projectOps!.deleteFile(folderPath) + .catch((e: unknown) => console.warn('[fade] delete folder failed', e)); + }); + menu.style.position = 'fixed'; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + document.body.append(menu); + setTimeout(() => { + const onClick = (e: MouseEvent) => { + if (!menu.contains(e.target as Node)) closeAnyFileMenu(); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeAnyFileMenu(); }; + document.addEventListener('click', onClick, true); + document.addEventListener('keydown', onKey, true); + (menu as any).__cleanup = () => { + document.removeEventListener('click', onClick, true); + document.removeEventListener('keydown', onKey, true); + }; + }, 0); +} + function renderFileListSelection() { for (const li of Array.from(fileListEl.children) as HTMLElement[]) { li.classList.toggle('active', li.dataset.name === activeName); @@ -2847,10 +3264,71 @@ async function bootstrap() { return; } // If the deleted file was a listed source, remove it from - // fade.json so we don't trip the missing-source error. + // fade.json so we don't trip the missing-source error. For + // folder deletes, drop every source under the folder too. await mutateManifest((p) => { - if (!p.sources.includes(name)) return null; - return { ...p, sources: p.sources.filter((s) => s !== name) }; + const folderPrefix = name + '/'; + const filtered = p.sources.filter( + (s) => s !== name && !s.startsWith(folderPrefix), + ); + if (filtered.length === p.sources.length) return null; + return { ...p, sources: filtered }; + }); + await refreshFadeProject(); + renderTabs(); + await renderFileList(workspace); + }, + createFolder: async (path) => { + // Validate per-segment so any nesting depth is fine. The + // workspace.mkdir helper happily creates intermediate dirs + // on demand; we just guard against bad characters here. + for (const seg of path.split('/')) { + if (!seg) continue; + if (!/^[\w.\-]+$/.test(seg)) { + alert(`Invalid folder name segment "${seg}". Letters, digits, dot, dash, underscore only.`); + return; + } + } + try { await workspace.mkdir(path); } + catch (e: any) { alert('Create folder failed: ' + (e?.message ?? e)); return; } + await renderFileList(workspace); + }, + inlineCreateFile: (ext, parentFolder) => { + void startInlineCreate(ext, parentFolder); + }, + inlineCreateFolder: (parentFolder) => { + void startInlineFolderCreate(parentFolder); + }, + renamePath: async (oldPath, newPath) => { + if (oldPath === newPath) return; + // Collect every tab whose path lives under the moved tree — + // we'll reopen them at their new paths after the rename so + // the user doesn't lose editor state. (For now we just + // close them — phase 5 wires the reopen path through the + // tab system's path-aware identity.) + const folderPrefix = oldPath + '/'; + const affectedTabs: string[] = []; + for (const [tabName] of tabs) { + if (tabName === oldPath || tabName.startsWith(folderPrefix)) { + affectedTabs.push(tabName); + } + } + for (const tabName of affectedTabs) closeTab(tabName); + try { await workspace.rename(oldPath, newPath); } + catch (e: any) { alert('Rename failed: ' + (e?.message ?? e)); return; } + // Update fade.json: any source matching oldPath exactly, or + // sitting under oldPath/, gets rewritten to its new home. + await mutateManifest((p) => { + let touched = false; + const updated = p.sources.map((s) => { + if (s === oldPath) { touched = true; return newPath; } + if (s.startsWith(folderPrefix)) { + touched = true; + return newPath + '/' + s.slice(folderPrefix.length); + } + return s; + }); + return touched ? { ...p, sources: updated } : null; }); await refreshFadeProject(); renderTabs(); @@ -6682,18 +7160,34 @@ async function bootstrap() { // inline edit row appears in the file list, pre-filled with a // suggested name (base portion selected). Enter saves; Escape / // blur / invalid name silently discards. - const NEW_FILE_EXTENSIONS: Array<{ label: string; ext: string }> = [ - { label: 'Fade source (.fbasic)', ext: 'fbasic' }, - { label: 'Shader (.fx)', ext: 'fx' }, - { label: 'JSON (.json)', ext: 'json' }, - { label: 'Text (.txt)', ext: 'txt' }, - ]; - - function showNewFileMenu(x: number, y: number) { + function showNewFileMenu(x: number, y: number, parentFolder?: string) { closeAnyFileMenu(); const menu = document.createElement('div'); menu.className = 'source-badge-menu'; menu.dataset.menu = 'file-context'; + // Folder context appears as a faint header so it's obvious WHERE + // the new item will land. Omitted at the root. + if (parentFolder) { + const ctx = document.createElement('div'); + ctx.className = 'source-badge-sep-label'; + ctx.textContent = `in ${parentFolder}`; + menu.append(ctx); + } + // New folder — first item since "create a place to put things" + // is the action users come for when right-clicking. + const folderItem = document.createElement('button'); + folderItem.className = 'source-badge-item'; + folderItem.type = 'button'; + folderItem.textContent = 'New folder…'; + folderItem.onclick = (e) => { + e.stopPropagation(); + closeAnyFileMenu(); + startInlineFolderCreate(parentFolder); + }; + menu.append(folderItem); + const sepAfterFolder = document.createElement('div'); + sepAfterFolder.className = 'source-badge-sep'; + menu.append(sepAfterFolder); for (const { label, ext } of NEW_FILE_EXTENSIONS) { const item = document.createElement('button'); item.className = 'source-badge-item'; @@ -6702,13 +7196,15 @@ async function bootstrap() { item.onclick = (e) => { e.stopPropagation(); closeAnyFileMenu(); - startInlineCreate(ext); + startInlineCreate(ext, parentFolder); }; menu.append(item); } // Separator + upload action. Opens a file picker; selected files // land in OPFS under their original names (collision-renamed) and - // the first one is auto-previewed. + // the first one is auto-previewed. Upload always targets the + // root for now — extending it to drop inside `parentFolder` + // would be straightforward; deferred until someone asks. const sep = document.createElement('div'); sep.className = 'source-badge-sep'; menu.append(sep); @@ -6744,21 +7240,33 @@ async function bootstrap() { // Insert an inline-edit row at the top of the file list and focus // its input. Commit on Enter; cancel on Escape/blur/invalid. + // `parentFolder` (optional) scopes the new file to that folder's + // path; auto-expands it on success so the new row is visible. let inlineCreateRow: HTMLLIElement | null = null; - async function startInlineCreate(ext: string) { + async function startInlineCreate(ext: string, parentFolder?: string) { // If a previous row is hanging, kill it first. inlineCreateRow?.remove(); inlineCreateRow = null; - // Find a name that doesn't collide. - const existing = new Set(await workspace.list()); - let base = 'untitled'; + // Find a name that doesn't collide. Uniqueness is computed in + // the parentFolder's namespace — `untitled.fb` at the root is + // distinct from `src/untitled.fb`. + const allPaths = new Set(await workspace.list()); + const inFolder = (n: string) => parentFolder ? `${parentFolder}/${n}` : n; + const base = 'untitled'; let candidate = `${base}.${ext}`; let n = 1; - while (existing.has(candidate)) candidate = `${base}${++n}.${ext}`; + while (allPaths.has(inFolder(candidate))) candidate = `${base}${++n}.${ext}`; const li = document.createElement('li'); li.className = 'file-edit-row'; + if (parentFolder) { + // Indent the edit row to match where the new file will sit + // in the tree once committed. Matches the depth math used + // in renderFileList. + const depth = parentFolder.split('/').length; + li.style.paddingLeft = `calc(1rem + ${depth * 14}px)`; + } const input = document.createElement('input'); input.type = 'text'; input.spellcheck = false; @@ -6785,13 +7293,17 @@ async function bootstrap() { if (!name) return; if (!/^[\w.-]+$/.test(name)) return; if (name === FADE_JSON_NAME) return; - const names = await workspace.list(); - if (names.includes(name)) return; + const fullPath = inFolder(name); + if ((await workspace.list()).includes(fullPath)) return; try { - await workspace.write(name, ''); - await openFile(workspace, name); - if (/\.(fbasic|fb)$/i.test(name)) { - await projectOps?.addSourceAt(name, 'end'); + await workspace.write(fullPath, ''); + // Make sure the parent folder is expanded so the new + // file is visible. Without this, creating a file in a + // collapsed folder feels like nothing happened. + if (parentFolder) collapsedFolders.delete(parentFolder); + await openFile(workspace, fullPath); + if (/\.(fbasic|fb)$/i.test(fullPath)) { + await projectOps?.addSourceAt(fullPath, 'end'); } } catch (e) { console.error('[fade] new-file failed:', e); @@ -6804,6 +7316,73 @@ async function bootstrap() { input.addEventListener('blur', () => { void finish(true); }); } + /** Inline-create row for a folder. Mirrors startInlineCreate's + * shape: same edit row UX, same Enter-to-commit / Escape-to- + * cancel. Creates the directory via workspace.mkdir and triggers + * a re-render so the new folder appears in place. */ + async function startInlineFolderCreate(parentFolder?: string) { + inlineCreateRow?.remove(); + inlineCreateRow = null; + + const allPaths = new Set(await workspace.list()); + // Also exclude existing directory paths so a name collision + // with another folder is caught. workspace.listEntries is the + // authoritative source for both. + const allEntries = await workspace.listEntries(); + const dirPaths = new Set(allEntries.filter((e) => e.kind === 'directory').map((e) => e.path)); + const inFolder = (n: string) => parentFolder ? `${parentFolder}/${n}` : n; + const exists = (n: string) => allPaths.has(inFolder(n)) || dirPaths.has(inFolder(n)); + + const base = 'new-folder'; + let candidate = base; + let n = 1; + while (exists(candidate)) candidate = `${base}${++n}`; + + const li = document.createElement('li'); + li.className = 'file-edit-row'; + if (parentFolder) { + const depth = parentFolder.split('/').length; + li.style.paddingLeft = `calc(1rem + ${depth * 14}px)`; + } + const input = document.createElement('input'); + input.type = 'text'; + input.spellcheck = false; + input.autocomplete = 'off'; + input.value = candidate; + input.placeholder = 'folder name'; + li.append(input); + fileListEl.prepend(li); + inlineCreateRow = li; + input.focus(); + input.select(); + + let settled = false; + const finish = async (commit: boolean) => { + if (settled) return; + settled = true; + const name = input.value.trim(); + li.remove(); + inlineCreateRow = null; + if (!commit) return; + if (!name) return; + if (!/^[\w.\-]+$/.test(name)) return; + const fullPath = inFolder(name); + if (exists(name)) return; + try { + await workspace.mkdir(fullPath); + if (parentFolder) collapsedFolders.delete(parentFolder); + await renderFileList(workspace); + } catch (e) { + console.error('[fade] new-folder failed:', e); + } + }; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); void finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); void finish(false); } + }); + input.addEventListener('blur', () => { void finish(true); }); + } + // ─── Upload + drag-drop binary files into the workspace ────────────── // Both the menu's "Upload file…" item and the file-list drag-drop // handlers route through ingestFiles() so the OPFS write + open-preview @@ -6904,6 +7483,21 @@ async function bootstrap() { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); showNewFileMenu(r.left, r.bottom + 2); }); + // New-folder button: prompt for a path (slashes allowed → nested + // creation), validate per-segment, call workspace.mkdir via the + // projectOps surface. Folders auto-expand once created (default + // state for any folder not in `collapsedFolders`). + newFolderBtn.addEventListener('click', () => { + if (!projectOps) return; + const raw = window.prompt( + 'New folder name (use / for nested):', + 'assets', + ); + if (!raw) return; + const path = raw.trim().replace(/^\/+|\/+$/g, ''); + if (!path) return; + void projectOps.createFolder(path); + }); // Right-click on the workspace pane's empty area (file rows handle // their own contextmenu and stop propagation via preventDefault + // showFileContextMenu). From d1fedcf190e57f1c53a1e416c709447fb9de958c Mon Sep 17 00:00:00 2001 From: Chris Hanna Date: Fri, 29 May 2026 13:21:35 -0400 Subject: [PATCH 29/30] release --- .gitignore | 5 +- .../ApplicationSupport/Project/ProjectDocs.cs | 50 +- FadeBasic/FadeBasic.Export.Web/FadeBridge.cs | 225 +- .../StandardCommandDocs.cs | 37 +- .../FadeBasic.Export.Web/wwwroot/runtime.js | 5 + FadeBasic/Tests/JsonTests.cs | 14 +- Playground/README.md | 134 + Playground/docs/Playground.md | 54 + Playground/index.html | 1742 ++++- Playground/package-lock.json | 1697 ++++- Playground/package.json | 9 +- Playground/public/_worker.js | 31 + Playground/public/docs-index.json | 1 + Playground/public/docs/Language.md | 2112 ++++++ Playground/public/docs/Playground.md | 54 + .../rag_files/monogame/FadeCommandDocs.md | 6369 +++++++++++++++++ Playground/scripts/build-docs-index.mjs | 166 + Playground/scripts/docs-sources.mjs | 32 + Playground/scripts/inspect-toc.mjs | 49 + Playground/scripts/inspect-toc2.mjs | 34 + Playground/scripts/probe-help-collapsible.mjs | 136 + Playground/scripts/probe-help-cref.mjs | 88 + Playground/scripts/probe-help-final.mjs | 100 + Playground/scripts/probe-help-groups.mjs | 73 + .../scripts/probe-help-lang-sections.mjs | 81 + .../scripts/probe-help-screenshot-toc.mjs | 42 + Playground/scripts/probe-help-search.mjs | 105 + Playground/scripts/probe-help-styles.mjs | 100 + Playground/scripts/probe-help-subs.mjs | 105 + Playground/scripts/probe-help-tabs.mjs | 41 + Playground/scripts/probe-help-toc-padding.mjs | 102 + Playground/scripts/probe-other.mjs | 34 + Playground/scripts/probe-search-panel.mjs | 230 + Playground/scripts/probe-settings-panel.mjs | 203 + Playground/scripts/probe-syntax-colors.mjs | 59 + Playground/scripts/probe-themes.mjs | 133 + Playground/scripts/sync-public-docs.mjs | 37 + Playground/src/ai-chat.ts | 1124 ++- .../src/ai/adapters/monaco-diagnostics.ts | 56 + Playground/src/ai/agent.test.ts | 778 ++ Playground/src/ai/agent.ts | 674 ++ Playground/src/ai/context.test.ts | 284 + Playground/src/ai/context.ts | 304 + Playground/src/ai/providers/anthropic.test.ts | 288 + Playground/src/ai/providers/anthropic.ts | 316 + Playground/src/ai/providers/index.ts | 178 + Playground/src/ai/providers/mock.ts | 128 + .../src/ai/providers/transformers-js.test.ts | 60 + .../src/ai/providers/transformers-js.ts | 337 + Playground/src/ai/providers/types.ts | 90 + Playground/src/ai/rag/chunker.test.ts | 92 + Playground/src/ai/rag/chunker.ts | 201 + Playground/src/ai/rag/embedder.ts | 118 + Playground/src/ai/rag/index-loader.ts | 86 + Playground/src/ai/rag/retrieval.test.ts | 53 + Playground/src/ai/rag/retrieval.ts | 101 + Playground/src/ai/rag/search.test.ts | 107 + Playground/src/ai/rag/search.ts | 61 + Playground/src/ai/rag/types.ts | 53 + Playground/src/ai/slash-commands/clear.ts | 19 + .../src/ai/slash-commands/commands.test.ts | 164 + Playground/src/ai/slash-commands/context.ts | 67 + .../src/ai/slash-commands/default-registry.ts | 20 + Playground/src/ai/slash-commands/help.ts | 16 + Playground/src/ai/slash-commands/logs.ts | 19 + Playground/src/ai/slash-commands/model.ts | 53 + Playground/src/ai/slash-commands/plan.ts | 19 + .../src/ai/slash-commands/registry.test.ts | 100 + Playground/src/ai/slash-commands/registry.ts | 83 + Playground/src/ai/slash-commands/tools.ts | 27 + Playground/src/ai/slash-commands/types.ts | 70 + Playground/src/ai/tool-protocol.test.ts | 176 + Playground/src/ai/tool-protocol.ts | 369 + Playground/src/ai/tools/apply-edit.test.ts | 117 + Playground/src/ai/tools/apply-edit.ts | 82 + Playground/src/ai/tools/create-file.ts | 37 + Playground/src/ai/tools/default-registry.ts | 23 + .../src/ai/tools/get-diagnostics.test.ts | 95 + Playground/src/ai/tools/get-diagnostics.ts | 79 + Playground/src/ai/tools/index.ts | 171 + Playground/src/ai/tools/list-files.ts | 16 + Playground/src/ai/tools/read-file.ts | 19 + Playground/src/ai/tools/search-docs.ts | 42 + Playground/src/ai/ui/diff-approval.test.ts | 308 + Playground/src/ai/ui/diff-approval.ts | 269 + Playground/src/ai/webgpu-info.test.ts | 73 + Playground/src/ai/webgpu-info.ts | 91 + Playground/src/changelog.ts | 52 + Playground/src/fade-config.ts | 12 +- Playground/src/help.test.ts | 128 + Playground/src/help.ts | 1121 ++- Playground/src/languages.ts | 21 +- Playground/src/logs-panel.ts | 42 +- Playground/src/main.ts | 1353 +++- Playground/src/project-source-map.ts | 88 + Playground/src/search-panel.ts | 467 ++ Playground/src/settings-panel.ts | 391 + Playground/src/settings.ts | 322 + Playground/src/sharing/collaboration-panel.ts | 21 +- Playground/src/sharing/diff-viewer.ts | 14 +- Playground/src/themes.ts | 111 + Playground/src/version-popup.ts | 174 + Playground/tsconfig.json | 1 + Playground/tsconfig.tsbuildinfo | 2 +- oauth-proxy/.gitignore | 4 + oauth-proxy/README.md | 130 + oauth-proxy/package-lock.json | 1606 +++++ oauth-proxy/package.json | 17 + oauth-proxy/src/index.ts | 212 + oauth-proxy/tsconfig.json | 19 + oauth-proxy/wrangler.toml | 33 + 111 files changed, 27736 insertions(+), 1207 deletions(-) create mode 100644 Playground/README.md create mode 100644 Playground/docs/Playground.md create mode 100644 Playground/public/_worker.js create mode 100644 Playground/public/docs-index.json create mode 100644 Playground/public/docs/Language.md create mode 100644 Playground/public/docs/Playground.md create mode 100644 Playground/rag_files/monogame/FadeCommandDocs.md create mode 100644 Playground/scripts/build-docs-index.mjs create mode 100644 Playground/scripts/docs-sources.mjs create mode 100644 Playground/scripts/inspect-toc.mjs create mode 100644 Playground/scripts/inspect-toc2.mjs create mode 100644 Playground/scripts/probe-help-collapsible.mjs create mode 100644 Playground/scripts/probe-help-cref.mjs create mode 100644 Playground/scripts/probe-help-final.mjs create mode 100644 Playground/scripts/probe-help-groups.mjs create mode 100644 Playground/scripts/probe-help-lang-sections.mjs create mode 100644 Playground/scripts/probe-help-screenshot-toc.mjs create mode 100644 Playground/scripts/probe-help-search.mjs create mode 100644 Playground/scripts/probe-help-styles.mjs create mode 100644 Playground/scripts/probe-help-subs.mjs create mode 100644 Playground/scripts/probe-help-tabs.mjs create mode 100644 Playground/scripts/probe-help-toc-padding.mjs create mode 100644 Playground/scripts/probe-other.mjs create mode 100644 Playground/scripts/probe-search-panel.mjs create mode 100644 Playground/scripts/probe-settings-panel.mjs create mode 100644 Playground/scripts/probe-syntax-colors.mjs create mode 100644 Playground/scripts/probe-themes.mjs create mode 100644 Playground/scripts/sync-public-docs.mjs create mode 100644 Playground/src/ai/adapters/monaco-diagnostics.ts create mode 100644 Playground/src/ai/agent.test.ts create mode 100644 Playground/src/ai/agent.ts create mode 100644 Playground/src/ai/context.test.ts create mode 100644 Playground/src/ai/context.ts create mode 100644 Playground/src/ai/providers/anthropic.test.ts create mode 100644 Playground/src/ai/providers/anthropic.ts create mode 100644 Playground/src/ai/providers/index.ts create mode 100644 Playground/src/ai/providers/mock.ts create mode 100644 Playground/src/ai/providers/transformers-js.test.ts create mode 100644 Playground/src/ai/providers/transformers-js.ts create mode 100644 Playground/src/ai/providers/types.ts create mode 100644 Playground/src/ai/rag/chunker.test.ts create mode 100644 Playground/src/ai/rag/chunker.ts create mode 100644 Playground/src/ai/rag/embedder.ts create mode 100644 Playground/src/ai/rag/index-loader.ts create mode 100644 Playground/src/ai/rag/retrieval.test.ts create mode 100644 Playground/src/ai/rag/retrieval.ts create mode 100644 Playground/src/ai/rag/search.test.ts create mode 100644 Playground/src/ai/rag/search.ts create mode 100644 Playground/src/ai/rag/types.ts create mode 100644 Playground/src/ai/slash-commands/clear.ts create mode 100644 Playground/src/ai/slash-commands/commands.test.ts create mode 100644 Playground/src/ai/slash-commands/context.ts create mode 100644 Playground/src/ai/slash-commands/default-registry.ts create mode 100644 Playground/src/ai/slash-commands/help.ts create mode 100644 Playground/src/ai/slash-commands/logs.ts create mode 100644 Playground/src/ai/slash-commands/model.ts create mode 100644 Playground/src/ai/slash-commands/plan.ts create mode 100644 Playground/src/ai/slash-commands/registry.test.ts create mode 100644 Playground/src/ai/slash-commands/registry.ts create mode 100644 Playground/src/ai/slash-commands/tools.ts create mode 100644 Playground/src/ai/slash-commands/types.ts create mode 100644 Playground/src/ai/tool-protocol.test.ts create mode 100644 Playground/src/ai/tool-protocol.ts create mode 100644 Playground/src/ai/tools/apply-edit.test.ts create mode 100644 Playground/src/ai/tools/apply-edit.ts create mode 100644 Playground/src/ai/tools/create-file.ts create mode 100644 Playground/src/ai/tools/default-registry.ts create mode 100644 Playground/src/ai/tools/get-diagnostics.test.ts create mode 100644 Playground/src/ai/tools/get-diagnostics.ts create mode 100644 Playground/src/ai/tools/index.ts create mode 100644 Playground/src/ai/tools/list-files.ts create mode 100644 Playground/src/ai/tools/read-file.ts create mode 100644 Playground/src/ai/tools/search-docs.ts create mode 100644 Playground/src/ai/ui/diff-approval.test.ts create mode 100644 Playground/src/ai/ui/diff-approval.ts create mode 100644 Playground/src/ai/webgpu-info.test.ts create mode 100644 Playground/src/ai/webgpu-info.ts create mode 100644 Playground/src/changelog.ts create mode 100644 Playground/src/help.test.ts create mode 100644 Playground/src/project-source-map.ts create mode 100644 Playground/src/search-panel.ts create mode 100644 Playground/src/settings-panel.ts create mode 100644 Playground/src/settings.ts create mode 100644 Playground/src/themes.ts create mode 100644 Playground/src/version-popup.ts create mode 100644 oauth-proxy/.gitignore create mode 100644 oauth-proxy/README.md create mode 100644 oauth-proxy/package-lock.json create mode 100644 oauth-proxy/package.json create mode 100644 oauth-proxy/src/index.ts create mode 100644 oauth-proxy/tsconfig.json create mode 100644 oauth-proxy/wrangler.toml diff --git a/.gitignore b/.gitignore index fe9fe41..7ca4e16 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,7 @@ msbuild.wrn # Plugin signing material (JetBrains Marketplace) *.pem -*.crt \ No newline at end of file +*.crt + +# BenchmarkDotNet output +**/BenchmarkDotNet.Artifacts/ \ No newline at end of file diff --git a/FadeBasic/ApplicationSupport/Project/ProjectDocs.cs b/FadeBasic/ApplicationSupport/Project/ProjectDocs.cs index 0cf4105..ff431d7 100644 --- a/FadeBasic/ApplicationSupport/Project/ProjectDocs.cs +++ b/FadeBasic/ApplicationSupport/Project/ProjectDocs.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text; using System.Xml.Linq; @@ -310,21 +312,44 @@ public static void ParseBlock(IDocParser parser, XElement summary, StringBuilder public static ProjectDocs LoadDocs(this List metadatas, Action onDocParseError = null) where T : IDocParser, new() { - // Build command name -> group lookup so can resolve links + // Two lookups so can resolve from either the + // Fade-script call name (`"texture"`) OR the underlying C# method + // name (`"LoadTexture"`). The XML docs in our source use both forms + // freely; without the second map the C# names fall through to the + // inline-code fallback in MarkdownDocParser.ConvertSee and never + // produce a clickable link. var commandToGroup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var methodNameToCallName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var metadata in metadatas) { foreach (var command in metadata.commands) { commandToGroup[command.callName] = metadata.className; + var shortMethodName = ExtractShortMethodName(command.methodName, command.sig); + if (!string.IsNullOrEmpty(shortMethodName) && !methodNameToCallName.ContainsKey(shortMethodName)) + methodNameToCallName[shortMethodName] = command.callName; } } var parser = new T(); + // Fragment-style URL the playground intercepts in help.ts and routes + // through helpCtl.selectCommand. The browser treats `#…` as + // same-page navigation, so a stray middle-click / new-tab doesn't + // 404 against a path that nothing serves. The fragment's payload + // is URI-encoded so callNames with spaces (`"push asset"`) survive. parser.ResolveSeeRef = cref => { - if (commandToGroup.TryGetValue(cref, out var group)) - return "/command/" + group + "/" + cref; + if (string.IsNullOrEmpty(cref)) return null; + // Strip any trailing `(...)` so `` matches the + // bare `Sync` we have in the methodName map. The XML source uses + // both forms; both should link. + var key = cref; + var paren = key.IndexOf('('); + if (paren > 0) key = key.Substring(0, paren); + if (methodNameToCallName.TryGetValue(key, out var callName)) + return "#fade-cmd:" + Uri.EscapeDataString(callName); + if (commandToGroup.ContainsKey(key)) + return "#fade-cmd:" + Uri.EscapeDataString(key); return null; }; @@ -360,6 +385,25 @@ public static ProjectDocs LoadDocs(this List metadatas, Acti return docs; } + // The source generator emits `MethodName = "Call__"` per + // command. Crefs in the XML docs reference the underlying C# method + // ("Push", "LoadTexture") rather than the generated wrapper, so we + // strip the `Call_` prefix and `_` suffix to recover the short + // name we can match cref keys against. + internal static string ExtractShortMethodName(string methodName, string sig) + { + if (string.IsNullOrEmpty(methodName)) return null; + const string prefix = "Call_"; + if (!methodName.StartsWith(prefix, StringComparison.Ordinal)) return methodName; + var stripped = methodName.Substring(prefix.Length); + if (!string.IsNullOrEmpty(sig)) + { + var suffix = "_" + sig; + if (stripped.EndsWith(suffix, StringComparison.Ordinal)) + stripped = stripped.Substring(0, stripped.Length - suffix.Length); + } + return stripped; + } } public class ProjectDocs diff --git a/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs b/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs index 3d63323..922cf00 100644 --- a/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs +++ b/FadeBasic/FadeBasic.Export.Web/FadeBridge.cs @@ -32,6 +32,12 @@ public static partial class FadeBridge // declaration order and CreateWorkspace reads this list. private static readonly List _registeredSources = new(); + // Source-generated metadata blobs (`MetaData.COMMANDS_JSON`) + // pulled out of each registered assembly. Feeds the workspace's docs + // provider so hover/help can render rich markdown for commands from + // dynamically-loaded libraries, not just StandardCommands. + private static readonly List _registeredCommandJsonBlobs = new(); + // Dynamically-loaded assemblies keyed by simple name. WASM's default // AssemblyLoadContext doesn't fall back to "scan loaded assemblies by // simple name" when binding type references the way desktop CLR does — @@ -72,8 +78,18 @@ private static FadeWorkspace CreateWorkspace(string projectType) { var sources = new List(_registeredSources) { new StandardCommands() }; var commands = new CommandCollection(sources.ToArray()); - ICommandDocsProvider docs = StandardCommandDocs.BuildWeb(); - _ = projectType; // reserved for future type-specific doc providers + + // Docs follow whatever's registered: StandardCommands is always + // there, plus one COMMANDS_JSON blob per dynamically-loaded + // assembly (collected in RegisterCommandAssembly). projectType + // doesn't pick the docs anymore — the assemblies themselves do. + var blobs = new List(_registeredCommandJsonBlobs.Count + 1) + { + StandardCommandsMetaData.COMMANDS_JSON, + }; + blobs.AddRange(_registeredCommandJsonBlobs); + ICommandDocsProvider docs = StandardCommandDocs.Build(blobs.ToArray()); + _ = projectType; var ws = new FadeWorkspace(commands); ws.Docs = docs; @@ -109,12 +125,24 @@ public static string RegisterCommandAssembly(byte[] dllBytes, string className) var instance = Activator.CreateInstance(type) as IMethodSource ?? throw new Exception($"'{className}' does not implement IMethodSource"); _registeredSources.Add(instance); + + // The command source generator emits a sibling `MetaData` + // type with a `public const string COMMANDS_JSON` carrying the + // XML doc strings for every command. Pull it out so hover/help + // can render rich markdown — without this, registered libraries + // show just the signature shape. + var metaType = asm.GetType(className + "MetaData"); + var jsonField = metaType?.GetField("COMMANDS_JSON", + BindingFlags.Public | BindingFlags.Static); + if (jsonField?.GetRawConstantValue() is string json && !string.IsNullOrEmpty(json)) + _registeredCommandJsonBlobs.Add(json); + _workspace = CreateWorkspace(_activeProjectType); - return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + return StatusOk(); } catch (Exception ex) { - return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + return StatusErr(ex); } } @@ -124,6 +152,7 @@ public static string RegisterCommandAssembly(byte[] dllBytes, string className) public static string ClearCommandAssemblies() { _registeredSources.Clear(); + _registeredCommandJsonBlobs.Clear(); _workspace = CreateWorkspace(_activeProjectType); return "true"; } @@ -139,11 +168,11 @@ public static string LoadAssembly(byte[] dllBytes) try { LoadAndRegister(dllBytes); - return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + return StatusOk(); } catch (Exception ex) { - return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + return StatusErr(ex); } } @@ -242,11 +271,11 @@ public static string RunStart(byte[] entryDllBytes) hostMethods = HostMethodTable.FromCommandCollection(instance.CommandCollection), }; CooperativePump.RunStartWithVm(vm); - return JsonSerializer.Serialize(new { ok = true }, _jsonOpts); + return StatusOk(); } catch (Exception ex) { - return JsonSerializer.Serialize(new { ok = false, error = DescribeException(ex) }, _jsonOpts); + return StatusErr(ex); } } @@ -361,6 +390,57 @@ private static string DescribeException(Exception ex) IncludeFields = true, }; + // Hand-rolled JSON for the {ok, error?} status shape used by every + // assembly-loading JSExport. `JsonSerializer.Serialize(new { ok=true })` + // is anonymous-type-based; in this project's Release/trimmed publish, + // the trimmer strips parameter names from `<>f__AnonymousType*` even + // with TrimMode=copy, and System.Text.Json then throws "deserialization + // constructor ... contains parameters with null names". A literal JSON + // string sidesteps the metadata reflection entirely. + private static string StatusOk() => "{\"ok\":true}"; + private static string StatusErr(Exception ex) + { + var sb = new StringBuilder("{\"ok\":false,\"error\":"); + AppendJsonString(sb, DescribeException(ex)); + sb.Append('}'); + return sb.ToString(); + } + + // JSON-safe string emit: quotes the value and escapes the characters + // RFC 8259 requires (`"`, `\`, and U+0000–U+001F). FadeBasic.Json's + // built-in JsonWriteOp.AppendEscaped only handles `"` and `\`, which + // is fine for the short identifiers the rest of the project emits but + // produces invalid JSON when the value contains markdown newlines — + // exactly what ListCommandDocs hits when it serializes hover markdown. + private static void AppendJsonString(StringBuilder sb, string value) + { + sb.Append('"'); + if (value != null) + { + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) + sb.Append("\\u").Append(((int)c).ToString("x4")); + else + sb.Append(c); + break; + } + } + } + sb.Append('"'); + } + // ─── Run ────────────────────────────────────────────────────────────── [JSInvokable] [JSExport] @@ -431,6 +511,42 @@ public static string LspGetSemanticTokens(string uri) return JsonSerializer.Serialize(SemanticTokensHandler.Compute(doc), _jsonOpts); } + // Tokenize a free-floating snippet of Fade source — no workspace doc, + // no diagnostics published — and return a flat list of `{line, col, + // length, type}` entries. The Help tab uses this to syntax-highlight + // ```fade``` code blocks in command/language docs by piggybacking on + // the same lexer + ClassifyToken pass the LSP semantic-tokens handler + // uses for the editor. The type field is the legend index from + // SemanticTokensHandler.Legend (0=comment, 1=keyword, …). + [JSExport] + public static string LspTokenizeSnippet(string source) + { + if (string.IsNullOrEmpty(source)) return "[]"; + var commands = _workspace.Commands; + var lex = new FadeBasic.Lexer().TokenizeWithErrors(source, commands); + var doc = new FadeBasic.LSP.Core.FadeDocument + { + Uri = "fade://help-snippet", + Text = source, + LexResults = lex, + Commands = commands, + }; + var sb = new StringBuilder("["); + var first = true; + foreach (var ct in SemanticTokensHandler.Classify(doc)) + { + if (!first) sb.Append(','); + first = false; + sb.Append("{\"line\":").Append(ct.Token.lineNumber); + sb.Append(",\"col\":").Append(ct.Token.charNumber); + sb.Append(",\"length\":").Append(ct.Token.Length); + sb.Append(",\"type\":").Append(SemanticTokensHandler.LegendIndex(ct.Type)); + sb.Append('}'); + } + sb.Append(']'); + return sb.ToString(); + } + [JSExport] public static string LspHover(string uri, int line, int character) { @@ -555,16 +671,37 @@ public static string ListCommandDocs() try { var commands = _workspace.Commands?.Commands; - if (commands == null) + if (commands == null) return "[]"; + + // Map command name → owning class label, derived from each + // IMethodSource's CommandGroupName (e.g. + // "Fade.MonoGame.Lib.FadeMonoGameCommands"). FIRST source wins + // on name collisions, matching the dedupe ordering applied to + // workspace.Commands below — otherwise a command's body and + // group label could come from different sources. We shorten + // FQNs to "" so the TOC reads + // "Standard" / "FadeMonoGame" rather than the full namespace. + var nameToGroup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var sources = _workspace.Commands?.Sources; + if (sources != null) { - return "[]"; + foreach (var source in sources) + { + var groupLabel = ShortenGroupName(source.CommandGroupName); + foreach (var cmd in source.Commands) + { + if (string.IsNullOrEmpty(cmd.name)) continue; + if (!nameToGroup.ContainsKey(cmd.name)) nameToGroup[cmd.name] = groupLabel; + } + } } + // Dedupe by command.name. Overloads (e.g. `rgb` with 3 vs 4 // args) share a name; we surface one row per name and use the // first CommandInfo we find — BuildCommandMarkdown already // describes all parameter slots from that signature. var seen = new HashSet(); - var rows = new List(); + var rows = new List<(string name, string sig, string group, string markdown)>(); foreach (var c in commands) { if (string.IsNullOrEmpty(c.name)) continue; @@ -579,33 +716,40 @@ public static string ListCommandDocs() { markdown = $"### {c.name}\n\n_Failed to render docs: {ex.Message}_"; } - rows.Add(new - { - name = c.name, - signature = c.sig, - // Best-effort: classify into a "group" based on the - // command name's first word for the TOC. The native - // command-doc generator keeps a category in metadata - // we don't propagate here yet; this is a useful - // approximation until that's wired through. - group = GuessGroup(c.name), - markdown, - }); + // group: the IMethodSource the command came from, so the + // TOC reflects actual library origin. GuessGroup is the + // backstop for commands that somehow have no source map + // entry (shouldn't happen — every Command was iterated + // off some Source above — but defensive). + var group = nameToGroup.TryGetValue(c.name, out var g) ? g : GuessGroup(c.name); + rows.Add((c.name, c.sig, group, markdown)); } // Stable alphabetical order so the TOC is deterministic. - rows.Sort((a, b) => - string.Compare( - (string)a.GetType().GetProperty("name").GetValue(a), - (string)b.GetType().GetProperty("name").GetValue(b), - StringComparison.OrdinalIgnoreCase)); - return JsonSerializer.Serialize(rows, _jsonOpts); + rows.Sort((a, b) => string.Compare(a.name, b.name, StringComparison.OrdinalIgnoreCase)); + + // Hand-rolled output instead of `JsonSerializer.Serialize(new { ... })`: + // anonymous types are unreliable under the project's trimmed + // Release publish (see the note on StatusOk/StatusErr above). + var sb = new StringBuilder(); + sb.Append('['); + for (var i = 0; i < rows.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append("{\"name\":"); AppendJsonString(sb, rows[i].name); + sb.Append(",\"signature\":"); AppendJsonString(sb, rows[i].sig); + sb.Append(",\"group\":"); AppendJsonString(sb, rows[i].group); + sb.Append(",\"markdown\":"); AppendJsonString(sb, rows[i].markdown); + sb.Append('}'); + } + sb.Append(']'); + return sb.ToString(); } catch (Exception ex) { - return JsonSerializer.Serialize(new - { - error = "Failed to enumerate command docs: " + ex.Message, - }, _jsonOpts); + var sb = new StringBuilder("{\"error\":"); + AppendJsonString(sb, "Failed to enumerate command docs: " + ex.Message); + sb.Append('}'); + return sb.ToString(); } } @@ -622,6 +766,21 @@ private static string GuessGroup(string name) return idx > 0 ? name.Substring(0, idx) : "Core"; } + // Turn an IMethodSource.CommandGroupName (a fully-qualified type name like + // "Fade.MonoGame.Lib.FadeMonoGameCommands") into a human-friendly TOC + // section label ("FadeMonoGame"). Strips the namespace and the + // conventional "Commands" suffix on the type name. + private static string ShortenGroupName(string fqn) + { + if (string.IsNullOrEmpty(fqn)) return "Core"; + var dot = fqn.LastIndexOf('.'); + var typeName = dot >= 0 ? fqn.Substring(dot + 1) : fqn; + const string suffix = "Commands"; + if (typeName.EndsWith(suffix, StringComparison.Ordinal) && typeName.Length > suffix.Length) + typeName = typeName.Substring(0, typeName.Length - suffix.Length); + return string.IsNullOrEmpty(typeName) ? "Core" : typeName; + } + // ─── Tests ──────────────────────────────────────────────────────────── // Compile the source and list the test entry points. Returns a JSON // array of { name, isAbstract, fromParent, sourceLine }. On compile diff --git a/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs b/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs index 21c06a9..64a9509 100644 --- a/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs +++ b/FadeBasic/FadeBasic.Export.Web/StandardCommandDocs.cs @@ -7,15 +7,14 @@ // → ProjectDocs (ProjectDocMethods.LoadDocs) // → ICommandDocsProvider (ProjectDocsCommandDocsProvider) // -// One file with two builders: web (StandardCommands only) and monogame -// (FadeMonoGameCommands + StandardCommands). Both go through the same -// LoadDocs pipeline, which accepts a list — so monogame just passes both -// metadata blobs. +// Callers pass every COMMANDS_JSON blob that's currently live — Standard +// plus whatever assemblies were dynamically registered. The pipeline +// merges them into one ProjectDocs map keyed by callName + sig. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using FadeBasic.ApplicationSupport.Project; -using FadeBasic.Lib.Standard; using FadeBasic.LSP.Core; namespace FadeBasic.Export.Web; @@ -28,26 +27,24 @@ internal static class StandardCommandDocs PropertyNameCaseInsensitive = true, }; - /// Docs for the 'web' command surface — WebCommands + StandardCommands. - /// - /// WebCommands doesn't ship a source-generator metadata blob (it's tiny; - /// hover falls back to the basic signature header for those). Standard - /// is the only set with rich docs in this branch. - /// - public static ICommandDocsProvider BuildWeb() => - BuildFromMetadata(StandardCommandsMetaData.COMMANDS_JSON); - - /// Docs for the 'monogame' command surface — placeholder until dynamic command registration is wired up. - public static ICommandDocsProvider BuildMonoGame() => - BuildFromMetadata(StandardCommandsMetaData.COMMANDS_JSON); - - private static ICommandDocsProvider BuildFromMetadata(params string[] commandsJsonBlobs) + // System.Text.Json reflects ctors + fields off these types at deserialize + // time. In the Release/trimmed publish the trimmer drops the default + // ctors (so STJ throws "Deserialization of types without a parameterless + // constructor … is not supported") and the public fields (so even with + // ctors, every field deserializes to default). These dependencies pin + // both. Without them every command in the Help tab renders as just a + // signature header, with no summary, parameters, or examples. + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields, typeof(CommandMetadata))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields, typeof(ProjectCommandMetadata))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields, typeof(ProjectCommandParameterMetedata))] + public static ICommandDocsProvider Build(params string[] commandsJsonBlobs) { try { var metas = new List(commandsJsonBlobs.Length); foreach (var json in commandsJsonBlobs) { + if (string.IsNullOrEmpty(json)) continue; var m = JsonSerializer.Deserialize(json, _opts); if (m != null) metas.Add(m); } @@ -57,7 +54,7 @@ private static ICommandDocsProvider BuildFromMetadata(params string[] commandsJs } catch { - // Best-effort — hover falls back to the basic signature header. + // Best-effort — hover/help fall back to the basic signature header. return null!; } } diff --git a/FadeBasic/FadeBasic.Export.Web/wwwroot/runtime.js b/FadeBasic/FadeBasic.Export.Web/wwwroot/runtime.js index 7ea95a0..639fbc1 100644 --- a/FadeBasic/FadeBasic.Export.Web/wwwroot/runtime.js +++ b/FadeBasic/FadeBasic.Export.Web/wwwroot/runtime.js @@ -489,6 +489,11 @@ function handle(msg) { 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(); } diff --git a/FadeBasic/Tests/JsonTests.cs b/FadeBasic/Tests/JsonTests.cs index 103baf9..392060e 100644 --- a/FadeBasic/Tests/JsonTests.cs +++ b/FadeBasic/Tests/JsonTests.cs @@ -12,7 +12,7 @@ class RecurseList : IJsonable { public int n; public List l = new List(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(n), ref n); op.IncludeField(nameof(l), ref l); @@ -22,7 +22,7 @@ public void ProcessJson(IJsonOperation op) class ByteArray : IJsonable { public byte[] numbers; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(numbers), ref numbers); } @@ -33,7 +33,7 @@ class StringInt : IJsonable public string reason; public int status; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(reason), ref reason); op.IncludeField(nameof(status), ref status); @@ -44,7 +44,7 @@ class Dud : IJsonable { public int x; - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField("x", ref x); } @@ -53,7 +53,7 @@ public void ProcessJson(IJsonOperation op) class DictTest : IJsonable { public Dictionary duds = new Dictionary(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField("duds", ref duds); } @@ -63,7 +63,7 @@ class DictStringInt : IJsonable { public Dictionary duds = new Dictionary(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(duds), ref duds); } @@ -74,7 +74,7 @@ class DoubleDict : IJsonable public Dictionary beeps = new Dictionary(); public Dictionary boops = new Dictionary(); - public void ProcessJson(IJsonOperation op) + public void ProcessJson(ref T op) where T : IJsonOperation { op.IncludeField(nameof(beeps), ref beeps); op.IncludeField(nameof(boops), ref boops); diff --git a/Playground/README.md b/Playground/README.md new file mode 100644 index 0000000..8fd712b --- /dev/null +++ b/Playground/README.md @@ -0,0 +1,134 @@ +# Fade Playground + +Browser-based editor and runtime for FadeBasic. A static Vite SPA built +around Monaco / `@codingame/monaco-vscode-api` plus the FadeBasic +runtime compiled to WASM. + +## Local development + +```bash +cd Playground +npm install +npm run dev +``` + +`predev` builds the runtime and syncs docs into `public/`, so the first +boot takes a few seconds longer than subsequent ones. Dev server runs +on http://localhost:5311. + +## Production build + +```bash +npm run build +``` + +Outputs to `Playground/dist`. The runtime build and docs sync are +*not* run by `npm run build` — run them first if your local copies are +stale: + +```bash +npm run build:runtime +npm run sync:public-docs +npm run build +``` + +## Deploying to Cloudflare Pages + +The deployed site lives on Cloudflare Pages, project name +`fade-playground`. Two public URLs: + +- **Production** — https://fade-playground.pages.dev (deploys from + `--branch=main`) +- **Preview** — https://tests.fade-playground.pages.dev (deploys from + `--branch=tests`, used for in-progress work that isn't on `main` + yet) + +Each deploy also gets a permanent hash URL like +`https://e436d744.fade-playground.pages.dev` — useful for sharing a +specific build without overwriting the canonical one. + +### Deploy from your terminal + +`wrangler` is installed in [`../oauth-proxy/node_modules`](../oauth-proxy) +— there's no separate wrangler dependency in this package. The deploy +is a one-shot upload of `dist/` to Cloudflare Pages. + +```bash +# 1. From Playground/ — rebuild runtime, docs, and SPA. +npm run build:runtime +npm run sync:public-docs +npm run build + +# 2. From oauth-proxy/ — push dist/ to Cloudflare Pages. +cd ../oauth-proxy +npx wrangler pages deploy ../Playground/dist \ + --project-name=fade-playground \ + --commit-dirty=true \ + --branch=tests +``` + +Swap `--branch=tests` for `--branch=main` to publish to the production +URL. `--commit-dirty=true` silences the "your git tree is dirty" +warning so the upload doesn't refuse to run. + +### One-time setup + +If you've never deployed before: + +```bash +cd oauth-proxy +npx wrangler login # browser auth +npx wrangler pages project create fade-playground \ + --production-branch=main # only once +``` + +The project name and production-branch are baked into the Cloudflare +account; you don't need to re-run this on subsequent deploys. + +### CORS / OAuth proxy + +The Playground's GitHub auth uses the device-flow proxy in +[`../oauth-proxy`](../oauth-proxy). That worker enforces an origin +allow-list — `ALLOWED_ORIGINS` in +[`oauth-proxy/wrangler.toml`](../oauth-proxy/wrangler.toml). When you +add a new Pages URL (custom domain, new preview branch alias), append +it to that list and `npm run deploy` from `oauth-proxy/`, or device +flow will 403. + +Per-deploy hash URLs (`https://.fade-playground.pages.dev`) are +*not* in the allow-list. Use the branch alias URL for any flow that +hits the OAuth proxy. + +### CORS echo (`public/_worker.js`) + +The sandboxed MonoGame preview iframe ([src/monogame-host.ts](src/monogame-host.ts)) +runs without `allow-same-origin`, so its origin is the literal string +`"null"`. Blazor's `dotnet.js` then fetches `blazor.boot.json` and the +runtime `.wasm` modules with `credentials: 'include'`, and browsers +refuse credentialed responses with `Access-Control-Allow-Origin: *` +— which is what Cloudflare Pages serves static assets with by default. +[public/_worker.js](public/_worker.js) is the production-side +counterpart to the `cors-echo-origin` plugin in +[vite.config.ts](vite.config.ts): it echoes the request's `Origin` +back (including the literal `"null"`) and sets +`Access-Control-Allow-Credentials: true`, identical to the dev-server +behavior. Vite copies `public/` to `dist/` on every build, so the +worker ships automatically — no extra deploy step. + +### First-deploy SSL lag + +Cloudflare's universal `*.pages.dev` cert covers +`fade-playground.pages.dev` instantly, but the per-project wildcard +that covers `*.fade-playground.pages.dev` (branch aliases, per-deploy +hash URLs) is provisioned asynchronously after project creation. +Until that lands — typically 15 min, sometimes longer — Firefox will +report `SSL_ERROR_NO_CYPHER_OVERLAP` and curl will see an outright +handshake failure on those subdomain URLs. The root URL works the +whole time, so deploy with `--branch=main` if you need an +immediately-reachable build. + +### Size limits + +Cloudflare Pages caps individual files at 25 MiB and total files at +20,000 per deploy. The onnxruntime WASM is the largest single file +(~23.5 MiB) — keep an eye on it if upstream bumps the bundle. diff --git a/Playground/docs/Playground.md b/Playground/docs/Playground.md new file mode 100644 index 0000000..a6a6bdb --- /dev/null +++ b/Playground/docs/Playground.md @@ -0,0 +1,54 @@ +# Playground + +This is the in-browser Fade development environment. It runs the Fade +compiler, LSP, and VM entirely client-side so you can write, debug, and +ship a project without installing anything. + +## Layout + +The page is organized into dockable panels. Drag a tab to rearrange, +double-click a tab to maximize, or close a panel from the **View** menu. + +- **Editor** — Monaco with the `fade` language: syntax highlighting, + diagnostics, hover docs, go-to-definition, find-references, rename. +- **Workspace** — files in your active project, plus per-project + `fade.json` manifest. Right-click for create/rename/delete. +- **Problems** — every diagnostic the LSP is currently reporting. +- **Help** — what you're reading now. Tabs at the top jump between this + Playground guide, the language reference, and the command catalog. +- **AI Chat** — chat-style assistant that can read your files, search + docs, and propose edits. +- **Game / Console** — runtime output. The Game panel is the canvas for + `monogame` projects; the Console panel collects `print` output for + any project. +- **Tests** — runs your `test` blocks via the same VM the game uses. +- **Debugger** — breakpoints, step over/in/out, evaluate expressions. + +## Projects + +Every project is a folder with a `fade.json` manifest, one or more +`.fbasic` sources, and an optional `commandDlls` list. Two templates +ship today: + +- **`web`** — pure FadeBasic with the `FadeBasic.Lib.Web` command set + (`prompt$`, `wait ms`, etc.). Renders nothing. +- **`monogame`** — FadeBasic plus the `Fade.MonoGame.Lib` commands + (`sprite`, `texture`, `sync`, …). The Game panel hosts the + MonoGame canvas. + +Switch the project type by editing `fade.json`'s `"type"` field. The +Help tab's command list and the AI's retrieved docs both follow the +active type. + +## Files persist locally + +Everything you create lives in the browser's OPFS storage. Closing the +tab keeps your work; clearing site data wipes it. Use the **Workspace** +panel's import / export actions to round-trip projects through `.zip`. + +## Sharing + +The **Share** button uploads the active project to a hosted gist-style +endpoint and copies a link. The recipient opens the link, the +Playground forks the project into their workspace, and they're editing +their own copy. diff --git a/Playground/index.html b/Playground/index.html index 7b2cf3e..ffbdc74 100644 --- a/Playground/index.html +++ b/Playground/index.html @@ -7,6 +7,12 @@ + + +

    WebRTC Connectivity Probe

    +

    + Verifies whether STUN and TURN actually work for this browser on + this network. Same ICE server config as the playground's live-session + feature. If you're seeing "Ice connection failed" errors, run this + first. +

    + +
    + + +
    + +
    +

    Environment

    +
    +
    + +
    +

    ICE candidate gathering

    +
    Click "Run probe" to start.
    + + + + + + + + + + + + +
    +
    + +
    +

    Loopback connection

    +
    + After candidate gathering completes, the probe will try to establish + a peer-to-peer connection between two RTCPeerConnection objects + in this same page using only the gathered candidates. This tells + you whether the candidates can ACTUALLY connect or just + theoretically reachable. +
    +
    +
    + +
    +

    Verdict

    +
    +
    + +
    +

    Raw report

    +
    (empty)
    +
    + + + + diff --git a/Playground/scripts/build-monogame-runtime.mjs b/Playground/scripts/build-monogame-runtime.mjs index d8b5723..e731ef6 100644 --- a/Playground/scripts/build-monogame-runtime.mjs +++ b/Playground/scripts/build-monogame-runtime.mjs @@ -29,7 +29,7 @@ const targetDir = resolve(playgroundDir, 'public', 'runtime', 'monogame'); const legacyTargetDir = resolve(playgroundDir, 'public', 'monogame-runtime'); console.log('[build:monogame-runtime] dotnet publish', runtimeProject); -execSync(`dotnet publish "${runtimeProject}" -c Release`, { +execSync(`dotnet publish "${runtimeProject}" -c Release /p:FadeMonoGamePlatform=Web`, { stdio: 'inherit', }); diff --git a/Playground/scripts/smoke-collab.mjs b/Playground/scripts/smoke-collab.mjs new file mode 100644 index 0000000..40b4d82 --- /dev/null +++ b/Playground/scripts/smoke-collab.mjs @@ -0,0 +1,141 @@ +// Smoke test for the Live Session feature: boot two pages in the same +// Playwright browser context (BroadcastChannel works between them), have +// one host + one join via the Mock transport, then verify text typed on +// one page propagates to the other via the Yjs CRDT. +// +// Requires the dev server already running on port 5311 (`npm run dev`). + +import { chromium } from 'playwright'; + +const URL = 'http://localhost:5311/'; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const failures = []; + + async function makePage(label) { + const p = await context.newPage(); + p.on('console', (m) => { + const t = m.type(); + if (t === 'error' || t === 'warning') { + console.log(`[${label}] console.${t}:`, m.text()); + } + }); + p.on('pageerror', (e) => { + console.log(`[${label}] PAGE ERROR:`, e.message); + failures.push(`${label}: ${e.message}`); + }); + await p.goto(URL, { waitUntil: 'load' }); + await p.waitForFunction( + () => window.monaco != null && window.__fadeDockview != null, + { timeout: 30000 }, + ); + return p; + } + + const host = await makePage('host'); + const guest = await makePage('guest'); + + // Both pages: open the first file from the workspace tree. + for (const [label, p] of [['host', host], ['guest', guest]]) { + try { + await p.waitForSelector('#file-list li.file-row', { timeout: 15000 }); + await p.click('#file-list li.file-row >> nth=0'); + await p.waitForTimeout(500); + } catch (e) { + console.log(`[${label}] failed to open first file:`, e.message); + } + } + + async function openLiveSession(p, label) { + const viewBtn = p.locator('button:has-text("View")').first(); + await viewBtn.click(); + await p.waitForTimeout(200); + await p.locator('button.view-menu-item:has-text("Live Session")').first().click(); + await p.waitForTimeout(500); + const headerVisible = await p.locator('.fade-live-header:has-text("Live Session")').isVisible(); + if (!headerVisible) failures.push(`${label}: Live Session panel header not visible`); + } + + await openLiveSession(host, 'host'); + await openLiveSession(guest, 'guest'); + + // Host: start a session. + await host.locator('.fade-live-btn:has-text("Host a session")').click(); + await host.waitForSelector('.fade-live-modal'); + await host.fill('.fade-live-modal-field input[type="text"]', 'Alice'); + const transportSelect = host.locator('.fade-live-modal-field select'); + if (await transportSelect.count() > 0) { + await transportSelect.selectOption({ label: /Mock/ }); + } + await host.locator('.fade-live-modal button[type="submit"]').click(); + await host.waitForSelector('.fade-live-code-box', { timeout: 8000 }); + const roomId = (await host.locator('.fade-live-code-box').first().textContent())?.trim(); + console.log('[host] roomId =', roomId); + if (!roomId) failures.push('host: failed to obtain roomId'); + + // Guest: join. + await guest.locator('.fade-live-btn:has-text("Join a session")').click(); + await guest.waitForSelector('.fade-live-modal'); + const inputs = guest.locator('.fade-live-modal-field input'); + await inputs.nth(0).fill('Bob'); + await inputs.nth(1).fill(roomId ?? ''); + const gSel = guest.locator('.fade-live-modal-field select'); + if (await gSel.count() > 0) { + await gSel.selectOption({ label: /Mock/ }); + } + await guest.locator('.fade-live-modal button[type="submit"]').click(); + await guest.waitForSelector('.fade-live-banner-guest', { timeout: 8000 }); + + await host.waitForTimeout(800); + + const hostPeers = await host.locator('.fade-live-peer-name').allTextContents(); + const guestPeers = await guest.locator('.fade-live-peer-name').allTextContents(); + console.log('[host] peer list:', hostPeers); + console.log('[guest] peer list:', guestPeers); + if (!hostPeers.some((n) => /Bob/.test(n))) failures.push('host did not see Bob in peer list'); + if (!guestPeers.some((n) => /Alice/.test(n))) failures.push('guest did not see Alice in peer list'); + + // Type on the host, verify it appears on the guest. + await host.evaluate(() => { + const ed = window.monaco.editor.getEditors()[0]; + const model = ed.getModel(); + if (!model) throw new Error('no model'); + model.setValue('hello from alice\n' + model.getValue()); + }); + await host.waitForTimeout(800); + + const guestText = await guest.evaluate(() => { + const ed = window.monaco.editor.getEditors()[0]; + return ed?.getModel()?.getValue() ?? null; + }); + console.log('[guest] editor first 60 chars:', (guestText ?? '').slice(0, 60)); + if (!guestText || !guestText.startsWith('hello from alice')) { + failures.push(`guest did not receive host edit. text was: ${(guestText ?? '').slice(0, 80)}`); + } + + // Reverse direction. + await guest.evaluate(() => { + const ed = window.monaco.editor.getEditors()[0]; + const model = ed.getModel(); + model.setValue('typed by bob\n' + model.getValue()); + }); + await guest.waitForTimeout(800); + const hostText = await host.evaluate(() => { + const ed = window.monaco.editor.getEditors()[0]; + return ed?.getModel()?.getValue() ?? null; + }); + if (!hostText || !hostText.startsWith('typed by bob')) { + failures.push(`host did not receive guest edit. text was: ${(hostText ?? '').slice(0, 80)}`); + } + + await browser.close(); + + if (failures.length) { + console.error('\nFAILURES:'); + for (const f of failures) console.error(' -', f); + process.exit(1); + } + console.log('\nALL CHECKS PASSED'); +})(); diff --git a/Playground/scripts/smoke-game-overlay.mjs b/Playground/scripts/smoke-game-overlay.mjs new file mode 100644 index 0000000..40bf010 --- /dev/null +++ b/Playground/scripts/smoke-game-overlay.mjs @@ -0,0 +1,102 @@ +// Smoke test that verifies the game-stream overlay isn't shadowing the +// real game surface when no live session is active. After Phase 2A +// landed the overlay used `display: flex` in its inline style — that +// shadowed the [hidden] attribute's UA display:none, so the overlay was +// permanently visible and covered both the web iframe and the monogame +// Blazor root with a black box. This probe boots the page, asserts the +// overlay element has `hidden` set, has zero rendered size, and that the +// canvas underneath (web iframe or monogame root) is visible. +// +// Requires `npm run dev` running on localhost:5311. + +import { chromium } from 'playwright'; + +const URL = 'http://localhost:5311/'; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const failures = []; + page.on('pageerror', (e) => failures.push('pageerror: ' + e.message)); + + // We deliberately don't wait for full bootstrap (the dev env has a + // pre-existing LSP-worker init failure that aborts bootstrap before + // monaco/dockview attach). The bug we're verifying is in the static + // index.html — the overlay is in the DOM as soon as the page parses. + await page.goto(URL, { waitUntil: 'domcontentloaded' }); + // Give CSS a beat to apply. + await page.waitForTimeout(500); + + const overlayInfo = await page.evaluate(() => { + const overlay = document.getElementById('game-stream-overlay'); + if (!overlay) return { exists: false }; + const rect = overlay.getBoundingClientRect(); + const cs = window.getComputedStyle(overlay); + return { + exists: true, + hidden: overlay.hidden, + display: cs.display, + width: rect.width, + height: rect.height, + zIndex: cs.zIndex, + }; + }); + console.log('overlay:', overlayInfo); + if (!overlayInfo.exists) failures.push('overlay element missing entirely'); + else { + if (!overlayInfo.hidden) failures.push('overlay.hidden is not true at idle'); + if (overlayInfo.display !== 'none') failures.push(`overlay computed display is "${overlayInfo.display}", expected "none"`); + if (overlayInfo.width !== 0 || overlayInfo.height !== 0) { + failures.push(`overlay is taking ${overlayInfo.width}x${overlayInfo.height} space; should be 0x0 when hidden`); + } + } + + // The monogame Blazor root or the web iframe should be visible + // underneath. They're siblings inside the game panel-cell. + const surfacesInfo = await page.evaluate(() => { + const mg = document.getElementById('mg-blazor-root'); + const webHost = document.getElementById('web-preview-host'); + const mgCs = mg ? window.getComputedStyle(mg) : null; + const webCs = webHost ? window.getComputedStyle(webHost) : null; + return { + mgPresent: !!mg, + mgDisplay: mgCs?.display ?? null, + webPresent: !!webHost, + webDisplay: webCs?.display ?? null, + }; + }); + console.log('surfaces:', surfacesInfo); + // At least one of the two surfaces should be rendered (display !== 'none'). + const anyVisible = + (surfacesInfo.mgPresent && surfacesInfo.mgDisplay !== 'none') || + (surfacesInfo.webPresent && surfacesInfo.webDisplay !== 'none'); + if (!anyVisible) failures.push('neither game surface is visible (mg + web both display:none)'); + + // Toggle the overlay visible and confirm display flips to flex. + // Mirrors what main.ts's showGameStreamOverlay() does when a remote + // peer starts running — flips hidden off, then sets the banner text. + // If the cascade is wrong, display would stay `none` and we'd never + // see the streamed frames. + const toggledInfo = await page.evaluate(() => { + const overlay = document.getElementById('game-stream-overlay'); + if (!overlay) return null; + overlay.hidden = false; + const cs = window.getComputedStyle(overlay); + return { display: cs.display, position: cs.position }; + }); + console.log('toggled-visible:', toggledInfo); + if (!toggledInfo) failures.push('overlay missing on toggle test'); + else if (toggledInfo.display !== 'flex') { + failures.push(`after setting hidden=false, display is "${toggledInfo.display}", expected "flex"`); + } + + await browser.close(); + + if (failures.length) { + console.error('\nFAILURES:'); + for (const f of failures) console.error(' -', f); + process.exit(1); + } + console.log('\nALL CHECKS PASSED — game overlay is correctly hidden at idle'); +})(); diff --git a/Playground/scripts/test-sharing-signin.html b/Playground/scripts/test-sharing-signin.html index 7611f77..6eeeeec 100644 --- a/Playground/scripts/test-sharing-signin.html +++ b/Playground/scripts/test-sharing-signin.html @@ -57,7 +57,18 @@

    Sharing — sign-in + adapter smoke test