From ad50b0619dacccef48f7d3e161534eb48f4b8982 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:58:48 +0000 Subject: [PATCH 1/3] feat(docs): run the real sysml REPL in the browser with walkthroughs; make the landing diagram editable The CLI page's toy shell over sysml-engine is replaced by cmd/sysml built for js/wasm with -tags sysml_prod, hosted by docs/assets/sysml-repl.js on an in-memory filesystem holding the runtime-showcase models. Seven guided walkthroughs type their commands into the prompt; Up/Down history persists across visits and Tab completes through Session.Complete, exposed to the page as sysmlReplComplete. TestBrowserREPLWalkthroughs runs every walkthrough through the page host and pins its output. The landing diagram's source opens in an editor: the engine re-parses and re-instantiates the model as you type and redraws parts and interfaces, keeping the last good diagram while a parse error is shown. Co-Authored-By: jason.han --- .gitignore | 2 + Makefile | 6 +- changes/unreleased/browser-repl.added.md | 12 + cmd/sysml/complete_js.go | 30 ++ cmd/sysml/complete_wasip1.go | 8 + cmd/sysml/prompt_plain.go | 3 +- docs/assets/repl-walkthroughs.json | 470 ++++++++++++++++ docs/assets/sysml-repl.js | 659 +++++++++++++++++++++++ docs/cli.md | 295 +++++----- overrides/home.html | 142 ++++- tests/wasm/browser_repl_test.go | 133 +++++ tests/wasm/testdata/browser-repl.mjs | 45 ++ 12 files changed, 1609 insertions(+), 196 deletions(-) create mode 100644 changes/unreleased/browser-repl.added.md create mode 100644 cmd/sysml/complete_js.go create mode 100644 cmd/sysml/complete_wasip1.go create mode 100644 docs/assets/repl-walkthroughs.json create mode 100644 docs/assets/sysml-repl.js create mode 100644 tests/wasm/browser_repl_test.go create mode 100644 tests/wasm/testdata/browser-repl.mjs diff --git a/.gitignore b/.gitignore index ebf801fe93..a33f3f965b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ # In-browser engine assets (make docs-engine-assets); built, never vendored /docs/assets/sysml-engine.wasm* +/docs/assets/sysml-repl.wasm* +/docs/assets/repl-examples/ /docs/assets/wasm_exec.js # Julia package environments resolve locally; the pinned deps are in Project.toml. diff --git a/Makefile b/Makefile index c8499976c4..4d159cf600 100644 --- a/Makefile +++ b/Makefile @@ -503,8 +503,12 @@ docs-engine-assets: ## Build the in-browser engine assets into docs/assets @mkdir -p docs/assets GOOS=js GOARCH=wasm $(GO_BUILD) -o docs/assets/sysml-engine.wasm ./cmd/sysml-engine gzip -9f docs/assets/sysml-engine.wasm + GOOS=js GOARCH=wasm $(GO_BUILD) -tags sysml_prod -o docs/assets/sysml-repl.wasm ./cmd/sysml + gzip -9f docs/assets/sysml-repl.wasm + @mkdir -p docs/assets/repl-examples + @cp examples/runtime-showcase/*.sysml docs/assets/repl-examples/ @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" docs/assets/ - @echo "✓ Built docs/assets/sysml-engine.wasm.gz + wasm_exec.js" + @echo "✓ Built docs/assets/sysml-engine.wasm.gz, sysml-repl.wasm.gz, repl-examples/ + wasm_exec.js" docs-serve: docs-install ## Serve the documentation site with live reload $(PYTHON) -m mkdocs serve --strict diff --git a/changes/unreleased/browser-repl.added.md b/changes/unreleased/browser-repl.added.md new file mode 100644 index 0000000000..22dbce8b78 --- /dev/null +++ b/changes/unreleased/browser-repl.added.md @@ -0,0 +1,12 @@ +- **The CLI page runs the real `sysml` REPL in the browser, with guided walkthroughs.** The + page's toy shell over `sysml-engine` is replaced by `cmd/sysml` itself, built for js/wasm + with `-tags sysml_prod` (about 12 MB gzipped, downloaded when started), on an in-memory + filesystem holding the runtime-showcase models. Seven walkthroughs type their commands into + the prompt: units, the Saturn V mass rollup, the delta-v analysis and sweep, failing + requirements, stepping an action, a state machine on a clock and a user-written one. Up and + Down recall earlier entries, kept across visits, and Tab completes through the REPL's own + completer. `TestBrowserREPLWalkthroughs` runs every walkthrough through the page's host. +- **The landing-page diagram can be edited.** "Edit the model" opens the diagram's SysML + source; the engine re-parses and re-instantiates it as you type and redraws the parts and + interfaces, a parse error is shown with its line and column while the last good diagram + stays up, and "Run the model" executes the edited model. diff --git a/cmd/sysml/complete_js.go b/cmd/sysml/complete_js.go new file mode 100644 index 0000000000..1015033c49 --- /dev/null +++ b/cmd/sysml/complete_js.go @@ -0,0 +1,30 @@ +//go:build js + +package main + +import ( + "encoding/json" + "syscall/js" + + "github.com/Open-MBEE/OpenSysML/internal/frontend/repl" +) + +// exposeCompletion lets a page complete the line being typed: +// globalThis.sysmlReplComplete(textBeforeCursor) answers {candidates, prefix} as JSON. +func exposeCompletion(sess *repl.Session) { + js.Global().Set("sysmlReplComplete", js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) == 0 || args[0].Type() != js.TypeString { + return "{}" + } + line := args[0].String() + c := sess.Complete(line, len(line)) + out, err := json.Marshal(struct { + Candidates []string `json:"candidates"` + Prefix string `json:"prefix"` + }{c.Candidates, c.Prefix}) + if err != nil { + return "{}" + } + return string(out) + })) +} diff --git a/cmd/sysml/complete_wasip1.go b/cmd/sysml/complete_wasip1.go new file mode 100644 index 0000000000..283bb35be8 --- /dev/null +++ b/cmd/sysml/complete_wasip1.go @@ -0,0 +1,8 @@ +//go:build wasip1 + +package main + +import "github.com/Open-MBEE/OpenSysML/internal/frontend/repl" + +// exposeCompletion has no host to expose completion to under WASI. +func exposeCompletion(*repl.Session) {} diff --git a/cmd/sysml/prompt_plain.go b/cmd/sysml/prompt_plain.go index 08d6c7b66d..6ec86dd852 100644 --- a/cmd/sysml/prompt_plain.go +++ b/cmd/sysml/prompt_plain.go @@ -17,7 +17,8 @@ import ( // host wired up, without history, completion or an interrupt to catch. The session // is passed for the signature it has on every build and is not consulted. The // returned function closes the reader, which is nothing to do over standard input. -func newLineInput(_ *repl.Session) (repl.LineReader, func() error, error) { +func newLineInput(sess *repl.Session) (repl.LineReader, func() error, error) { + exposeCompletion(sess) return &plainReader{in: bufio.NewReader(os.Stdin), out: os.Stdout}, func() error { return nil }, nil } diff --git a/docs/assets/repl-walkthroughs.json b/docs/assets/repl-walkthroughs.json new file mode 100644 index 0000000000..e3d08945f3 --- /dev/null +++ b/docs/assets/repl-walkthroughs.json @@ -0,0 +1,470 @@ +{ + "tours": [ + { + "id": "basics", + "title": "First steps", + "steps": [ + { + "title": "An expression is a command", + "text": "Type any SysML expression at the prompt and the REPL evaluates it. There is nothing to set up first.", + "input": [ + "2 + 3 * 4" + ], + "expect": [ + "= 14" + ] + }, + { + "title": "Quantities carry their units", + "text": "Write a unit in brackets after a number. The REPL takes the units from the standard SI library and converts the result to SI.", + "input": [ + "100 [SI::km] / 2 [SI::h]" + ], + "expect": [ + "= 13.88888888888889 [SI::'m/s']" + ] + }, + { + "title": "and the units are checked", + "text": "You cannot add a mass to a length. The REPL refuses the sum and names both units.", + "input": [ + "1 [SI::kg] + 1 [SI::m]" + ], + "expect": [ + "incommensurable units" + ] + }, + { + "title": "Declare a model at the prompt", + "text": "A declaration typed at the prompt joins the session model. A block that is still open shows the ...> continuation prompt. The warning is real: the library's sum is declared over Real, and this model gives it masses.", + "input": [ + "package Garage {", + " private import SI::*;", + " private import RealFunctions::sum;", + " part def Wheel { attribute mass :> ISQ::mass = 25 [kg]; }", + " part car {", + " part wheels : Wheel[4];", + " attribute bodyMass :> ISQ::mass = 1200 [kg];", + " attribute mass :> ISQ::mass = bodyMass + sum(wheels.mass);", + " }", + "}" + ], + "expect": [ + "✓ package Garage" + ] + }, + { + "title": "Derived values are evaluated on an instance", + "text": "car.mass is never written down anywhere. To evaluate it, the runtime makes an instance of car with four wheels and sums their masses into the body's.", + "input": [ + "%eval Garage::car.mass" + ], + "expect": [ + "= 1300 [kg]" + ] + }, + { + "title": "Hold an object and query it", + "text": "%instantiate keeps the object in the session. %eval in evaluates an expression with that object as its context.", + "input": [ + "%instantiate Garage::car", + "%eval in Garage::car : mass / 4" + ], + "expect": [ + "Created instance of Garage::car", + "= 325.0 [kg]" + ] + }, + { + "title": "Calculations with typed parameters", + "text": "A calc def is a function whose parameters are typed. %calc calls it with quantities, and the result comes back in SI.", + "input": [ + "calc def Speed { in d : ISQ::LengthValue; in t : ISQ::TimeValue; return : ISQ::SpeedValue = d / t; }", + "%calc Speed 42.195 [SI::km] 2 [SI::h]" + ], + "expect": [ + "= 5.860416666666667 [SI::'m/s']" + ] + }, + { + "title": "Look around", + "text": "%list prints what the session has declared. %search looks up names in the session and the standard library. Press Tab at the prompt to complete commands and names, and ↑ to bring back earlier lines.", + "input": [ + "%list", + "%search Wheel" + ], + "expect": [ + "Garage::Wheel part def" + ] + } + ] + }, + { + "id": "mass", + "title": "Saturn V mass rollup", + "steps": [ + { + "title": "Load a model from the examples", + "text": "The page carries the runtime showcase models in an in-memory filesystem, so %load works the way it does on a disk. MassRollup defines totalMass recursively: a component's own mass plus the totalMass of each of its subcomponents.", + "input": [ + "%load examples/runtime-showcase/mass-rollup.sysml" + ], + "expect": [ + "✓ package MassRollup" + ] + }, + { + "title": "Roll up three stages and eleven engines", + "text": "No totalMass value appears anywhere in the file. Evaluating it builds the whole vehicle and computes the value at every level.", + "input": [ + "%eval MassRollup::saturnV.totalMass", + "%eval MassRollup::saturnV.stage1.totalMass" + ], + "expect": [ + "= 2941728.0 [kg]", + "= 2332000.0 [kg]" + ] + }, + { + "title": "A gap that only the runtime finds", + "text": "saturnVWithIU adds an instrument unit that declares a mass but never gives it a value. That is legal SysML, so validation finds nothing wrong. The rollup is where the missing value shows up.", + "input": [ + "%eval MassRollup::saturnVWithIU.totalMass" + ], + "expect": [ + "no value for feature mass" + ] + }, + { + "title": "Print the element at fault", + "text": "%print prints any element as SysML notation, comments included.", + "input": [ + "%print MassRollup::InstrumentUnit" + ], + "expect": [ + "part def InstrumentUnit" + ] + } + ] + }, + { + "id": "orbit", + "title": "Will it reach orbit?", + "steps": [ + { + "title": "A delta-v budget", + "text": "DeltaVBudget applies the rocket equation to each stage of a Saturn IB and checks the total against an orbit requirement. The warning on load is genuine: the model calls the rocket equation in a way that leaves one parameter unbound.", + "input": [ + "%load examples/runtime-showcase/delta-v-budget.sysml" + ], + "expect": [ + "✓ package DeltaVBudget" + ] + }, + { + "title": "Call the rocket equation", + "text": "Specific impulse, standard gravity, wet mass and dry mass go in. Metres per second come out, because the units are carried through every step.", + "input": [ + "%calc DeltaVBudget::RocketEquation 421 [SI::s] 9.80665 [SI::'m/s²'] 133000 [SI::kg] 28000 [SI::kg]" + ], + "expect": [ + "= 6432.955324716369 [SI::'m/s']" + ] + }, + { + "title": "Run the analysis case", + "text": "The analysis computes each stage's delta-v and the margin over the required speed, then decides its objective: the OrbitWithMargin requirement.", + "input": [ + "%analysis DeltaVBudget::saturnIBAscent" + ], + "expect": [ + "objective reachesOrbit: satisfied" + ] + }, + { + "title": "Ask for more", + "text": "Arguments rebind the case's inputs. Require 400 m/s more and the verdict flips. The REPL prints the condition that failed.", + "input": [ + "%analysis DeltaVBudget::saturnIBAscent(required = 9800 ['m/s'])" + ], + "expect": [ + "objective reachesOrbit: not satisfied: margin > 0 ['m/s']" + ] + }, + { + "title": "Sweep the requirement", + "text": "%sweep runs the case once for each value in a range and prints one row per run. The verdict changes between 9 250 and 9 500 m/s.", + "input": [ + "%sweep DeltaVBudget::saturnIBAscent required=9000.0 ['m/s']..10000.0 ['m/s']:250.0 ['m/s']" + ], + "expect": [ + "5 run(s)", + "9500.0 ['m/s']" + ] + }, + { + "title": "Check every satisfy claim", + "text": "%satisfy evaluates each assert satisfy in the model against an instance of its subject.", + "input": [ + "%satisfy" + ], + "expect": [ + "satisfy saturnIBOrbit by saturnIB holds" + ] + } + ] + }, + { + "id": "reliability", + "title": "Requirements that fail", + "steps": [ + { + "title": "A crew-safety requirement", + "text": "Reliability multiplies e^(-λt) over the craft's components and requires the product to be at least 0.99. apollo flies for 195 hours. longStay flies the same hardware for 84 days.", + "input": [ + "%load examples/runtime-showcase/reliability.sysml", + "%eval Reliability::apollo.reliability", + "%eval Reliability::longStay.reliability" + ], + "expect": [ + "= 0.9902201369662298", + "= 0.9033850540586944" + ] + }, + { + "title": "Decide the requirement", + "text": "One mission meets the requirement and the other does not. The REPL reports the condition that was violated and the instance it was decided on.", + "input": [ + "%satisfy" + ], + "expect": [ + "satisfy crewSafety by apollo holds", + "satisfy crewSafety by longStay fails", + "Required condition evaluated to false: craft.reliability >= threshold" + ] + } + ] + }, + { + "id": "mission", + "title": "Step through Apollo 11", + "steps": [ + { + "title": "An action flow with a decision", + "text": "LunarMission is a sequence of burns that spends a delta-v budget, with a decide node that skips the landing if too little is left after lunar orbit insertion.", + "input": [ + "%load examples/runtime-showcase/mission-sequence.sysml" + ], + "expect": [ + "✓ package MissionSequence" + ] + }, + { + "title": "Start the action on an object", + "text": "Apollo 11 performs the mission. %action starts a debugger on the action, which waits with one token at the start.", + "input": [ + "%instantiate MissionSequence::apollo11", + "%action MissionSequence::Flight::mission MissionSequence::apollo11" + ], + "expect": [ + "Started action executor" + ] + }, + { + "title": "Step it and look at the token", + "text": "Each %step moves the token one node. %tokens shows where the token is and the value of remaining so far.", + "input": [ + "%step", + "%step", + "%tokens" + ], + "expect": [ + "Token 1 @ lunarOrbitInsertion", + "remaining = 6350 ['m/s']" + ] + }, + { + "title": "Run it to the end", + "text": "Enough delta-v is left, so the decision takes the landing branch.", + "input": [ + "%continue" + ], + "expect": [ + "landed = true", + "remaining = 480 ['m/s']" + ] + }, + { + "title": "Apollo 8 takes the other branch", + "text": "Apollo 8 carries 4 000 m/s less. The same flow run on that object skips the landing.", + "input": [ + "%instantiate MissionSequence::apollo8", + "%action MissionSequence::Flight::mission MissionSequence::apollo8", + "%continue" + ], + "expect": [ + "landed = false", + "remaining = 450 ['m/s']" + ] + }, + { + "title": "The mission as a state machine on a clock", + "text": "MissionPhases moves through its phases with timed transitions, measured in seconds. %advance runs the simulation clock forward and reports where the machine is.", + "input": [ + "%instantiate MissionSequence::mission", + "%state MissionSequence::mission", + "%advance 400000", + "%advance 400000" + ], + "expect": [ + "Current state: onSurface", + "State machine completed" + ] + } + ] + }, + { + "id": "comms", + "title": "A spacecraft on a clock", + "steps": [ + { + "title": "Signals, parallel regions and a battery", + "text": "A ground station pings a spacecraft 30 s into the mission. The spacecraft answers with one 1 024-byte frame a second, and a parallel region drains its battery 2 % per frame. Below 80 % the battery recharges, and below 40 % transmission is interrupted.", + "input": [ + "%load examples/runtime-showcase/spacecraft-comms.sysml", + "%instantiate SpacecraftComms::mission", + "%state SpacecraftComms::mission.spacecraftVehicle" + ], + "expect": [ + "Current state: waitingGSPing | notRecharging" + ] + }, + { + "title": "Advance 40 seconds", + "text": "The ping has arrived and the spacecraft is transmitting. Read the battery level off the running object.", + "input": [ + "%advance 40", + "%eval in SpacecraftComms::mission.spacecraftVehicle : battery" + ], + "expect": [ + "Current state: transmitting | notRecharging", + "= 80" + ] + }, + { + "title": "Advance another 40", + "text": "The battery has fallen below 40 %, so transmission is interrupted and the spacecraft goes into low power to recharge. The ground station has counted the frames that arrived.", + "input": [ + "%advance 40", + "%eval in SpacecraftComms::mission.spacecraftVehicle : data", + "%eval in SpacecraftComms::mission.groundStation : framesReceived" + ], + "expect": [ + "Current state: lowPower | recharging", + "= 51200", + "= 50" + ] + }, + { + "title": "Run to the end of the transfer", + "text": "All 100 kB arrive by t = 241 s. %current also reports what the machine is still waiting for.", + "input": [ + "%advance 220", + "%current" + ], + "expect": [ + "Current state: transmitted | notRecharging", + "Cannot progress" + ] + } + ] + }, + { + "id": "own", + "title": "Your own state machine", + "steps": [ + { + "title": "Write a state machine", + "text": "A lamp that a Toggle signal switches on and off, and that switches itself off after 5 seconds.", + "input": [ + "package Lamp {", + " item def Toggle;", + " state def Switch {", + " entry; then off;", + " state off;", + " transition first off accept Toggle then on;", + " state on;", + " transition first on accept Toggle then off;", + " transition first on accept after 5 [SI::s] then off;", + " }", + " part lamp { exhibit state s : Switch; }", + "}" + ], + "expect": [ + "✓ package Lamp" + ] + }, + { + "title": "Start it", + "text": "%state attaches a debugger to the machine the object exhibits.", + "input": [ + "%instantiate Lamp::lamp", + "%state Lamp::lamp" + ], + "expect": [ + "Current state: off" + ] + }, + { + "title": "Send it a signal", + "text": "%send posts the signal on the runtime's message bus, and advancing the clock dispatches it. The report shows the 5-second timer that is now pending.", + "input": [ + "%send Toggle to Lamp::lamp", + "%advance 1" + ], + "expect": [ + "Current state: on", + "t=5.0" + ] + }, + { + "title": "Let the timer fire", + "text": "Nothing is sent this time. The timer started on entering on fires at t = 5 s and switches the lamp off.", + "input": [ + "%advance 5" + ], + "expect": [ + "Current state: off" + ] + }, + { + "title": "Trace what the runtime does", + "text": "%trace on prints each exit, entry, evaluation and transition as it happens.", + "input": [ + "%trace on", + "%send Toggle to Lamp::lamp", + "%advance 1", + "%trace off" + ], + "expect": [ + "[trace] transition: off -> on" + ] + }, + { + "title": "Save, clear and load again", + "text": "%save writes the session model as SysML notation to this page's in-memory disk. %clear empties the session, and %load reads the file back in.", + "input": [ + "%save lamp.sysml", + "%clear", + "%load lamp.sysml", + "%list" + ], + "expect": [ + "saved", + "✓ package Lamp" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/assets/sysml-repl.js b/docs/assets/sysml-repl.js new file mode 100644 index 0000000000..1f52ae858a --- /dev/null +++ b/docs/assets/sysml-repl.js @@ -0,0 +1,659 @@ +// Runs the real sysml REPL (cmd/sysml built for js/wasm) in a page: an +// in-memory filesystem stands in for the disk and stdin, and mount() draws a +// terminal with history, completion and guided walkthroughs over it. +(function (root) { + "use strict"; + + var enc = new TextEncoder(); + var S_IFDIR = 0x4000, S_IFREG = 0x8000, S_IFCHR = 0x2000; + var C = { O_WRONLY: 1, O_RDWR: 2, O_CREAT: 64, O_EXCL: 128, O_TRUNC: 512, O_APPEND: 1024, O_DIRECTORY: 65536 }; + + function fsError(code) { + var e = new Error(code); + e.code = code; + return e; + } + + function normalize(path) { + var parts = []; + String(path).split("/").forEach(function (p) { + if (!p || p === ".") return; + if (p === "..") parts.pop(); else parts.push(p); + }); + return "/" + parts.join("/"); + } + + // MemFS answers the calls Go's syscall/fs_js makes of globalThis.fs, node's + // callback API, for one session: fd 0 is the stdin queue, 1 and 2 the output. + function MemFS(onOutput, onWaiting, defer) { + var self = this; + var ino = 1; + var nodes = { "/": { dir: true, ino: ino++, mtime: Date.now() } }; + var fds = {}, nextFd = 3; + var stdin = [], pendingRead = null, eof = false; + var decoders = { 1: new TextDecoder(), 2: new TextDecoder() }; + self.cwd = "/"; + + function abs(p) { return p.charAt(0) === "/" ? normalize(p) : normalize(self.cwd + "/" + p); } + function parent(p) { return normalize(p + "/.."); } + function stat(n) { + var size = n.dir || n.chr ? 0 : n.size; + return { + dev: 1, ino: n.ino, mode: (n.dir ? S_IFDIR | 493 : n.chr ? S_IFCHR | 438 : S_IFREG | 420), + nlink: 1, uid: 0, gid: 0, rdev: 0, size: size, blksize: 4096, blocks: Math.ceil(size / 512), + atimeMs: n.mtime, mtimeMs: n.mtime, ctimeMs: n.mtime, + isDirectory: function () { return !!n.dir; } + }; + } + function mkdirs(p) { + if (nodes[p]) return; + mkdirs(parent(p)); + nodes[p] = { dir: true, ino: ino++, mtime: Date.now() }; + } + function children(p) { + var pre = p === "/" ? "/" : p + "/"; + return Object.keys(nodes).filter(function (k) { + return k !== p && k.indexOf(pre) === 0 && k.slice(pre.length).indexOf("/") < 0; + }).map(function (k) { return k.slice(pre.length); }).sort(); + } + function grow(n, size) { + if (n.data.length >= size) return; + var d = new Uint8Array(Math.max(size, n.data.length * 2, 256)); + d.set(n.data.subarray(0, n.size)); + n.data = d; + } + function output(fd, bytes) { + onOutput(decoders[fd].decode(bytes, { stream: true }), fd); + } + function deliver() { + if (!pendingRead) return; + if (!stdin.length) { + if (!eof) return; + var done = pendingRead; + pendingRead = null; + done.cb(null, 0); + return; + } + var r = pendingRead, chunk = stdin[0], n = Math.min(r.length, chunk.length); + pendingRead = null; + r.buffer.set(chunk.subarray(0, n), r.offset); + if (n < chunk.length) stdin[0] = chunk.subarray(n); else stdin.shift(); + r.cb(null, n); + } + + self.addFile = function (path, content) { + var p = abs(path); + mkdirs(parent(p)); + var data = typeof content === "string" ? enc.encode(content) : new Uint8Array(content); + nodes[p] = { ino: ino++, data: data, size: data.length, mtime: Date.now() }; + }; + self.readFile = function (path) { + var n = nodes[abs(path)]; + return n && !n.dir ? new TextDecoder().decode(n.data.subarray(0, n.size)) : null; + }; + self.list = function () { + return Object.keys(nodes).filter(function (k) { return !nodes[k].dir; }).sort(); + }; + self.send = function (text) { + if (text) stdin.push(enc.encode(text)); + defer(deliver); + }; + self.eof = function () { eof = true; defer(deliver); }; + self.waiting = function () { return !!pendingRead && !stdin.length; }; + + var ok = function () { var cb = arguments[arguments.length - 1]; cb(null); }; + self.api = { + constants: C, + writeSync: function (fd, buf) { + if (fd === 1 || fd === 2) output(fd, buf); + return buf.length; + }, + write: function (fd, buf, offset, length, position, cb) { + var bytes = buf.subarray(offset, offset + length); + if (fd === 1 || fd === 2) { output(fd, bytes); cb(null, length); return; } + var f = fds[fd]; + if (!f || f.node.dir) { cb(fsError("EBADF")); return; } + var n = f.node, at = position != null ? position : f.append ? n.size : f.pos; + grow(n, at + length); + n.data.set(bytes, at); + n.size = Math.max(n.size, at + length); + n.mtime = Date.now(); + if (position == null) f.pos = at + length; + cb(null, length); + }, + read: function (fd, buffer, offset, length, position, cb) { + if (fd === 0) { + pendingRead = { buffer: buffer, offset: offset, length: length, cb: cb }; + if (stdin.length || eof) deliver(); else onWaiting(); + return; + } + var f = fds[fd]; + if (!f) { cb(fsError("EBADF")); return; } + if (f.node.dir) { cb(fsError("EISDIR")); return; } + var at = position != null ? position : f.pos, n = Math.max(0, Math.min(length, f.node.size - at)); + buffer.set(f.node.data.subarray(at, at + n), offset); + if (position == null) f.pos = at + n; + cb(null, n); + }, + open: function (path, flags, mode, cb) { + var p = abs(path), n = nodes[p]; + if (!n) { + if (!(flags & C.O_CREAT)) { cb(fsError("ENOENT")); return; } + var dir = nodes[parent(p)]; + if (!dir) { cb(fsError("ENOENT")); return; } + if (!dir.dir) { cb(fsError("ENOTDIR")); return; } + n = nodes[p] = { ino: ino++, data: new Uint8Array(0), size: 0, mtime: Date.now() }; + } else if ((flags & C.O_CREAT) && (flags & C.O_EXCL)) { + cb(fsError("EEXIST")); return; + } + if (n.dir && (flags & (C.O_WRONLY | C.O_RDWR))) { cb(fsError("EISDIR")); return; } + if (!n.dir && (flags & C.O_DIRECTORY)) { cb(fsError("ENOTDIR")); return; } + if (!n.dir && (flags & C.O_TRUNC)) n.size = 0; + var fd = nextFd++; + fds[fd] = { node: n, pos: 0, append: !!(flags & C.O_APPEND) }; + cb(null, fd); + }, + close: function (fd, cb) { delete fds[fd]; cb(null); }, + fstat: function (fd, cb) { + if (fd <= 2) { cb(null, stat({ chr: true, ino: fd + 1000, mtime: Date.now() })); return; } + var f = fds[fd]; + if (!f) cb(fsError("EBADF")); else cb(null, stat(f.node)); + }, + stat: function (path, cb) { + var n = nodes[abs(path)]; + if (!n) cb(fsError("ENOENT")); else cb(null, stat(n)); + }, + lstat: function (path, cb) { self.api.stat(path, cb); }, + readdir: function (path, cb) { + var p = abs(path), n = nodes[p]; + if (!n) cb(fsError("ENOENT")); else if (!n.dir) cb(fsError("ENOTDIR")); else cb(null, children(p)); + }, + mkdir: function (path, perm, cb) { + var p = abs(path); + if (nodes[p]) { cb(fsError("EEXIST")); return; } + if (!nodes[parent(p)]) { cb(fsError("ENOENT")); return; } + nodes[p] = { dir: true, ino: ino++, mtime: Date.now() }; + cb(null); + }, + rename: function (from, to, cb) { + var a = abs(from), b = abs(to); + if (!nodes[a]) { cb(fsError("ENOENT")); return; } + if (!nodes[parent(b)]) { cb(fsError("ENOENT")); return; } + Object.keys(nodes).forEach(function (k) { + if (k === a || k.indexOf(a + "/") === 0) { + nodes[b + k.slice(a.length)] = nodes[k]; + delete nodes[k]; + } + }); + cb(null); + }, + unlink: function (path, cb) { + var p = abs(path), n = nodes[p]; + if (!n) cb(fsError("ENOENT")); else if (n.dir) cb(fsError("EISDIR")); else { delete nodes[p]; cb(null); } + }, + rmdir: function (path, cb) { + var p = abs(path), n = nodes[p]; + if (!n) cb(fsError("ENOENT")); + else if (!n.dir) cb(fsError("ENOTDIR")); + else if (children(p).length) cb(fsError("ENOTEMPTY")); + else { delete nodes[p]; cb(null); } + }, + ftruncate: function (fd, len, cb) { + var f = fds[fd]; + if (!f || f.node.dir) { cb(fsError("EBADF")); return; } + grow(f.node, len); + if (len > f.node.size) f.node.data.fill(0, f.node.size, len); + f.node.size = len; + cb(null); + }, + truncate: function (path, len, cb) { + self.api.open(path, C.O_WRONLY, 0, function (err, fd) { + if (err) { cb(err); return; } + self.api.ftruncate(fd, len, function (e) { delete fds[fd]; cb(e); }); + }); + }, + fsync: ok, chmod: ok, fchmod: ok, chown: ok, fchown: ok, lchown: ok, utimes: ok, + readlink: function (p, cb) { cb(fsError("EINVAL")); }, + link: function (a, b, cb) { cb(fsError("ENOSYS")); }, + symlink: function (a, b, cb) { cb(fsError("ENOSYS")); } + }; + self.process = { + getuid: function () { return -1; }, getgid: function () { return -1; }, + geteuid: function () { return -1; }, getegid: function () { return -1; }, + getgroups: function () { throw fsError("ENOSYS"); }, + pid: -1, ppid: -1, + umask: function () { return 18; }, + cwd: function () { return self.cwd; }, + chdir: function (d) { + var p = abs(d); + if (!nodes[p] || !nodes[p].dir) throw fsError("ENOENT"); + self.cwd = p; + } + }; + self.path = { resolve: function () { return abs(Array.prototype.slice.call(arguments).join("/")); } }; + } + + var modules = {}; + + // compile fetches a gzipped binary once per URL, reporting bytes as they arrive. + function compile(url, onProgress) { + if (!modules[url]) { + modules[url] = fetch(url).then(function (r) { + if (!r.ok) throw new Error("the REPL did not download (HTTP " + r.status + ")"); + if (typeof DecompressionStream === "undefined") throw new Error("this browser cannot inflate the REPL"); + var total = +r.headers.get("Content-Length") || 0, got = 0; + var counted = r.body.pipeThrough(new TransformStream({ + transform: function (chunk, ctl) { + got += chunk.length; + if (onProgress) onProgress(got, total); + ctl.enqueue(chunk); + } + })); + return new Response(counted.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer(); + }).then(function (bytes) { return WebAssembly.compile(bytes); }); + modules[url].catch(function () { delete modules[url]; }); + } + return modules[url]; + } + + // start runs one REPL process on module. Go reads globalThis.fs, process and + // path once, at init, so they are swapped in for that synchronous start only. + function start(opts) { + var defer = opts.defer || function (fn) { setTimeout(fn, 0); }; + var vfs = new MemFS(opts.onOutput, opts.onWaiting || function () {}, defer); + Object.keys(opts.files || {}).forEach(function (p) { vfs.addFile(p, opts.files[p]); }); + if (opts.cwd) vfs.cwd = normalize(opts.cwd); + var go = new Go(); + var code = 0; + go.argv = ["sysml"].concat(opts.args || []); + go.env = Object.assign({ HOME: "/home/visitor", TERM: "dumb", OPENSYSML_RECORD_CACHE: "0" }, opts.env || {}); + var exit = go.exit; + go.exit = function (c) { code = c; exit.call(go, c); }; + return WebAssembly.instantiate(opts.module, go.importObject).then(function (instance) { + var saved = { fs: globalThis.fs, process: globalThis.process, path: globalThis.path }; + globalThis.fs = vfs.api; + globalThis.process = vfs.process; + globalThis.path = vfs.path; + var done; + try { + done = go.run(instance); + } finally { + globalThis.fs = saved.fs; + globalThis.process = saved.process; + globalThis.path = saved.path; + } + var session = { + fs: vfs, + send: vfs.send, + eof: vfs.eof, + waiting: vfs.waiting, + exited: false, + done: null, + // The REPL installs sysmlReplComplete once its session exists, which + // can be after go.run yields, so it is looked up on each request. + complete: function (head) { + var complete = globalThis.sysmlReplComplete; + if (typeof complete !== "function" || session.exited || !vfs.waiting()) return null; + try { return JSON.parse(complete(head)); } catch (e) { return null; } + } + }; + session.done = Promise.resolve(done).then(function () { + session.exited = true; + return code; + }); + return session; + }); + } + + function loadScript(src) { + if (typeof Go === "function") return Promise.resolve(); + return new Promise(function (resolve, reject) { + var s = document.createElement("script"); + s.src = src; + s.onload = resolve; + s.onerror = function () { reject(new Error("wasm_exec.js did not load")); }; + document.head.appendChild(s); + }); + } + + var HISTORY_KEY = "osml-repl-history", HISTORY_MAX = 500, OUTPUT_MAX = 4000; + + function loadHistory() { + try { + var h = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); + return Array.isArray(h) ? h.filter(function (x) { return typeof x === "string"; }) : []; + } catch (e) { return []; } + } + function saveHistory(h) { + try { localStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(-HISTORY_MAX))); } catch (e) { /* private mode */ } + } + + function lineClass(line) { + if (/^\s*(✓|=\s)/.test(line)) return "osml-ok"; + if (/^\s*✗|^\s*error:|^sysml: |: error: /.test(line)) return "osml-err"; + if (/: warning: |^\s*warning:/.test(line)) return "osml-hl"; + if (/^\s+(standing:|ID: |Use %)/.test(line)) return "osml-dim"; + return ""; + } + + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + + // mount wires the terminal markup inside rootEl (see docs/cli.md) to a REPL. + function mount(rootEl) { + var d = rootEl.dataset; + var out = rootEl.querySelector("[data-repl-out]"); + var input = rootEl.querySelector("[data-repl-in]"); + var promptEl = rootEl.querySelector("[data-repl-prompt]"); + var statusEl = rootEl.querySelector("[data-repl-status]"); + var startBtn = rootEl.querySelector("[data-repl-start]"); + var toursEl = rootEl.querySelector("[data-repl-tours]"); + var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + var session = null, starting = null, partial = "", history = loadHistory(), hIndex = history.length, draft = ""; + var queue = [], typing = false, examples = null, tours = []; + + function status(text, isError) { + statusEl.textContent = text; + statusEl.classList.toggle("osml-repl__status--err", !!isError); + } + function scroll() { out.scrollTop = out.scrollHeight; } + function appendLine(text, cls) { + out.appendChild(el("span", cls || lineClass(text) || null, text + "\n")); + while (out.childNodes.length > OUTPUT_MAX) out.removeChild(out.firstChild); + } + function write(text) { + var lines = (partial + text).split("\n"); + partial = lines.pop(); + lines.forEach(function (l) { appendLine(l); }); + scroll(); + } + function onWaiting() { + if (partial) { + promptEl.textContent = partial.replace(/ $/, "\u00a0"); + partial = ""; + } + rootEl.classList.remove("is-busy"); + input.disabled = false; + pump(); + } + + function fetchExamples() { + if (!examples) { + var list = (d.replExamples || "").split(",").filter(Boolean); + examples = Promise.all(list.map(function (name) { + return fetch(d.replExampleBase + name).then(function (r) { + if (!r.ok) throw new Error(name + " did not download (HTTP " + r.status + ")"); + return r.text(); + }).then(function (t) { return [name, t]; }); + })).then(function (pairs) { + var files = {}; + pairs.forEach(function (p) { files["examples/runtime-showcase/" + p[0]] = p[1]; }); + return files; + }); + examples.catch(function () { examples = null; }); + } + return examples; + } + + function boot() { + if (session && !session.exited) return Promise.resolve(session); + if (starting) return starting; + startBtn.disabled = true; + var fresh = !modules[d.replWasm]; + status(fresh ? "Downloading the sysml REPL\u2026" : "Starting a new session\u2026"); + starting = loadScript(d.replWasmExec).then(function () { + return Promise.all([compile(d.replWasm, function (got, total) { + var mb = function (n) { return (n / 1048576).toFixed(1); }; + status("Downloading the sysml REPL: " + mb(got) + (total ? " of " + mb(total) : "") + " MB\u2026"); + }), fetchExamples()]); + }).then(function (got) { + status("Starting the REPL\u2026"); + var t0 = performance.now(); + return start({ + module: got[0], + files: got[1], + onOutput: write, + onWaiting: onWaiting, + defer: function (fn) { requestAnimationFrame(function () { setTimeout(fn, 0); }); } + }).then(function (s) { + session = s; + rootEl.classList.add("is-live"); + startBtn.hidden = true; + input.disabled = false; + input.placeholder = "SysML or a %command \u2014 \u2191\u2193 history, Tab completes, Shift+Enter adds a line"; + status("Running the real sysml REPL in this page (started in " + + Math.round(performance.now() - t0) + " ms). The runtime-showcase models are in examples/runtime-showcase/."); + s.done.then(function () { + rootEl.classList.remove("is-live", "is-busy"); + promptEl.textContent = "$\u00a0"; + appendLine("[session ended \u2014 press Enter to start a new one]", "osml-dim"); + scroll(); + status("The session ended. Press Enter in the prompt, or run a walkthrough step, to start a new one."); + queue = []; + }); + return s; + }); + }).catch(function (e) { + status("The REPL could not start: " + e.message, true); + throw e; + }).then(function (s) { + starting = null; + startBtn.disabled = false; + return s; + }, function (e) { + starting = null; + startBtn.disabled = false; + throw e; + }); + return starting; + } + + function remember(line) { + if (line.trim() && history[history.length - 1] !== line) { + history.push(line); + if (history.length > HISTORY_MAX) history.splice(0, history.length - HISTORY_MAX); + saveHistory(history); + } + hIndex = history.length; + draft = ""; + } + + // submit hands one line to the REPL, echoing it after the prompt it answers. + function submit(line) { + appendLine(promptEl.textContent.replace(/\u00a0/g, " ") + line, "osml-repl__echo"); + scroll(); + rootEl.classList.add("is-busy"); + session.send(line + "\n"); + } + + function setInput(text) { + input.value = text; + input.style.height = "auto"; + input.style.height = input.scrollHeight + "px"; + input.style.overflowY = input.scrollHeight > input.clientHeight + 1 ? "auto" : "hidden"; + } + function splitLines(text) { return text.replace(/\r\n?/g, "\n").split("\n"); } + + // pump feeds queued lines one per prompt, typing those from a walkthrough + // where motion is allowed. + function pump() { + if (typing || !queue.length || !session || session.exited || !session.waiting()) return; + var next = queue.shift(), line = next.text; + if (!next.type || reduceMotion.matches || line.length > 160) { + submit(line); + return; + } + typing = true; + var i = 0, step = Math.max(1, Math.ceil(line.length / 40)); + (function type() { + i = Math.min(line.length, i + step); + setInput(line.slice(0, i)); + if (i < line.length) { setTimeout(type, 12); return; } + setTimeout(function () { + typing = false; + setInput(""); + submit(line); + }, 120); + })(); + } + + // run sends a block one line per prompt, as a terminal would, and keeps + // the block as one history entry. + function run(text, type) { + remember(text); + return boot().then(function () { + queue = queue.concat(splitLines(text).map(function (l) { return { text: l, type: type }; })); + pump(); + }, function () {}); + } + + function complete() { + if (!session || session.exited) return; + var pos = input.selectionStart, head = input.value.slice(0, pos), tail = input.value.slice(pos); + var c = session.complete(head.split("\n").pop()); + if (!c || !c.candidates || !c.candidates.length) return; + var prefix = c.prefix || "", cands = c.candidates; + var common = cands.reduce(function (a, b) { + var n = 0; + while (n < a.length && n < b.length && a[n] === b[n]) n++; + return a.slice(0, n); + }); + var base = head.slice(0, head.length - prefix.length); + if (cands.length === 1) { + var word = cands[0] + (/[/]$/.test(cands[0]) ? "" : " "); + setInput(base + word + tail); + input.setSelectionRange((base + word).length, (base + word).length); + return; + } + if (common.length > prefix.length) { + setInput(base + common + tail); + input.setSelectionRange((base + common).length, (base + common).length); + return; + } + appendLine(promptEl.textContent.replace(/\u00a0/g, " ") + input.value, "osml-repl__echo"); + appendLine(cands.slice(0, 60).join(" ") + (cands.length > 60 ? " \u2026 " + (cands.length - 60) + " more" : ""), "osml-dim"); + scroll(); + } + + input.addEventListener("keydown", function (e) { + if (typing) { e.preventDefault(); return; } + var v = input.value; + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (!session || session.exited) { + boot().then(function () { input.focus(); }, function () {}); + return; + } + if (!session.waiting() || queue.length) return; + setInput(""); + run(v, false); + } else if ((e.key === "ArrowUp" && v.lastIndexOf("\n", input.selectionStart - 1) < 0) || (e.ctrlKey && e.key === "p")) { + e.preventDefault(); + if (!hIndex) return; + if (hIndex === history.length) draft = v; + setInput(history[--hIndex]); + input.setSelectionRange(input.value.length, input.value.length); + } else if ((e.key === "ArrowDown" && v.indexOf("\n", input.selectionEnd) < 0) || (e.ctrlKey && e.key === "n")) { + e.preventDefault(); + if (hIndex >= history.length) return; + setInput(++hIndex === history.length ? draft : history[hIndex]); + input.setSelectionRange(input.value.length, input.value.length); + } else if (e.key === "Tab" && !e.shiftKey && input.value.trim()) { + e.preventDefault(); + complete(); + } else if (e.ctrlKey && e.key === "l") { + e.preventDefault(); + out.textContent = ""; + } else if (e.ctrlKey && e.key === "c" && input.selectionStart === input.selectionEnd) { + e.preventDefault(); + appendLine(promptEl.textContent.replace(/\u00a0/g, " ") + input.value + "^C", "osml-dim"); + setInput(""); + queue = []; + scroll(); + } else if (e.ctrlKey && e.key === "d" && !input.value && session && !session.exited) { + e.preventDefault(); + session.eof(); + } + }); + input.addEventListener("input", function () { setInput(input.value); }); + out.addEventListener("click", function () { + if (!window.getSelection().toString() && !input.disabled) input.focus(); + }); + startBtn.addEventListener("click", function () { boot().then(function () { input.focus(); }, function () {}); }); + + // Walkthroughs: each step narrates, shows its input, and types it into the REPL. + var tourIndex = 0, stepIndex = 0; + function renderTour() { + var tour = tours[tourIndex], step = tour.steps[stepIndex]; + toursEl.querySelectorAll("[data-tour]").forEach(function (b) { + b.setAttribute("aria-selected", String(+b.dataset.tour === tourIndex)); + }); + var card = toursEl.querySelector("[data-tour-card]"); + card.innerHTML = ""; + var head = el("p", "osml-repl__tourhead"); + head.appendChild(el("span", "osml-repl__tourstep", "Step " + (stepIndex + 1) + " of " + tour.steps.length)); + head.appendChild(document.createTextNode(" " + step.title)); + card.appendChild(head); + var text = el("div", "osml-repl__tourtext"); + text.innerHTML = step.text; + card.appendChild(text); + if (step.input && step.input.length) { + var pre = el("pre", "osml-repl__tourinput"); + pre.appendChild(el("code", null, step.input.join("\n"))); + card.appendChild(pre); + } + var nav = el("div", "osml-repl__tournav"); + var back = el("button", "osml-repl__btn", "\u25c0 Back"); + back.type = "button"; + back.disabled = stepIndex === 0; + back.addEventListener("click", function () { stepIndex--; renderTour(); }); + nav.appendChild(back); + if (step.input && step.input.length) { + var runBtn = el("button", "osml-repl__btn osml-repl__btn--go", "\u25b6 Run this step"); + runBtn.type = "button"; + runBtn.addEventListener("click", function () { + run(step.input.join("\n"), true); + if (stepIndex < tour.steps.length - 1) { stepIndex++; renderTour(); } + input.focus({ preventScroll: true }); + }); + nav.appendChild(runBtn); + } + var next = el("button", "osml-repl__btn", stepIndex < tour.steps.length - 1 ? "Skip \u25b6" : "Next walkthrough \u25b6"); + next.type = "button"; + next.disabled = stepIndex === tour.steps.length - 1 && tourIndex === tours.length - 1; + next.addEventListener("click", function () { + if (stepIndex < tour.steps.length - 1) stepIndex++; + else { tourIndex++; stepIndex = 0; } + renderTour(); + }); + nav.appendChild(next); + card.appendChild(nav); + } + if (toursEl && d.replTours) { + fetch(d.replTours).then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }).then(function (data) { + tours = data.tours; + var tabs = toursEl.querySelector("[data-tour-tabs]"); + tours.forEach(function (t, i) { + var b = el("button", "osml-repl__btn", (i + 1) + ". " + t.title); + b.type = "button"; + b.dataset.tour = i; + b.setAttribute("role", "tab"); + b.addEventListener("click", function () { tourIndex = i; stepIndex = 0; renderTour(); }); + tabs.appendChild(b); + }); + renderTour(); + }).catch(function (e) { + toursEl.querySelector("[data-tour-card]").textContent = "The walkthroughs did not load (" + e.message + ")."; + }); + } + } + + root.osmlRepl = { start: start, compile: compile, mount: mount, MemFS: MemFS }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/docs/cli.md b/docs/cli.md index 70be9470db..87b688d673 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,5 +1,5 @@ --- -description: The sysml command line — validator, analysis runner, interactive REPL, behavior engine, RDF exporter and document generator. Try the engine live in your browser. +description: The sysml command line — validator, analysis runner, interactive REPL, behavior engine, RDF exporter and document generator. Run the real REPL, with guided walkthroughs, in your browser. --- # The `sysml` command line @@ -204,66 +204,110 @@ behavior engine, RDF exporter and document generator. The same runtime sits behi opacity: 1; border-color: #fff; } - .osml-live__editor { - position: relative; - border: 1px solid rgba(255, 255, 255, .18); - border-bottom: none; - border-radius: .35rem .35rem 0 0; - background: #10132e; + .osml-repl__tours { + margin: 0 0 .8rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: .35rem; overflow: hidden; } - .osml-live__filename { - display: block; - padding: .3rem .7rem; - font-size: .58rem; - letter-spacing: .04em; - color: rgba(232, 234, 246, .55); - border-bottom: 1px solid rgba(255, 255, 255, .1); - } - .osml-live__editor textarea { - display: block; - width: 100%; - min-height: 11rem; - padding: .6rem .8rem; + .osml-repl__tabs { + display: flex; + flex-wrap: wrap; + gap: .2rem; + padding: .35rem .45rem; + background: var(--md-code-bg-color); + border-bottom: 1px solid var(--md-default-fg-color--lightest); + } + .osml-repl__btn { + padding: .2rem .55rem; + border: 1px solid transparent; + border-radius: 1rem; background: transparent; - border: none; - outline: none; - resize: vertical; - color: #e8eaf6; - font-family: var(--md-code-font-family, monospace); - font-size: .6rem; - line-height: 1.6; + color: var(--md-default-fg-color--light); + font: inherit; + font-size: .62rem; + cursor: pointer; } - .osml-live .osml-terminal { - border-radius: 0 0 .35rem .35rem; + .osml-repl__btn:hover, + .osml-repl__btn:focus-visible { color: var(--md-accent-fg-color); } + .osml-repl__btn[aria-selected="true"] { + border-color: var(--md-primary-fg-color); + color: var(--md-default-fg-color); + font-weight: 700; + } + .osml-repl__btn:disabled { opacity: .4; cursor: default; } + .osml-repl__btn--go { + background: var(--md-primary-fg-color); + color: var(--md-primary-bg-color); + font-weight: 700; + } + .osml-repl__btn--go:hover, + .osml-repl__btn--go:focus-visible { + background: var(--md-accent-fg-color); + color: var(--md-accent-bg-color); + } + .osml-repl__card { padding: .6rem .8rem .7rem; font-size: .7rem; } + .osml-repl__card p { margin: 0 0 .4rem; } + .osml-repl__tourhead { font-weight: 700; } + .osml-repl__tourstep { + margin-right: .3rem; + font-weight: 400; + color: var(--md-default-fg-color--light); + } + .osml-repl__tourtext { margin: 0 0 .5rem; } + .md-typeset .osml-repl__tourinput { + margin: 0 0 .5rem; + max-height: 9rem; + overflow: auto; } - .osml-live__row { + .md-typeset .osml-repl__tourinput > code { font-size: .62rem; } + .osml-repl__tournav { display: flex; flex-wrap: wrap; gap: .3rem; } + .osml-repl .osml-terminal pre { + height: 24rem; + padding: .7rem .9rem; + scrollbar-width: thin; + cursor: text; + } + .osml-repl .osml-terminal pre::-webkit-scrollbar { display: initial; } + .osml-repl .osml-repl__echo { color: #fff; } + .osml-repl__row { display: flex; align-items: center; gap: .6rem; - padding: .45rem .7rem; + padding: .45rem .9rem; border-top: 1px solid rgba(255, 255, 255, .12); background: rgba(255, 255, 255, .05); - } - .osml-live__prompt { - display: flex; - align-items: center; - flex: 1; + font-family: var(--md-code-font-family, monospace); font-size: .62rem; color: #e8eaf6; } - .osml-live__prompt input { + .osml-repl.is-busy .osml-repl__row { opacity: .6; } + .osml-repl__row .osml-repl__btn--go { font-family: var(--md-text-font); } + .osml-repl__prompt { display: flex; align-items: flex-start; flex: 1; min-width: 0; line-height: 1.65; } + .osml-repl__prompt .osml-prompt { white-space: pre; } + .osml-repl__prompt textarea { flex: 1; + min-width: 0; + max-height: 14rem; + margin: 0; + padding: 0; background: transparent; border: none; outline: none; + resize: none; + overflow: hidden; color: #e8eaf6; font: inherit; - font-family: var(--md-code-font-family, monospace); + line-height: inherit; + white-space: pre-wrap; } - .osml-live__prompt input::placeholder { - color: rgba(232, 234, 246, .4); + .osml-repl__prompt textarea::placeholder { color: rgba(232, 234, 246, .4); } + .osml-repl__status { + margin: .4rem 0 0; + font-size: .62rem; + color: var(--md-default-fg-color--light); } + .osml-repl__status--err { color: var(--md-typeset-del-color, #c62828); } @media (prefers-reduced-motion: reduce) { .osml-pane { transition: none; } } @@ -612,148 +656,59 @@ Vehicles::Car::wheels PartUsage -

