diff --git a/packages/affine-vscode/README.adoc b/packages/affine-vscode/README.adoc index 315c636..d8eb666 100644 --- a/packages/affine-vscode/README.adoc +++ b/packages/affine-vscode/README.adoc @@ -36,17 +36,43 @@ adapter._extraImports = () => makeBindings(vscode, lc, () => instance); The adapter implements every `extern fn` declared in: -* `stdlib/Vscode.affine` — 11 bindings covering vscode.commands.registerCommand, +* `stdlib/Vscode.affine` — initial set (issue #35 Phase 2): commands.registerCommand, workspace.getConfiguration / createFileSystemWatcher, - window.showErrorMessage / showWarningMessage / showInformationMessage, + window.{showError,showWarning,showInformation}Message, window.createTerminal + terminal.show / sendText, - ExtensionContext.subscriptions.push, and window.activeTextEditor. + ExtensionContext.subscriptions.push, window.activeTextEditor, + editorActive{FilePath,LanguageId}, workspaceConfigGet{Bool,String}, + consoleLog, execSync, and three string helpers. ++ +Expanded 2026-05-11 for the rsr-certifier port (issue #64): +workspaceFolderFirstPath / workspaceRootUri, uriFromPath / uriJoinPath / uriPath, +fsWriteFile, openTextDocument / showTextDocument, +createStatusBarItem + statusBarItem.{setText,setTooltip,setCommand,setBackgroundColorTheme,show,hide,asDisposable}, +createDiagnosticCollection + diagnosticCollection.{clear,setForUri,asDisposable}, +createWebviewPanel + webviewPanel.{setHtml,asDisposable}, +clipboardWriteText, onDidSaveTextDocument, +pathBasename / pathJoin / processPlatform, and extensionAbsolutePath. + * `stdlib/VscodeLanguageClient.affine` — 3 bindings covering new LanguageClient(...) / start() / stop(). +== Deliberate omissions + +Two API shapes are not bound because they cannot be expressed in the +current synchronous extern-call ABI without an async-extern hookup: + +* `vscode.window.withProgress(opts, async task)` — the second arg is an + async Thenable-returning task. +* `LanguageClient.sendRequest(method, params)` — returns a Thenable. + +Extensions that need either should fall back to shelling out to a CLI +via `Vscode::createTerminal` / `Vscode::execSync` until an async-extern +ABI lands. + == Design notes -* All host objects (Disposable, Terminal, ExtensionContext, ...) are +* All host objects (Disposable, Terminal, ExtensionContext, StatusBarItem, + DiagnosticCollection, WebviewPanel, Uri, TextDocument, ...) are represented as opaque integer handles on both sides of the FFI. The adapter maintains a JS-side handle table. * String args are passed across as i32 pointers into the wasm linear @@ -55,9 +81,14 @@ The adapter implements every `extern fn` declared in: * Wasm function-pointer args (e.g. command handlers) come in as `__indirect_function_table` indices; the adapter wraps each in a JS thunk that re-enters the wasm module on invocation. +* Diagnostics avoid a Range+Diagnostic struct FFI by accepting a JSON + array: `[{startLine,startCol,endLine,endCol,message,severity}, ...]`. + The adapter parses it and constructs the vscode objects. == Status -Phase 2 — bindings landed. Phase 3 (`editors/vscode/src/extension.ts` → -`editors/vscode/src/extension.affine` rewrite) is the next milestone. Phase 4 -sweeps the rattlescript-face copy. +Phase 2 — bindings landed. The pilot affinescript port (issue #63) and +external-extension ports (issue #64: my-lang, rsr-certifier) consume +this adapter; the long-term plan is to publish it to npm so consumers +do not have to vendor `mod.js`. Phase 4 (rattlescript-face sweep) still +to do. diff --git a/packages/affine-vscode/mod.js b/packages/affine-vscode/mod.js index 69c74c9..e7dca03 100644 --- a/packages/affine-vscode/mod.js +++ b/packages/affine-vscode/mod.js @@ -150,6 +150,174 @@ module.exports = function makeVscodeBindings(vscode, lcModule, hostShim) { const out = s.endsWith(suffix) ? s.slice(0, -suffix.length) + replacement : s; return reg(out); }, + stringIsEmpty: (sPtr) => readString(sPtr).length === 0 ? 1 : 0, + + // ── Workspace ─────────────────────────────────────────────────── + workspaceFolderFirstPath: () => { + const folders = vscode.workspace.workspaceFolders; + const first = folders && folders[0]; + return reg(first ? first.uri.fsPath : ""); + }, + workspaceRootUri: () => { + const folders = vscode.workspace.workspaceFolders; + const first = folders && folders[0]; + return first ? reg(first.uri) : 0; + }, + + // ── URI / file-system / text documents ───────────────────────── + uriFromPath: (pathPtr) => reg(vscode.Uri.file(readString(pathPtr))), + uriJoinPath: (baseHandle, segPtr) => { + const base = get(baseHandle); + if (!base) return 0; + return reg(vscode.Uri.joinPath(base, readString(segPtr))); + }, + uriPath: (uHandle) => { + const u = get(uHandle); + return reg(u ? u.fsPath : ""); + }, + fsWriteFile: (uHandle, contentPtr) => { + const u = get(uHandle); + if (!u) return 1; + try { + // Fire-and-forget the Thenable. The host serialises FS ops so + // a subsequent openTextDocument on the same URI sees the file. + vscode.workspace.fs.writeFile(u, Buffer.from(readString(contentPtr))); + return 0; + } catch (e) { + return 1; + } + }, + openTextDocument: (uHandle) => { + const u = get(uHandle); + if (!u) return 0; + // openTextDocument returns a Thenable. The synchronous + // FFI returns a handle to the Thenable itself; showTextDocument is + // also Thenable-returning and chains via vscode's internal queue, + // so this works in practice for the open-then-show pattern. + return reg(vscode.workspace.openTextDocument(u)); + }, + showTextDocument: (dHandle) => { + const d = get(dHandle); + if (!d) return 1; + // If `d` is itself a Thenable, vscode unwraps it. + Promise.resolve(d).then((doc) => vscode.window.showTextDocument(doc)); + return 0; + }, + + // ── Status bar ───────────────────────────────────────────────── + createStatusBarItem: (alignment, priority) => { + const align = alignment === 1 + ? vscode.StatusBarAlignment.Right + : vscode.StatusBarAlignment.Left; + return reg(vscode.window.createStatusBarItem(align, priority)); + }, + statusBarItemSetText: (sHandle, tPtr) => { + const s = get(sHandle); + if (s) s.text = readString(tPtr); + return 0; + }, + statusBarItemSetTooltip: (sHandle, tPtr) => { + const s = get(sHandle); + if (s) s.tooltip = readString(tPtr); + return 0; + }, + statusBarItemSetCommand: (sHandle, cPtr) => { + const s = get(sHandle); + if (s) s.command = readString(cPtr); + return 0; + }, + statusBarItemSetBackgroundColorTheme: (sHandle, cPtr) => { + const s = get(sHandle); + if (!s) return 0; + const name = readString(cPtr); + s.backgroundColor = name.length === 0 ? undefined : new vscode.ThemeColor(name); + return 0; + }, + statusBarItemShow: (sHandle) => { const s = get(sHandle); if (s) s.show(); return 0; }, + statusBarItemHide: (sHandle) => { const s = get(sHandle); if (s) s.hide(); return 0; }, + statusBarItemAsDisposable: (sHandle) => sHandle, // same JS object is a Disposable + + // ── Diagnostics ──────────────────────────────────────────────── + createDiagnosticCollection: (namePtr) => + reg(vscode.languages.createDiagnosticCollection(readString(namePtr))), + diagnosticCollectionClear: (cHandle) => { + const c = get(cHandle); + if (c) c.clear(); + return 0; + }, + diagnosticCollectionSetForUri: (cHandle, uHandle, jsonPtr) => { + const c = get(cHandle); + const u = get(uHandle); + if (!c || !u) return 1; + let arr; + try { arr = JSON.parse(readString(jsonPtr)); } + catch (e) { return 2; } + if (!Array.isArray(arr)) return 3; + const diagnostics = arr.map((d) => { + const range = new vscode.Range( + d.startLine | 0, d.startCol | 0, + d.endLine | 0, d.endCol | 0 + ); + const severity = [ + vscode.DiagnosticSeverity.Error, + vscode.DiagnosticSeverity.Warning, + vscode.DiagnosticSeverity.Information, + vscode.DiagnosticSeverity.Hint, + ][Math.max(0, Math.min(3, d.severity | 0))]; + return new vscode.Diagnostic(range, String(d.message ?? ""), severity); + }); + c.set(u, diagnostics); + return 0; + }, + diagnosticCollectionAsDisposable: (cHandle) => cHandle, + + // ── Webview ──────────────────────────────────────────────────── + createWebviewPanel: (vtPtr, titlePtr, vc) => { + const viewColumn = + vc === 2 ? vscode.ViewColumn.Two : + vc === 3 ? vscode.ViewColumn.Three : + vscode.ViewColumn.One; + return reg(vscode.window.createWebviewPanel( + readString(vtPtr), readString(titlePtr), viewColumn, {} + )); + }, + webviewPanelSetHtml: (pHandle, htmlPtr) => { + const p = get(pHandle); + if (p) p.webview.html = readString(htmlPtr); + return 0; + }, + webviewPanelAsDisposable: (pHandle) => pHandle, + + // ── Clipboard ────────────────────────────────────────────────── + clipboardWriteText: (tPtr) => { + try { + vscode.env.clipboard.writeText(readString(tPtr)); + return 0; + } catch (e) { + return 1; + } + }, + + // ── Events ───────────────────────────────────────────────────── + onDidSaveTextDocument: (handlerIdx) => { + const thunk = wrapHandler(handlerIdx); + // The vscode event ships a TextDocument; we deliberately drop it at + // the FFI boundary (see Vscode.affine docstring). Handlers that + // need the saved file path can call editorActiveFilePath(). + return reg(vscode.workspace.onDidSaveTextDocument(() => thunk())); + }, + + // ── Path helpers ─────────────────────────────────────────────── + pathBasename: (pPtr) => reg(require("path").basename(readString(pPtr))), + pathJoin: (aPtr, bPtr) => + reg(require("path").join(readString(aPtr), readString(bPtr))), + processPlatform: () => reg(process.platform), + + // ── ExtensionContext helpers ─────────────────────────────────── + extensionAbsolutePath: (ctxHandle, relPtr) => { + const ctx = get(ctxHandle); + return reg(ctx ? ctx.asAbsolutePath(readString(relPtr)) : ""); + }, }; const VscodeLanguageClient = { diff --git a/stdlib/Vscode.affine b/stdlib/Vscode.affine index 13f3bad..1a0d793 100644 --- a/stdlib/Vscode.affine +++ b/stdlib/Vscode.affine @@ -19,6 +19,17 @@ // the AffineScript and rattlescript-face VS Code extensions today. Add // further bindings on demand — keep this file's growth proportional to // real consumers. +// +// Surface expanded 2026-05-11 for the rsr-certifier port (affinescript#64 +// — Port external VS Code extensions). Additions cover the workspace, +// file-system, status-bar, diagnostics, webview, clipboard, and path +// surfaces actually used by rhodium-standard-repositories/.../rsr-certifier. +// Two API shapes were deliberately omitted from this expansion because +// they cannot be expressed in the current synchronous extern-call ABI: +// - vscode.window.withProgress(opts, async task) +// - LanguageClient.sendRequest(method, params) (returns Thenable) +// Extensions that need those features should fall back to terminal / +// child_process shells until an async-extern ABI lands. module Vscode; @@ -31,6 +42,11 @@ pub extern type TextEditor; pub extern type Terminal; pub extern type Thenable; pub extern type ExtensionContext; +pub extern type StatusBarItem; +pub extern type DiagnosticCollection; +pub extern type WebviewPanel; +pub extern type Uri; +pub extern type TextDocument; // ── vscode.commands ─────────────────────────────────────────────────── @@ -113,3 +129,157 @@ pub extern fn stringEndsWith(s: String, suffix: String) -> Int; pub extern fn stringReplaceSuffix(s: String, suffix: String, replacement: String) -> String; + +/// Returns 1 if `s` has zero length, 0 otherwise. Useful for testing the +/// "no result" sentinel returned by getters like `workspaceFolderFirstPath` +/// without needing a String=String primitive in the AffineScript surface. +pub extern fn stringIsEmpty(s: String) -> Int; + +// ── Workspace ───────────────────────────────────────────────────────── + +/// First workspace folder's `uri.fsPath`, or "" if there is no workspace. +/// Equivalent to `vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""`. +pub extern fn workspaceFolderFirstPath() -> String; + +/// First workspace folder as a `vscode.Uri`. Used as the `base` for +/// `uriJoinPath` when building paths under the workspace root. Returns a +/// sentinel handle of 0 when there is no workspace. +pub extern fn workspaceRootUri() -> Uri; + +// ── URI / file-system / text documents ─────────────────────────────── + +/// `vscode.Uri.file(path)` — wraps a filesystem path as a Uri handle. +pub extern fn uriFromPath(path: String) -> Uri; + +/// `vscode.Uri.joinPath(base, segment)` — single-segment join only; +/// callers that need multiple segments should chain. +pub extern fn uriJoinPath(base: Uri, segment: String) -> Uri; + +/// `uri.fsPath` — returns the filesystem path the Uri represents. +pub extern fn uriPath(u: Uri) -> String; + +/// `vscode.workspace.fs.writeFile(uri, Buffer.from(content))`. The write +/// is dispatched to the VS Code FS API; the binding returns 0 on a clean +/// dispatch and a non-zero error code otherwise. The underlying write +/// returns a Thenable — the binding does not await it. Best-effort by +/// design (sufficient for the rsr.initConfig use-case which immediately +/// opens the file for the user to inspect). +pub extern fn fsWriteFile(u: Uri, content: String) -> Int; + +/// `vscode.workspace.openTextDocument(uri)` — likewise best-effort +/// dispatch; the returned handle is valid for `showTextDocument`. +pub extern fn openTextDocument(u: Uri) -> TextDocument; + +/// `vscode.window.showTextDocument(doc)`. +pub extern fn showTextDocument(d: TextDocument) -> Int; + +// ── Status bar ─────────────────────────────────────────────────────── + +/// `vscode.window.createStatusBarItem(alignment, priority)`. +/// `alignment`: 0 = Left, 1 = Right (matches `vscode.StatusBarAlignment`). +pub extern fn createStatusBarItem(alignment: Int, priority: Int) -> StatusBarItem; + +pub extern fn statusBarItemSetText(s: StatusBarItem, text: String) -> Int; +pub extern fn statusBarItemSetTooltip(s: StatusBarItem, tooltip: String) -> Int; +pub extern fn statusBarItemSetCommand(s: StatusBarItem, command_id: String) -> Int; + +/// Sets `statusBarItem.backgroundColor` to a `new vscode.ThemeColor(name)`. +/// An empty `theme_color` clears the background (sets it to `undefined`). +pub extern fn statusBarItemSetBackgroundColorTheme(s: StatusBarItem, + theme_color: String) -> Int; + +pub extern fn statusBarItemShow(s: StatusBarItem) -> Int; +pub extern fn statusBarItemHide(s: StatusBarItem) -> Int; + +/// Reinterprets a `StatusBarItem` as a `Disposable` so it can be pushed +/// onto `context.subscriptions`. (The vscode-API contract: status-bar +/// items implement Disposable.) +pub extern fn statusBarItemAsDisposable(s: StatusBarItem) -> Disposable; + +// ── Diagnostics ────────────────────────────────────────────────────── +// +// To avoid an FFI for `Range` and `Diagnostic` constructors plus an array +// type, diagnostics are passed as a JSON array of objects: +// +// [ +// { +// "message": "...", +// "severity": 0..3, // Error|Warning|Information|Hint +// "startLine": 0, +// "startCol": 0, +// "endLine": 0, +// "endCol": 0 +// }, +// ... +// ] +// +// The adapter parses the JSON and constructs `vscode.Diagnostic` +// instances. Range fields are required; sentinel `0,0,0,0` is the +// established convention for "no specific location, attach to the file +// as a whole" (matches what the rsr-certifier TS extension did). + +pub extern fn createDiagnosticCollection(name: String) -> DiagnosticCollection; +pub extern fn diagnosticCollectionClear(c: DiagnosticCollection) -> Int; + +/// `c.set(uri, parseDiagnosticsJson(diagnostics_json))`. Returns 0 on +/// success or a parse-error sentinel (non-zero) if `diagnostics_json` +/// failed to parse. +pub extern fn diagnosticCollectionSetForUri(c: DiagnosticCollection, + u: Uri, + diagnostics_json: String) -> Int; + +/// Returns a `Disposable` for `c` so it can join `context.subscriptions`. +pub extern fn diagnosticCollectionAsDisposable(c: DiagnosticCollection) -> Disposable; + +// ── Webview ────────────────────────────────────────────────────────── + +/// `vscode.window.createWebviewPanel(viewType, title, viewColumn, {})`. +/// `view_column`: 1 = One, 2 = Two, 3 = Three (matches +/// `vscode.ViewColumn`). No webview options exposed in this first cut — +/// the default (no scripts, no retained context) is what rsr-certifier's +/// existing report uses. +pub extern fn createWebviewPanel(view_type: String, + title: String, + view_column: Int) -> WebviewPanel; + +/// Assigns to `panel.webview.html`. +pub extern fn webviewPanelSetHtml(p: WebviewPanel, html: String) -> Int; + +pub extern fn webviewPanelAsDisposable(p: WebviewPanel) -> Disposable; + +// ── Clipboard ──────────────────────────────────────────────────────── + +/// `vscode.env.clipboard.writeText(text)`. Best-effort dispatch; the +/// underlying call returns a Thenable that the binding does not await. +pub extern fn clipboardWriteText(text: String) -> Int; + +// ── Events ─────────────────────────────────────────────────────────── + +/// `vscode.workspace.onDidSaveTextDocument(() => handler())`. +/// +/// The handler is registered as a zero-argument wasm-table thunk. The +/// `TextDocument` argument that the underlying event ships is discarded +/// at the FFI boundary; handlers that need the saved file path can call +/// `editorActiveFilePath()` from inside the thunk. That covers the +/// 95% case where the saved doc is the active editor; the remaining +/// cases (background-saved docs) degrade to "no action". +pub extern fn onDidSaveTextDocument(handler: Int) -> Disposable; + +// ── Path helpers ───────────────────────────────────────────────────── + +/// `path.basename(p)` — last segment of a slash- or backslash-separated +/// path. +pub extern fn pathBasename(p: String) -> String; + +/// `path.join(a, b)` — single-segment join using the host platform's +/// separator. +pub extern fn pathJoin(a: String, b: String) -> String; + +/// `process.platform` — "win32" | "linux" | "darwin" | "freebsd" | ... +pub extern fn processPlatform() -> String; + +// ── ExtensionContext helpers ───────────────────────────────────────── + +/// `context.asAbsolutePath(rel_path)` — resolves a path relative to the +/// extension's install location. +pub extern fn extensionAbsolutePath(ctx: ExtensionContext, rel_path: String) -> String;