diff --git a/.agents/skills/testing-docs-site/SKILL.md b/.agents/skills/testing-docs-site/SKILL.md index 1b36b5b9a0..4b8e55e43c 100644 --- a/.agents/skills/testing-docs-site/SKILL.md +++ b/.agents/skills/testing-docs-site/SKILL.md @@ -46,6 +46,27 @@ messages, reload, and confirm the loaded file carries the intended rule before diagnosing a CSS fix as ineffective. Do not change repository configuration solely to make a preview work. +### The in-browser engines + +The landing diagram and the CLI page's REPL load generated assets: `docs/assets/sysml-engine.wasm.gz`, +`sysml-repl.wasm.gz`, `wasm_exec.js` and `repl-examples/`. They are gitignored; if any is missing, +run `make docs-engine-assets` before building the site. + +The REPL walkthroughs' inputs and expected output strings live in `docs/assets/repl-walkthroughs.json`. +Run each step through its button and check only newly appended terminal output. Wait for the input +queue to drain and `is-busy` to clear before sending the next command. + +To check the SMT refusal, declare a valid constraint first, for example +`constraint def Positive { in x : ScalarValues::Integer; x > 0 }`, then run `%check Positive`. Expect +the message that a WebAssembly build cannot start external processes. + +Completion considers every matching name, standard library included. `%inst` has several candidates; +`%instanti` has one. For a shared-prefix test, pick names that no library name shares. + +At mobile width, navigate through the header menu. `label[for="__drawer"]` also matches a hidden +overlay, so scope automation to `header label[for="__drawer"]`. Compare the document's width with the +viewport's; a code textarea's own horizontal scrolling is not page overflow. + ### Devin Secrets Needed None for the public local site and public outbound link checks. 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/host_js.go b/cmd/sysml/host_js.go new file mode 100644 index 0000000000..f109a91786 --- /dev/null +++ b/cmd/sysml/host_js.go @@ -0,0 +1,34 @@ +//go:build js + +package main + +import ( + "encoding/json" + "syscall/js" + + "github.com/Open-MBEE/OpenSysML/internal/frontend/repl" +) + +// hostInterruptLine is the line a page sends to interrupt the REPL (Ctrl-C), +// discarding an unfinished declaration. +const hostInterruptLine = "\x03" + +// 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/host_wasip1.go b/cmd/sysml/host_wasip1.go new file mode 100644 index 0000000000..87e4f6a95f --- /dev/null +++ b/cmd/sysml/host_wasip1.go @@ -0,0 +1,11 @@ +//go:build wasip1 + +package main + +import "github.com/Open-MBEE/OpenSysML/internal/frontend/repl" + +// hostInterruptLine is empty: WASI input is a plain stream with no interrupt. +const hostInterruptLine = "" + +// 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..cd8333bb8e 100644 --- a/cmd/sysml/prompt_plain.go +++ b/cmd/sysml/prompt_plain.go @@ -14,17 +14,19 @@ import ( // newLineInput opens the prompt's line reader over standard input. A WebAssembly // host has no line editor to hand: lines arrive one at a time from whatever the -// 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) { - return &plainReader{in: bufio.NewReader(os.Stdin), out: os.Stdout}, func() error { return nil }, nil +// host wired up. A browser page gets completion through the session and may send +// hostInterruptLine for Ctrl-C. The returned function closes the reader, which is +// nothing to do over standard input. +func newLineInput(sess *repl.Session) (repl.LineReader, func() error, error) { + exposeCompletion(sess) + return &plainReader{in: bufio.NewReader(os.Stdin), out: os.Stdout, interrupt: hostInterruptLine}, func() error { return nil }, nil } // plainReader yields the lines it is read from, writing each prompt before it waits. type plainReader struct { - in *bufio.Reader - out io.Writer + in *bufio.Reader + out io.Writer + interrupt string // a line that reads as repl.ErrInterrupt, when nonempty } // ReadLine writes the prompt and reads the next line, without its line ending, and @@ -43,7 +45,11 @@ func (r *plainReader) ReadLine(prompt string) (string, error) { return "", io.EOF } } - return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil + line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + if r.interrupt != "" && line == r.interrupt { + return "", repl.ErrInterrupt + } + return line, nil } // isTerminal reports whether the file descriptor is a terminal: no WebAssembly host 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..3d53cd7167 --- /dev/null +++ b/docs/assets/sysml-repl.js @@ -0,0 +1,683 @@ +// 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, + // interrupt is Ctrl-C: the REPL drops the declaration it is buffering. + interrupt: function () { vfs.send(INTERRUPT_LINE + "\n"); }, + 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 INTERRUPT_LINE = "\u0003"; + 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, typeTimer = null, pendingInterrupt = 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; + if (pendingInterrupt) { + pendingInterrupt = false; + if (atContinuation()) { interruptREPL(); return; } + } + pump(); + } + function atContinuation() { return /^\s*\.\.\.>/.test(promptEl.textContent); } + function interruptREPL() { + rootEl.classList.add("is-busy"); + session.interrupt(); + } + // interrupt is Ctrl-C: it drops the typed line, any queued walkthrough + // input, and the declaration the REPL is still collecting. + function interrupt() { + clearTimeout(typeTimer); + typing = false; + queue = []; + appendLine(promptEl.textContent.replace(/\u00a0/g, " ") + input.value + "^C", "osml-dim"); + setInput(""); + scroll(); + if (!session || session.exited) return; + if (!session.waiting()) pendingInterrupt = true; + else if (atContinuation()) interruptREPL(); + } + + 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) { typeTimer = setTimeout(type, 12); return; } + typeTimer = 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 (e.ctrlKey && e.key === "c" && input.selectionStart === input.selectionEnd) { + e.preventDefault(); + interrupt(); + return; + } + 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 === "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..e82489caf2 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[hidden] { display: none; } + .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__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).

+
+ -