Run it in your browser

-

The same engine compiles to WebAssembly — this is a real sysml -session running in the page, no server involved. Edit the model, %parse it, -then evaluate, instantiate and execute against it.

-
-
-model.sysml - +

Run it in your browser

+

This is the real sysml REPL, the same cmd/sysml you run in a +terminal, built for WebAssembly with the production build tags and running entirely in this +page. Nothing is sent to a server. Pick a walkthrough and press Run this step to type +each command into the prompt, or type your own. Arrow keys bring back earlier lines, including +those from your last visit, and Tab completes commands and names.

+
+
+
+
Loading the walkthroughs…
-
-
sysml-engine — wasm, in this page +
+
sysml — WebAssembly, in this page
-
$ sysml model.sysml — engine not loaded
-  press "load engine" — downloads ~7 MB once, then runs entirely here
-
-
- -sysml>  +
$ sysml        # the full REPL, about 12 MB, downloaded once when you start it
+
+
+ +
+

Running a walkthrough step starts the REPL too.

+

What the browser can't do: there is no disk, so %load and %save +work on an in-memory filesystem that holds the showcase models under +examples/runtime-showcase/ and is lost when the session ends. A page can't start +other programs either, so %check, %solve, %configure, +%optimize and %explain, which run an external SMT solver, report that +the solver is unavailable. The production build also leaves out code generation, Flexo +synchronization, SysML v1 migration, FMI and PDF rendering. Everything else in the +REPL command reference runs here as it does in a terminal.

