feat: add REPL-style Ruby code block runner with captured output

- Add Run button for rendered ruby fences and show stdout/stderr/result/error separately
- Support running full ruby code block when no selection is made and insert formatted output
- Implement version-aware ruby.wasm runtime loading with queued execution and output capture
- Document new features in README and remove obsolete manifest/styles assets
This commit is contained in:
Masumi Kawasaki 2026-05-16 17:19:16 +09:00
parent c82a236f5a
commit 75a4ebe371
No known key found for this signature in database
GPG key ID: 9E5903751D5634BF
7 changed files with 534 additions and 89 deletions

View file

@ -16,6 +16,14 @@ Currently supported:
- Ruby 3.3
- Ruby 3.2
## Features
- Run the selected Ruby code from the editor
- Run the whole current Ruby code block when nothing is selected
- Show stdout, stderr, return value, and errors separately
- Add a Run button under rendered `ruby` code blocks for a lightweight REPL flow
- Choose the Ruby runtime version from plugin settings
# How to use
Select a code block

542
main.ts
View file

@ -1,10 +1,13 @@
/*
ABOUTME: Registers Obsidian commands that run selected Ruby code in a modal or inline.
ABOUTME: Lazily downloads the ruby.wasm runtime so the release bundle stays sync-friendly.
ABOUTME: Runs Ruby code from the editor and rendered markdown using ruby.wasm runtimes.
ABOUTME: Adds version-aware runtime loading, block execution, and inline REPL-style output.
*/
import {
App,
Editor,
EditorPosition,
MarkdownPostProcessorContext,
MarkdownRenderChild,
Modal,
Notice,
Plugin,
@ -12,10 +15,10 @@ import {
Setting,
requestUrl,
} from "obsidian";
import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser";
import type { RubyVM } from "@ruby/wasm-wasi";
import { RubyVM, consolePrinter } from "@ruby/wasm-wasi";
const RUBY_WASM_PACKAGE_VERSION = "2.9.3-2.9.4";
const RUBY_LANGUAGES = new Set(["ruby", "rb"]);
const RUBY_RUNTIMES = {
head: {
@ -46,6 +49,44 @@ interface RubyWasmPluginSettings {
rubyVersion: RubyRuntimeId;
}
interface RubyCodeBlock {
startLine: number;
endLine: number;
code: string;
}
interface RubyExecutionError {
title: string;
details: string;
}
interface RubyExecutionResult {
code: string;
runtimeLabel: string;
stdout: string;
stderr: string;
result: string;
hasResult: boolean;
error: RubyExecutionError | null;
}
interface RubyRuntimeCapture {
stdout: string[];
stderr: string[];
}
interface RubyRuntimeSession {
vm: RubyVM;
setCapture: (capture: RubyRuntimeCapture | null) => void;
}
interface EditorExecutionTarget {
code: string;
block: RubyCodeBlock | null;
from: EditorPosition | null;
to: EditorPosition | null;
}
const DEFAULT_SETTINGS: RubyWasmPluginSettings = {
rubyVersion: "3.3",
};
@ -53,99 +94,188 @@ const DEFAULT_SETTINGS: RubyWasmPluginSettings = {
const formatError = (error: unknown) =>
error instanceof Error ? error.toString() : String(error);
const formatExecutionError = (error: unknown): RubyExecutionError => {
if (error instanceof Error) {
return {
title: error.name || "Ruby error",
details: error.stack ?? error.toString(),
};
}
return {
title: "Ruby error",
details: String(error),
};
};
const isRubyRuntimeId = (value: string): value is RubyRuntimeId =>
Object.prototype.hasOwnProperty.call(RUBY_RUNTIMES, value);
const compileWebAssemblyModule = (buffer: ArrayBuffer) =>
WebAssembly.compile(buffer);
const normalizeRubyOutput = (chunks: string[]) => chunks.join("");
const parseFenceLine = (line: string) => {
const match = line.trim().match(/^(```+|~~~+)\s*([^\s`~]*)?/);
if (!match) {
return null;
}
return {
fence: match[1],
language: match[2]?.toLowerCase() ?? "",
};
};
const createExecutionSection = (
containerEl: HTMLElement,
label: string,
content: string,
className: string
) => {
const sectionEl = containerEl.createDiv({
cls: `ruby-execution-section ${className}`.trim(),
});
sectionEl.createDiv({ cls: "ruby-execution-label", text: label });
sectionEl.createEl("pre", {
cls: "ruby-execution-body",
text: content.length > 0 ? content : "(empty)",
});
};
const renderExecutionResult = (
containerEl: HTMLElement,
execution: RubyExecutionResult
) => {
containerEl.empty();
containerEl.removeClass("is-empty");
containerEl.removeClass("is-loading");
containerEl.addClass("ruby-execution");
containerEl.createDiv({
cls: "ruby-execution-runtime",
text: execution.runtimeLabel,
});
if (execution.stdout) {
createExecutionSection(
containerEl,
"Stdout",
execution.stdout,
"is-stdout"
);
}
if (execution.stderr) {
createExecutionSection(
containerEl,
"Stderr",
execution.stderr,
"is-stderr"
);
}
if (execution.hasResult) {
createExecutionSection(
containerEl,
"Return value",
execution.result,
"is-result"
);
}
if (execution.error) {
createExecutionSection(
containerEl,
execution.error.title,
execution.error.details,
"is-error"
);
}
};
const renderExecutionLoading = (containerEl: HTMLElement, runtimeLabel: string) => {
containerEl.empty();
containerEl.removeClass("is-empty");
containerEl.addClass("is-loading");
containerEl.createDiv({
cls: "ruby-execution-runtime",
text: runtimeLabel,
});
containerEl.createDiv({
cls: "ruby-execution-status",
text: "Running Ruby code...",
});
};
const formatExecutionMarkdown = (execution: RubyExecutionResult) => {
const sections = [`runtime: ${execution.runtimeLabel}`];
if (execution.stdout) {
sections.push(`stdout:\n${execution.stdout}`);
}
if (execution.stderr) {
sections.push(`stderr:\n${execution.stderr}`);
}
if (execution.hasResult) {
sections.push(`result:\n${execution.result.length > 0 ? execution.result : "(empty)"}`);
}
if (execution.error) {
sections.push(`${execution.error.title}:\n${execution.error.details}`);
}
return `\n\n\`\`\`text\n${sections.join("\n\n")}\n\`\`\``;
};
export default class RubyWasmPlugin extends Plugin {
settings: RubyWasmPluginSettings = DEFAULT_SETTINGS;
private rubyVmPromise: Promise<RubyVM> | null = null;
// Function to check if inside code block
isInCodeBlock = (editor: Editor, line: number) => {
const totalLines = editor.lineCount();
let inCodeBlock = true;
for (let i = line; i >= 0; i--) {
const lineText = editor.getLine(i).trim();
if (lineText.startsWith("```")) {
inCodeBlock = !inCodeBlock;
}
}
for (let i = line + 1; i < totalLines; i++) {
const lineText = editor.getLine(i).trim();
if (lineText.startsWith("```")) {
inCodeBlock = !inCodeBlock;
break;
}
}
return inCodeBlock;
};
private rubyVmPromise: Promise<RubyRuntimeSession> | null = null;
private executionQueue: Promise<void> = Promise.resolve();
async onload() {
await this.loadSettings();
this.addSettingTab(new RubyWasmSettingTab(this.app, this));
const showRuntimeLoadError = (error: unknown) => {
new Notice(
`Failed to load ${this.getSelectedRuntime().label}: ${formatError(
error
)}`
);
};
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: "run-in-modal",
name: "Run in Modal",
editorCallback: async (editor: Editor) => {
try {
const { code, result } = await this.runRuby(editor);
new CodeModal(this.app, code, result).open();
const target = this.getEditorExecutionTarget(editor);
const execution = await this.executeRuby(target.code);
new CodeModal(this.app, execution).open();
} catch (error) {
showRuntimeLoadError(error);
this.showRuntimeLoadError(error);
}
},
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: "run-in-editor",
name: "Run in Editor",
editorCallback: async (editor: Editor) => {
try {
const { code, result } = await this.runRuby(editor);
const cursorLine = editor.getCursor().line;
const insideCode = this.isInCodeBlock(editor, cursorLine);
if (insideCode) {
editor.replaceSelection(`${code}\n# => ${result}`);
} else {
editor.replaceSelection(
`${code}\n\`\`\`\n${result}\n\`\`\``
);
}
const target = this.getEditorExecutionTarget(editor);
const execution = await this.executeRuby(target.code);
this.insertExecutionResult(editor, target, execution);
} catch (error) {
showRuntimeLoadError(error);
this.showRuntimeLoadError(error);
}
},
});
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
// this.registerDomEvent(document, "click", (evt: MouseEvent) => {
// console.log("click", evt);
// });
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
// this.registerInterval(
// window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000)
// );
this.registerMarkdownPostProcessor((el, ctx) => {
this.enhanceRubyCodeBlocks(el, ctx);
});
}
onunload() {
this.rubyVmPromise = null;
this.executionQueue = Promise.resolve();
}
async updateRubyVersion(rubyVersion: RubyRuntimeId) {
@ -162,18 +292,182 @@ export default class RubyWasmPlugin extends Plugin {
new Notice(`Using ${this.getSelectedRuntime().label}.`);
}
private async runRuby(editor: Editor) {
const code = editor.getSelection();
const vm = await this.getRubyVM();
getSelectedRuntimeLabel() {
return this.getSelectedRuntime().label;
}
try {
return { code, result: vm.eval(code).toString() };
} catch (error) {
return { code, result: formatError(error) };
async executeRuby(code: string): Promise<RubyExecutionResult> {
const runtimeLabel = this.getSelectedRuntime().label;
const run = async () => {
const runtime = await this.getRubyVM();
const capture: RubyRuntimeCapture = {
stdout: [],
stderr: [],
};
runtime.setCapture(capture);
try {
const value = runtime.vm.eval(code);
return {
code,
runtimeLabel,
stdout: normalizeRubyOutput(capture.stdout),
stderr: normalizeRubyOutput(capture.stderr),
result: value.toString(),
hasResult: true,
error: null,
};
} catch (error) {
return {
code,
runtimeLabel,
stdout: normalizeRubyOutput(capture.stdout),
stderr: normalizeRubyOutput(capture.stderr),
result: "",
hasResult: false,
error: formatExecutionError(error),
};
} finally {
runtime.setCapture(null);
}
};
const resultPromise = this.executionQueue.then(run, run);
this.executionQueue = resultPromise.then(
() => undefined,
() => undefined
);
return resultPromise;
}
private showRuntimeLoadError(error: unknown) {
new Notice(
`Failed to load ${this.getSelectedRuntime().label}: ${formatError(error)}`
);
}
private getEditorExecutionTarget(editor: Editor): EditorExecutionTarget {
if (editor.somethingSelected()) {
return {
code: editor.getSelection(),
block: this.getRubyCodeBlock(editor, editor.getCursor("from").line),
from: editor.getCursor("from"),
to: editor.getCursor("to"),
};
}
const block = this.getRubyCodeBlock(editor, editor.getCursor().line);
if (block) {
return {
code: block.code,
block,
from: null,
to: null,
};
}
throw new Error(
"Select Ruby code or place the cursor inside a ruby code block."
);
}
private getRubyCodeBlock(editor: Editor, line: number): RubyCodeBlock | null {
for (let current = line; current >= 0; current--) {
const fence = parseFenceLine(editor.getLine(current));
if (!fence) {
continue;
}
if (!RUBY_LANGUAGES.has(fence.language)) {
return null;
}
for (let endLine = current + 1; endLine < editor.lineCount(); endLine++) {
const endFence = parseFenceLine(editor.getLine(endLine));
if (!endFence || endFence.fence !== fence.fence) {
continue;
}
if (line > endLine) {
return null;
}
const code = editor.getRange(
{ line: current + 1, ch: 0 },
{ line: endLine, ch: 0 }
);
return {
startLine: current,
endLine,
code,
};
}
return null;
}
return null;
}
private insertExecutionResult(
editor: Editor,
target: EditorExecutionTarget,
execution: RubyExecutionResult
) {
const output = formatExecutionMarkdown(execution);
if (target.block) {
const closingLine = editor.getLine(target.block.endLine);
editor.replaceRange(output, {
line: target.block.endLine,
ch: closingLine.length,
});
return;
}
if (target.to) {
editor.replaceRange(output, target.to);
return;
}
editor.replaceSelection(output);
}
private enhanceRubyCodeBlocks(
el: HTMLElement,
ctx: MarkdownPostProcessorContext
) {
const codeBlocks = Array.from(
el.querySelectorAll("pre > code.language-ruby")
);
for (const codeEl of codeBlocks) {
const preEl = codeEl.parentElement;
if (!(preEl instanceof HTMLElement)) {
continue;
}
if (preEl.parentElement?.classList.contains("ruby-repl-block")) {
continue;
}
const wrapperEl = createDiv({ cls: "ruby-repl-block" });
preEl.parentElement?.insertBefore(wrapperEl, preEl);
wrapperEl.appendChild(preEl);
ctx.addChild(
new RubyCodeBlockRenderChild(
wrapperEl,
this,
codeEl.textContent ?? ""
)
);
}
}
private async getRubyVM(): Promise<RubyVM> {
private async getRubyVM(): Promise<RubyRuntimeSession> {
if (!this.rubyVmPromise) {
this.rubyVmPromise = this.createRubyVM();
}
@ -181,11 +475,15 @@ export default class RubyWasmPlugin extends Plugin {
return this.rubyVmPromise;
}
private async createRubyVM(): Promise<RubyVM> {
private async createRubyVM(): Promise<RubyRuntimeSession> {
const runtime = this.getSelectedRuntime();
const loadingNotice = new Notice(`Loading ${runtime.label}...`, 0);
let capture: RubyRuntimeCapture | null = null;
try {
const { File, OpenFile, PreopenDirectory, WASI } = await import(
"@bjorn3/browser_wasi_shim"
);
const response = await requestUrl({
url: runtime.url,
method: "GET",
@ -198,8 +496,38 @@ export default class RubyWasmPlugin extends Plugin {
}
const module = await compileWebAssemblyModule(response.arrayBuffer);
const { vm } = await DefaultRubyVM(module);
return vm;
const wasi = new WASI(
[],
[],
[
new OpenFile(new File([])),
new OpenFile(new File([])),
new OpenFile(new File([])),
new PreopenDirectory("/", new Map()),
],
{ debug: false }
);
const printer = consolePrinter({
stdout: (text) => capture?.stdout.push(text),
stderr: (text) => capture?.stderr.push(text),
});
const { vm } = await RubyVM.instantiateModule({
module,
wasip1: wasi,
addToImports: (imports) => {
printer.addToImports(imports);
},
setMemory: (memory) => {
printer.setMemory(memory);
},
});
return {
vm,
setCapture: (nextCapture) => {
capture = nextCapture;
},
};
} catch (error) {
this.rubyVmPromise = null;
throw error;
@ -231,30 +559,28 @@ export default class RubyWasmPlugin extends Plugin {
}
class CodeModal extends Modal {
code: string;
result: string;
constructor(app: App, code: string, result: string) {
execution: RubyExecutionResult;
constructor(app: App, execution: RubyExecutionResult) {
super(app);
this.code = code;
this.result = result;
this.execution = execution;
}
async onOpen() {
const { contentEl } = this;
// contentEl.createEl("h1", { text: "Code" });
const codeBlock = contentEl.createEl("pre", {
cls: "language-ruby",
});
const codeElement = codeBlock.createEl("code", {
cls: "language-ruby",
});
codeElement.textContent = this.code || "code";
codeElement.textContent = this.execution.code || "code";
const resultElement = contentEl.createDiv({
cls: "ruby-output",
});
resultElement.textContent = this.result || "result";
renderExecutionResult(resultElement, this.execution);
const closeButton = contentEl.createEl("button", {
cls: "modal-button",
@ -272,6 +598,64 @@ class CodeModal extends Modal {
}
}
class RubyCodeBlockRenderChild extends MarkdownRenderChild {
plugin: RubyWasmPlugin;
source: string;
runButtonEl: HTMLButtonElement;
outputEl: HTMLDivElement;
runtimeEl: HTMLSpanElement;
constructor(containerEl: HTMLElement, plugin: RubyWasmPlugin, source: string) {
super(containerEl);
this.plugin = plugin;
this.source = source;
const toolbarEl = containerEl.createDiv({ cls: "ruby-repl-toolbar" });
this.runButtonEl = toolbarEl.createEl("button", {
cls: "clickable-icon ruby-repl-run-button",
attr: { type: "button", "aria-label": "Run Ruby code block" },
text: "Run",
});
this.runtimeEl = toolbarEl.createEl("span", {
cls: "ruby-repl-runtime",
text: plugin.getSelectedRuntimeLabel(),
});
this.outputEl = containerEl.createDiv({
cls: "ruby-output ruby-repl-panel is-empty",
});
}
onload() {
this.registerDomEvent(this.runButtonEl, "click", () => {
void this.runCodeBlock();
});
}
private async runCodeBlock() {
this.runButtonEl.disabled = true;
renderExecutionLoading(this.outputEl, this.plugin.getSelectedRuntimeLabel());
try {
const execution = await this.plugin.executeRuby(this.source);
this.runtimeEl.textContent = execution.runtimeLabel;
renderExecutionResult(this.outputEl, execution);
} catch (error) {
this.runtimeEl.textContent = this.plugin.getSelectedRuntimeLabel();
renderExecutionResult(this.outputEl, {
code: this.source,
runtimeLabel: this.plugin.getSelectedRuntimeLabel(),
stdout: "",
stderr: "",
result: "",
hasResult: false,
error: formatExecutionError(error),
});
} finally {
this.runButtonEl.disabled = false;
}
}
}
class RubyWasmSettingTab extends PluginSettingTab {
plugin: RubyWasmPlugin;

View file

@ -1,10 +1,10 @@
{
"id": "ruby-wasm",
"name": "ruby.wasm",
"version": "0.0.1",
"version": "0.0.2",
"minAppVersion": "1.5.12",
"description": "Run ruby code in your notes using WebAssembly.",
"author": "geeknees",
"authorUrl": "https://github.com/geeknees/",
"isDesktopOnly": false
}
}

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-ruby.wasm-plugin",
"version": "0.0.1",
"version": "0.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-ruby.wasm-plugin",
"version": "0.0.1",
"version": "0.0.2",
"license": "MIT",
"dependencies": {
"@bjorn3/browser_wasi_shim": "^0.3.0",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-ruby.wasm-plugin",
"version": "0.0.1",
"version": "0.0.2",
"description": "This is a ruby.wasm plugin for Obsidian (https://obsidian.md).",
"main": "main.js",
"scripts": {

View file

@ -1,6 +1,6 @@
/*
ABOUTME: Styles modal output for the Obsidian ruby.wasm plugin.
ABOUTME: Keeps runtime output readable without inline styles in the TypeScript code.
ABOUTME: Styles modal and inline REPL output for the Obsidian ruby.wasm plugin.
ABOUTME: Keeps runtime output, play buttons, and execution panels readable in preview.
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
@ -14,5 +14,57 @@ button.modal-button {
}
.ruby-output {
white-space: pre-wrap;
margin-top: 0.75rem;
}
.ruby-repl-block {
margin: 1em 0;
}
.ruby-repl-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.ruby-repl-run-button {
padding: 0.25rem 0.75rem;
}
.ruby-repl-runtime,
.ruby-execution-runtime {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.ruby-repl-panel.is-empty {
display: none;
}
.ruby-repl-panel.is-loading,
.ruby-execution {
display: block;
}
.ruby-execution-status,
.ruby-execution-label {
font-size: var(--font-ui-small);
font-weight: var(--font-medium);
}
.ruby-execution-section {
margin-top: 0.75rem;
}
.ruby-execution-body {
white-space: pre-wrap;
margin: 0.25rem 0 0;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--background-secondary);
}
.ruby-execution-section.is-error .ruby-execution-body {
color: var(--text-error);
}

View file

@ -1,3 +1,4 @@
{
"0.0.1": "0.15.0"
}
"0.0.1": "1.5.12",
"0.0.2": "1.5.12"
}