diff --git a/CLAUDE.md b/CLAUDE.md index 83eb479..05ec3ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ package format · 0004 compiling procedure bodies · 0005 mutable pairs · 0006 as abnormal passes · 0007 identity for derived combiners · 0008 caching compiled bodies (proposed, deliberately not built) · 0009 editor tooling from the runtime outward (`ik check --json`, environment enumeration, the `ik lsp` language -server, and reader recovery are its phases 1–4). +server, reader recovery, and the environment/profile views are its phases 1–5). They record measurements and rejected options, not just decisions. When a prediction in one turns out wrong, correct it in place rather than leaving it — several carry diff --git a/IronKernel.Tests/LspTests.fs b/IronKernel.Tests/LspTests.fs index 847d2ac..90defff 100644 --- a/IronKernel.Tests/LspTests.fs +++ b/IronKernel.Tests/LspTests.fs @@ -231,3 +231,36 @@ let ``a broken buffer still gets tokens defines and every error`` () = 0; 7; 3; 0; 0 // vau 2; 1; 5; 0; 0 ], // twice (use, on the completed trailing form) data) + +[] +let ``the environment request reports capabilities and frames`` () = + let frames = + session + [ request 1 "initialize" "{}" + didOpen "(define twice (vau (x) e x))\n" + request 2 "ironkernel/environment" + """{"textDocument":{"uri":"file:///probe.ikr"}}""" ] + let report = resultOf 2 frames + let capabilities = + report.GetProperty("capabilities").EnumerateArray() + |> Seq.map (fun value -> value.GetString()) + |> List.ofSeq + Assert.Contains("host-io", capabilities) + let reportFrames = report.GetProperty("frames").EnumerateArray() |> List.ofSeq + // The buffer frame comes first and carries the vau define as an operative. + let buffer = reportFrames.Head + Assert.Equal("buffer", buffer.GetProperty("label").GetString()) + let bufferSymbols = + buffer.GetProperty("symbols").EnumerateArray() + |> Seq.map (fun symbol -> + symbol.GetProperty("name").GetString(), symbol.GetProperty("class").GetString()) + |> List.ofSeq + Assert.Contains(("twice", "operative"), bufferSymbols) + // Environment frames follow; somewhere in them `+` is an applicative + // carrying its certified contract as detail. + let plus = + reportFrames.Tail + |> List.collect (fun frame -> frame.GetProperty("symbols").EnumerateArray() |> List.ofSeq) + |> List.find (fun symbol -> symbol.GetProperty("name").GetString() = "+") + Assert.Equal("applicative", plus.GetProperty("class").GetString()) + Assert.Contains("certified", plus.GetProperty("detail").GetString()) diff --git a/IronKernel/LanguageServer.fs b/IronKernel/LanguageServer.fs index 1ba11b8..263bbb0 100644 --- a/IronKernel/LanguageServer.fs +++ b/IronKernel/LanguageServer.fs @@ -421,6 +421,67 @@ module LanguageServer = previousColumn <- column yield! [ deltaLine; deltaColumn; length; int64 tokenType; 0L ] ] + /// `ironkernel/environment`: the inspector's data. Frame structure is + /// host-side by design -- phase 2 deliberately exposes no parent + /// environments to Kernel code, and the server holds the records + /// in-process (ADR 0009 phases 2 and 5). + let private renderCapability = function + | RawClrInterop -> "raw-clr-interop" + | HostIO -> "host-io" + | SourceLoading -> "source-loading" + | HostAsync -> "host-async" + | GeneratedClr name -> sprintf "(generated-clr \"%s\")" name + + let private handleEnvironment output session (id: JsonElement) (uri: string option) = + respond output id (fun writer -> + writer.WriteStartObject() + writer.WritePropertyName "capabilities" + writer.WriteStartArray() + for capability in Capabilities.ofEnvironment session.env |> Set.toList do + writer.WriteStringValue(renderCapability capability) + writer.WriteEndArray() + writer.WritePropertyName "frames" + writer.WriteStartArray() + let writeSymbol (name: string) symbolClass (value: LispVal option) = + writer.WriteStartObject() + writer.WriteString("name", name) + writer.WriteString("class", renderClass symbolClass) + match value |> Option.bind Contracts.tryGetContract with + | Some contract -> writer.WriteString("detail", renderContract contract) + | None -> () + writer.WriteEndObject() + // The buffer's defines are the innermost frame the reader sees. + match uri with + | Some uri -> + let text = + match session.documents.TryGetValue uri with + | true, value -> value + | _ -> "" + writer.WriteStartObject() + writer.WriteString("label", "buffer") + writer.WritePropertyName "symbols" + writer.WriteStartArray() + for name, symbolClass in Map.toList (definesIn uri text) do + writeSymbol name symbolClass None + writer.WriteEndArray() + writer.WriteEndObject() + | None -> () + SymbolTable.reachableFrames session.env + |> List.iteri (fun index record -> + writer.WriteStartObject() + writer.WriteString( + "label", + sprintf "frame %d (%d bindings)" (index + 1) record.bindings.Count) + writer.WritePropertyName "symbols" + writer.WriteStartArray() + for name in record.bindings.Keys |> Seq.sort do + let value = record.bindings.[name].state.value + writeSymbol name (classifyValue value) (Some value) + writer.WriteEndArray() + writer.WriteEndObject()) + writer.WriteEndArray() + writer.WriteEndObject()) + let private handleSemanticTokens output session (id: JsonElement) uri = let text = match session.documents.TryGetValue (uri: string) with @@ -547,6 +608,18 @@ module LanguageServer = handleHover output session id (textDocumentUri parameters) line character | "textDocument/semanticTokens/full" -> handleSemanticTokens output session id (textDocumentUri parameters) + | "ironkernel/environment" -> + let uri = + match parameters.ValueKind with + | JsonValueKind.Object -> + match parameters.TryGetProperty "textDocument" with + | true, textDocument -> + match textDocument.TryGetProperty "uri" with + | true, value -> Some(value.GetString()) + | _ -> None + | _ -> None + | _ -> None + handleEnvironment output session id uri | other when hasId -> respondError output id -32601 (sprintf "method not found: %s" other) | _ -> () diff --git a/docs/adr/0009-editor-tooling-from-the-runtime-outward.md b/docs/adr/0009-editor-tooling-from-the-runtime-outward.md index bd3dc71..027ef33 100644 --- a/docs/adr/0009-editor-tooling-from-the-runtime-outward.md +++ b/docs/adr/0009-editor-tooling-from-the-runtime-outward.md @@ -1,6 +1,7 @@ # ADR 0009: Editor tooling grows from the runtime outward -Status: Accepted — phases 1 through 4 implemented +Status: Accepted — phases 1 through 5 implemented (phase 5's third item +deferred behind phase 6) ## Decision @@ -179,10 +180,37 @@ cache is deleted, completion and semantic tokens run against the actual buffer mid-edit, `ik check` reports every broken region in a file, and the server publishes every error instead of the first. -**Phase 5 — the visible surfaces.** Environment inspector (a UI over phase -2), a profile status-bar item that also reads the project's own ``, -compiled-vs-residual highlighting (the IR's `CLocated` forms already mark -what the analyzer touched). +**Phase 5 — the visible surfaces.** *Done in two parts of three; the third +is corrected and deferred, not merely postponed.* + +The environment inspector is a tree view over a custom +`ironkernel/environment` request: the capability set, the buffer's defines +as the innermost frame, then every reachable frame with each binding +classified and its contract as detail. Frame structure stays host-side +exactly as phase 2 decided — the view knows the parents, Kernel code still +cannot reach them. "Eval in this env" did not ship: it needs the phase-6 +session protocol, not a tree view. + +The profile status bar shows the effective profile and, when the nearest +`.ikproj` declares a different ``, says so — the +extension's own `--profile` has silently overridden the project's +declaration since the profile setting existed, and surfacing that mismatch +is most of the item's value. Selecting a profile restarts the language +server, so the authority the status bar reports is the authority the +server's session environment actually carries. + +Compiled-vs-residual highlighting is the correction. The plan's aside — +"the IR's `CLocated` forms already mark what the analyzer touched" — was +true and beside the point: `CResidual` almost never occurs (dotted lists +and oddball values), because the hybrid boundary is not an IR node. Bodies +compile on first application (ADR 0004) and the performance story lives in +binding-guard specializations with interpreted fallbacks (`CGuarded`), so +"compiled vs residual" is a *runtime* property a static walk cannot show +truthfully — and located spans exist only on the special-form spine anyway. +An honest coverage view needs runtime instrumentation, which belongs with +phase 6's session protocol. Deferred with that dependency named, so the +"nearly free" framing does not get re-derived from the `CLocated` line +above. **Phase 6 — debug adapter.** Requires a persistent session protocol that does not exist — the REPL cannot even separate errors from values today — so the diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 1c7a552..470eebd 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -15,6 +15,12 @@ The extension never implements a second evaluator in JavaScript. classifies operatives/applicatives by *resolution* — your own `vau` definitions get the operative color (disable with `ironkernel.languageServer.enabled`) +- **Environment view** (explorer sidebar): the session's capability set and + every frame's bindings — your buffer's defines first — with contracts as + tooltips, live from the language server +- **Profile status bar**: the effective capability profile at a glance, + flagging when it overrides the project's own ``; click + to switch (the language server restarts to match) - **Run Current File** and **Compile Current File to IKC** commands - **Run Project** and **Build Project** for `.ikproj` (nearest project, picker, or explorer context menu) - Check on save: `ik check --json` runs on saved `.ikr` files and publishes @@ -67,6 +73,8 @@ host authority available to editor commands and playground runs. - `IronKernel: Compile Current File to IKC` - `IronKernel: Run Project` — `run ` (plus `ironkernel.runArgs`) - `IronKernel: Build Project` — `build ` → `bin/*.ikc` +- `IronKernel: Refresh Environment View` +- `IronKernel: Select Capability Profile` - `IronKernel: Open Playground` - `IronKernel: Show Output` diff --git a/editors/vscode/package.json b/editors/vscode/package.json index c1f81a4..e4f7625 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -36,7 +36,10 @@ "onCommand:ironkernel.compileFile", "onCommand:ironkernel.runProject", "onCommand:ironkernel.buildProject", - "onCommand:ironkernel.openPlayground" + "onCommand:ironkernel.openPlayground", + "onCommand:ironkernel.refreshEnvironment", + "onCommand:ironkernel.selectProfile", + "onView:ironkernelEnvironment" ], "capabilities": { "untrustedWorkspaces": { @@ -122,6 +125,17 @@ "command": "ironkernel.showOutput", "title": "IronKernel: Show Output", "category": "IronKernel" + }, + { + "command": "ironkernel.refreshEnvironment", + "title": "IronKernel: Refresh Environment View", + "category": "IronKernel", + "icon": "$(refresh)" + }, + { + "command": "ironkernel.selectProfile", + "title": "IronKernel: Select Capability Profile", + "category": "IronKernel" } ], "menus": { @@ -155,6 +169,13 @@ "when": "resourceExtname == .ikproj && isWorkspaceTrusted", "group": "ironkernel@2" } + ], + "view/title": [ + { + "command": "ironkernel.refreshEnvironment", + "when": "view == ironkernelEnvironment", + "group": "navigation" + } ] }, "configurationDefaults": { @@ -283,6 +304,23 @@ ], "pattern": "$ironkernel" } + ], + "views": { + "explorer": [ + { + "id": "ironkernelEnvironment", + "name": "IronKernel Environment", + "when": "ironkernel.environmentAvailable", + "icon": "$(symbol-namespace)", + "contextualTitle": "IronKernel Environment" + } + ] + }, + "viewsWelcome": [ + { + "view": "ironkernelEnvironment", + "contents": "The IronKernel language server supplies this view. Open an .ikr file in a trusted workspace to populate it." + } ] }, "scripts": { diff --git a/editors/vscode/src/environmentView.ts b/editors/vscode/src/environmentView.ts new file mode 100644 index 0000000..2528868 --- /dev/null +++ b/editors/vscode/src/environmentView.ts @@ -0,0 +1,124 @@ +import * as vscode from "vscode"; + +export interface EnvironmentSymbol { + name: string; + class: string; + detail?: string; +} + +export interface EnvironmentFrame { + label: string; + symbols: EnvironmentSymbol[]; +} + +export interface EnvironmentReport { + capabilities: string[]; + frames: EnvironmentFrame[]; +} + +export type EnvironmentRequester = ( + uri: string | undefined +) => Promise; + +export type EnvironmentNode = + | { type: "capabilities" } + | { type: "capability"; label: string } + | { type: "frame"; frame: EnvironmentFrame } + | { type: "symbol"; symbol: EnvironmentSymbol }; + +function symbolIcon(symbolClass: string): vscode.ThemeIcon { + switch (symbolClass) { + case "operative": + return new vscode.ThemeIcon("symbol-keyword"); + case "applicative": + return new vscode.ThemeIcon("symbol-function"); + default: + return new vscode.ThemeIcon("symbol-variable"); + } +} + +/** + * The environment inspector: capabilities and the frame chain, served by the + * language server's ironkernel/environment request. Frame structure is + * host-side by design — the runtime deliberately exposes no parent + * environments to Kernel code. + */ +export class EnvironmentViewProvider implements vscode.TreeDataProvider { + private report: EnvironmentReport | undefined; + private readonly emitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.emitter.event; + + constructor(private readonly request: EnvironmentRequester) {} + + async refresh(uri: string | undefined): Promise { + this.report = await this.request(uri); + this.emitter.fire(); + } + + getTreeItem(node: EnvironmentNode): vscode.TreeItem { + switch (node.type) { + case "capabilities": { + const count = this.report?.capabilities.length ?? 0; + const item = new vscode.TreeItem( + `Capabilities (${count})`, + count > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None + ); + item.iconPath = new vscode.ThemeIcon("shield"); + item.tooltip = + count > 0 + ? "Host authority carried by the session environment" + : "No host authority: the minimal profile"; + return item; + } + case "capability": { + const item = new vscode.TreeItem(node.label, vscode.TreeItemCollapsibleState.None); + item.iconPath = new vscode.ThemeIcon("key"); + return item; + } + case "frame": { + const item = new vscode.TreeItem( + node.frame.label, + node.frame.symbols.length > 0 + ? node.frame.label === "buffer" + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None + ); + item.iconPath = new vscode.ThemeIcon("symbol-namespace"); + item.description = `${node.frame.symbols.length}`; + return item; + } + case "symbol": { + const item = new vscode.TreeItem(node.symbol.name, vscode.TreeItemCollapsibleState.None); + item.iconPath = symbolIcon(node.symbol.class); + item.description = node.symbol.class; + if (node.symbol.detail) { + item.tooltip = node.symbol.detail; + } + return item; + } + } + } + + getChildren(node?: EnvironmentNode): EnvironmentNode[] { + if (!this.report) { + return []; + } + if (!node) { + return [ + { type: "capabilities" }, + ...this.report.frames.map((frame): EnvironmentNode => ({ type: "frame", frame })) + ]; + } + switch (node.type) { + case "capabilities": + return this.report.capabilities.map((label) => ({ type: "capability", label })); + case "frame": + return node.frame.symbols.map((symbol) => ({ type: "symbol", symbol })); + default: + return []; + } + } +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 8e0b2c7..b4ddc46 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -12,7 +12,9 @@ import { type CheckDiagnostic, type CheckLocation } from "./diagnostics.js"; +import { EnvironmentViewProvider, type EnvironmentReport } from "./environmentView.js"; import { PlaygroundPanel } from "./playgroundPanel.js"; +import { ProfileStatus } from "./profileStatus.js"; import { findIkprojWalkingUp, isIkprojPath, rankIkProjects } from "./projects.js"; const timeoutMs = 120000; @@ -22,9 +24,68 @@ export function activate(context: vscode.ExtensionContext): void { const diagnostics = vscode.languages.createDiagnosticCollection("ironkernel"); context.subscriptions.push(output, diagnostics); - void startLanguageServer(output); + const profileStatus = new ProfileStatus(); + const environmentView = new EnvironmentViewProvider(async (uri) => { + if (!languageClient) { + return undefined; + } + try { + return await languageClient.sendRequest( + "ironkernel/environment", + uri ? { textDocument: { uri } } : {} + ); + } catch { + return undefined; + } + }); + const refreshEnvironment = (): Promise => { + const editor = vscode.window.activeTextEditor; + const uri = + editor && editor.document.languageId === "ironkernel" + ? editor.document.uri.toString() + : undefined; + return environmentView.refresh(uri); + }; + const afterServerChange = (): void => { + void vscode.commands.executeCommand( + "setContext", + "ironkernel.environmentAvailable", + languageClient !== undefined + ); + void refreshEnvironment(); + }; + void startLanguageServer(output).then(afterServerChange); + void profileStatus.update(); context.subscriptions.push( - vscode.workspace.onDidGrantWorkspaceTrust(() => void startLanguageServer(output)) + profileStatus, + vscode.window.registerTreeDataProvider("ironkernelEnvironment", environmentView), + vscode.commands.registerCommand("ironkernel.refreshEnvironment", refreshEnvironment), + vscode.commands.registerCommand("ironkernel.selectProfile", async () => { + const picked = await vscode.window.showQuickPick(["minimal", "safe", "unrestricted"], { + placeHolder: "Capability profile for IronKernel commands and the language server" + }); + if (picked) { + await vscode.workspace + .getConfiguration("ironkernel") + .update("profile", picked, vscode.ConfigurationTarget.Workspace); + } + }), + vscode.window.onDidChangeActiveTextEditor(() => { + void profileStatus.update(); + void refreshEnvironment(); + }), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("ironkernel.profile")) { + void profileStatus.update(); + // The server was launched with the old --profile; restart it so the + // session environment carries the authority the status bar shows. + void restartLanguageServer(output).then(afterServerChange); + } + }), + vscode.workspace.onDidGrantWorkspaceTrust(() => { + void startLanguageServer(output).then(afterServerChange); + void profileStatus.update(); + }) ); context.subscriptions.push( @@ -39,6 +100,10 @@ export function activate(context: vscode.ExtensionContext): void { } }), vscode.workspace.onDidSaveTextDocument((document) => { + if (document.languageId === "ironkernel" || isIkprojPath(document.uri.fsPath)) { + void profileStatus.update(); + void refreshEnvironment(); + } if ( document.languageId === "ironkernel" && vscode.workspace.isTrusted && @@ -228,6 +293,19 @@ async function saveIronKernelDocumentsNear(projectPath: string): Promise { let languageClient: LanguageClient | undefined; +async function restartLanguageServer(output: vscode.OutputChannel): Promise { + const client = languageClient; + languageClient = undefined; + if (client) { + try { + await client.stop(); + } catch { + // A server that already died still gets its replacement. + } + } + await startLanguageServer(output); +} + async function startLanguageServer(output: vscode.OutputChannel): Promise { if (languageClient !== undefined || !vscode.workspace.isTrusted) { return; diff --git a/editors/vscode/src/profileStatus.ts b/editors/vscode/src/profileStatus.ts new file mode 100644 index 0000000..531002e --- /dev/null +++ b/editors/vscode/src/profileStatus.ts @@ -0,0 +1,58 @@ +import { promises as fs } from "node:fs"; +import * as vscode from "vscode"; +import { findIkprojWalkingUp, isIkprojPath, parseProjectProfile } from "./projects.js"; + +export class ProfileStatus { + private readonly item: vscode.StatusBarItem; + + constructor() { + this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + this.item.command = "ironkernel.selectProfile"; + } + + dispose(): void { + this.item.dispose(); + } + + async update(): Promise { + const editor = vscode.window.activeTextEditor; + const document = editor?.document; + const relevant = + document !== undefined && + (document.languageId === "ironkernel" || isIkprojPath(document.uri.fsPath)); + if (!document || !relevant) { + this.item.hide(); + return; + } + + const profile = vscode.workspace + .getConfiguration("ironkernel", document.uri) + .get("profile", "unrestricted"); + + let projectProfile: string | undefined; + try { + const ikproj = isIkprojPath(document.uri.fsPath) + ? document.uri.fsPath + : await findIkprojWalkingUp(document.uri.fsPath); + if (ikproj) { + projectProfile = parseProjectProfile(await fs.readFile(ikproj, "utf8")); + } + } catch { + // No readable project; the setting alone is the story. + } + + if (projectProfile && projectProfile !== profile) { + this.item.text = `$(shield) IK: ${profile} (project: ${projectProfile})`; + this.item.tooltip = + `IronKernel capability profile: ${profile} (ironkernel.profile setting).\n` + + `The project declares '${projectProfile}', but editor commands pass ` + + `--profile ${profile}, which overrides it. Click to change.`; + } else { + this.item.text = `$(shield) IK: ${profile}`; + this.item.tooltip = + `IronKernel capability profile: ${profile}. Applies to run/compile ` + + `commands, the playground, and the language server. Click to change.`; + } + this.item.show(); + } +} diff --git a/editors/vscode/src/projects.ts b/editors/vscode/src/projects.ts index cef0564..8fdf41e 100644 --- a/editors/vscode/src/projects.ts +++ b/editors/vscode/src/projects.ts @@ -71,3 +71,14 @@ export function rankIkProjects(activePath: string | undefined, projects: string[ return left.localeCompare(right); }); } + +/** + * The project's own declared profile, if the .ikproj carries one. The + * extension always passes --profile from the setting, which overrides this; + * the status bar surfaces the mismatch instead of leaving it a surprise. + */ +export function parseProjectProfile(xml: string): string | undefined { + const match = + /\s*(minimal|safe|unrestricted)\s*<\/IronKernelProfile>/.exec(xml); + return match?.[1]; +} diff --git a/editors/vscode/test/profileStatus.test.ts b/editors/vscode/test/profileStatus.test.ts new file mode 100644 index 0000000..2cdd20d --- /dev/null +++ b/editors/vscode/test/profileStatus.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { parseProjectProfile } from "../src/projects.js"; + +describe("parseProjectProfile", () => { + it("reads the declared profile", () => { + expect( + parseProjectProfile( + "safe" + ) + ).toBe("safe"); + }); + + it("tolerates whitespace and returns undefined for junk or absence", () => { + expect( + parseProjectProfile("\n minimal\n") + ).toBe("minimal"); + expect(parseProjectProfile("root")).toBeUndefined(); + expect(parseProjectProfile("")).toBeUndefined(); + }); +});