Why Osy# · Chapter 07 · The editor
Your editor is never wrong about your app.
There is no second implementation of the language for the editor to be wrong with. Hover, rename, go-to-definition and the debugger come from the resolver that compiles your app, over the whole workspace. The squiggle under your cursor and the error from the build are the same sentence, and they cannot disagree.
- a grammar written twice
- a separate language server
- a debugger that stops at the seam
$ osy vscode install$ osy init kanban ✓ .vscode/extensions.json ✓ .vscode/launch.json $ osy vscode install // installs the bundled extension into VS Code // (or Cursor, VSCodium, Windsurf). The language // server inside it IS the compiler's resolver.
01
The thing answering your editor is the compiler
Not a grammar that approximates it, and not a second implementation that drifts. The language server runs the same front-end osy validate runs, over the whole workspace — so the squiggle under your cursor and the error from the build are the same sentence, and they cannot disagree.
What it answers, today

var want: string is an inlay
hint; 0 references is a code lens; the status bar says Osy#, 0 errors.02
The editor knows which half of the program each line is
Osy# makes the client/server seam invisible on purpose — you write ordinary code across it, and the compiler decides where each body runs. That is right for correctness and it hides the one thing you still have to know: this line is a network round trip. So the editor draws it.
Ask the editor about a file and it answers for every call in it — the side that call runs on, and the side of the body it sits in. Here is an ordinary game loop. Nothing in it declares a side; the compiler worked all of this out.
component Game() { int score = 0; on every (TimeSpan.FromMilliseconds(16)) { 1 if (Keyboard.Down(Space)) { score = score + 1; } SaveScore(score); } render { Box(keys: [Space]) { Text($"{score}"); } } }
line 16 Keyboard.Down side=client body=server crosses=true line 17 SaveScore side=server body=server crosses=false
Captured from the real language server. The mark is on the line you would never suspect — and NOT on the one that looks expensive.
Read that the other way round and it is the whole argument for the margin.
SaveScore writes to the database and does not cross: the compiler put the whole tick body
on the server, so by the time that call runs it is already there. Keyboard.Down reads the
browser's held keys — a browser object — so it hands off and comes back. Sixty times a second. Nothing
in the source says either of those things, and no amount of reading the file tells you.
03
Tests in the tree, and one keystroke to stop inside one
The Test Explorer is FILE → FIXTURE → TEST, discovered by the language server. A [TestFixture] is a group rather than a test — it seeds the branch the tests under it fork from. A [Skip("reason")] test appears with its reason, because a parked test is a visible reminder that a gap exists.
From the editor, without a terminal

osy test --test takes.04
A debugger that pauses inside your own language
Not a JavaScript debugger, and not a .NET one showing you the interpreter's frames. It stops on the line you wrote, in the file you wrote it in, with your own values in the pane.
→ initialize { "supportsConfigurationDoneRequest": true, "supportsEvaluateForHovers": true, "supportsSetVariable": true, "supportsSetExpressionRequest": true, "supportsExceptionInfoRequest": true, "supportsConditionalBreakpoints": true, "supportsHitConditionalBreakpoints": true, "supportsLogPoints": true, "exceptionBreakpointFilters": [ "All Exceptions", "Uncaught Exceptions" ] } → setBreakpoints model/orders.osy:9 { "breakpoints": [ { "verified": true, "line": 9 } ] } → launch tests/orders.test.osy::a_big_order_gets_its_discount event stopped { "reason": "breakpoint", "threadId": 1 } → stackTrace Discount orders.osy:9 call:Discount orders.osy:14 Place orders.osy:14 call:Place a_big_order_gets_its_discount:4 a_big_order_gets_its_discount a_big_order_gets_its_discount:4 → variables total = 200 → evaluate total * 2 { "result": "400", "type": "Decimal" }
Captured by driving osy debug-test the way the extension drives it.
The stack is Osy# frames — your function, the call site that reached it, and the test that started
the whole thing.

total = 200 in Locals — and the same value rendered inline beside the code, on the lines
that have actually run.05
One call stack, across the network — and a run you can rewind
This is the part that has no equivalent elsewhere, because nowhere else is one program. A durable flow IS the debuggee: the same handle addresses a run that is executing right now and one that parked two days ago waiting for a person.
What attaching to a flow gives you
evaluate.What happens when you change it
Editor changes, and what they cost
Each of these is a thing you would do on a Tuesday. The verdict is the compiler's, not a convention's.
| You do this | Verdict | Because |
|---|---|---|
| Rename a field the whole app uses | compiles | Rename from the editor. It is semantic, and the diagnostics that follow come from the compiler, so the list you get is the list the build would give you. |
| Move a function to another file | compiles | Nothing to update. The workspace is one model — the gutter, the tokens and the diagnostics all see across files. |
| Work without the extension | compiles | Everything here has a CLI twin: osy check, osy test, osy debug-test, osy model. The editor is a front end for the same answers, which is why an agent and a person get the same ones. |
| Use an editor that is not VS Code | compiles | The server speaks LSP over stdio and the debugger speaks DAP. Neither knows what is on the other end — the extension itself contains no protocol logic. |
| Point the extension at a different compiler | refused | There is nothing to point. The extension ships the server, so the editor and the build cannot be two versions. |