+ ## What it does diff --git a/overrides/home.html b/overrides/home.html index 3b6eb1ce11..553526c8bd 100644 --- a/overrides/home.html +++ b/overrides/home.html @@ -731,19 +731,54 @@ color: #ffcdd2; opacity: 1; } + .osml-schematic__edit { margin: .7rem 0 0; } + .osml-schematic__edit[hidden] { display: none; } .osml-schematic__src { - max-height: 22rem; + display: block; + box-sizing: border-box; + width: 100%; + height: 22rem; + resize: vertical; overflow: auto; - margin: .7rem 0 0; + margin: 0; padding: .7rem .9rem; + border: 1px solid rgba(255, 255, 255, .25); border-radius: .3rem; background: rgba(0, 0, 0, .35); color: #e8eaf6; + font-family: var(--md-code-font-family, monospace); font-size: .58rem; line-height: 1.5; text-align: left; + tab-size: 4; + white-space: pre; + } + .osml-schematic__src:focus { outline: 2px solid var(--md-accent-fg-color); outline-offset: 1px; } + .osml-schematic__src[aria-invalid="true"] { border-color: #ef9a9a; } + .osml-schematic__hint { + margin: .4rem 0 0; + font-size: .6rem; + opacity: .85; + text-align: left; + } + .osml-schematic__hint code { + padding: 0 .2em; + background: rgba(0, 0, 0, .3); + color: inherit; + font-size: .95em; } - .osml-schematic__src[hidden] { display: none; } + .osml-schematic__hint button { + margin-left: .3rem; + padding: .1rem .5rem; + border: 1px solid rgba(255, 255, 255, .5); + border-radius: .2rem; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + } + .osml-schematic__hint button:hover, + .osml-schematic__hint button:focus { background-color: var(--md-accent-fg-color); } .osml-nodecard__note { margin-top: .45rem; font-size: .56rem; @@ -897,13 +932,16 @@

SysML v2 you can actually run.

Java · OMG reference -

One open interchange, three implementations, a version-controlled store. Drag the boxes around — click opens the project, right-click shows its attributes.

+

One open interchange, three implementations, a version-controlled store. Drag the boxes around — click opens the project, right-click shows its attributes. Edit the model and the engine redraws the diagram as you type.

- -

This diagram is a SysML model. Run it in your browser on the OpenSysML engine (about 7 MB, downloaded once).

+ +

This diagram is a SysML model. Run it or edit it in your browser on the OpenSysML engine (about 7 MB, downloaded once).

+
+ -