diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..686862f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fable": { + "version": "5.2.0", + "commands": [ + "fable" + ], + "rollForward": false + } + } +} diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 70f06c3..1db9cdd 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -47,6 +47,9 @@ jobs: cache: npm cache-dependency-path: website/package-lock.json + - name: Run Fable JS tests + run: make fable-test + - name: Build website run: | cd website diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 0076155..d0ef261 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -51,6 +51,9 @@ jobs: cache: npm cache-dependency-path: website/package-lock.json + - name: Run Fable JS tests + run: make fable-test + - name: Build website run: | cd website diff --git a/.gitignore b/.gitignore index 4c9489a..92df424 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ Thumbs.db vscode-fscript/dist/ vscode-fscript/*.vsix + +src/FScript.JavaScript/dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 439a15d..17ce107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Added a Fable-compiled JavaScript embedding package for running FScript in Node and browser-compatible ESM hosts. +- Added website documentation and a browser sandbox for the Fable JavaScript FScript runtime. + ## [0.73.0] diff --git a/FScript.sln b/FScript.sln index a583cef..53e6684 100644 --- a/FScript.sln +++ b/FScript.sln @@ -29,6 +29,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FScript.TypeProvider", "src EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FScript.TypeProvider.Tests", "tests\FScript.TypeProvider.Tests\FScript.TypeProvider.Tests.fsproj", "{42D043DE-8987-4072-8841-DCB2144AC18C}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FScript.JavaScript", "src\FScript.JavaScript\FScript.JavaScript.fsproj", "{CEB16446-D96F-40A7-9771-F90B9A78D7B5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -171,6 +173,18 @@ Global {42D043DE-8987-4072-8841-DCB2144AC18C}.Release|x64.Build.0 = Release|Any CPU {42D043DE-8987-4072-8841-DCB2144AC18C}.Release|x86.ActiveCfg = Release|Any CPU {42D043DE-8987-4072-8841-DCB2144AC18C}.Release|x86.Build.0 = Release|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Debug|x64.ActiveCfg = Debug|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Debug|x64.Build.0 = Debug|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Debug|x86.ActiveCfg = Debug|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Debug|x86.Build.0 = Debug|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Release|Any CPU.Build.0 = Release|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Release|x64.ActiveCfg = Release|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Release|x64.Build.0 = Release|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Release|x86.ActiveCfg = Release|Any CPU + {CEB16446-D96F-40A7-9771-F90B9A78D7B5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -187,5 +201,6 @@ Global {9B840598-3B03-457B-B1BE-9701BFD0D40A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {14D91D30-8E5E-482A-940B-CC55F2DE80AA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {42D043DE-8987-4072-8841-DCB2144AC18C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {CEB16446-D96F-40A7-9771-F90B9A78D7B5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/Makefile b/Makefile index e918111..65921ec 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test smoke-tests verify-changelog release-prepare clean publish publish-darwin publish-linux publish-windows pack-nuget publish-all website website-install website-build website-version website-typecheck +.PHONY: build test fable-build fable-test smoke-tests verify-changelog release-prepare clean publish publish-darwin publish-linux publish-windows pack-nuget publish-all website website-install website-build website-version website-typecheck build: dotnet build FScript.sln -c Release @@ -6,6 +6,13 @@ build: test: dotnet test FScript.sln -c Release +fable-build: + dotnet tool restore + dotnet fable src/FScript.JavaScript/FScript.JavaScript.fsproj --outDir src/FScript.JavaScript/dist --sourceMaps false + +fable-test: fable-build + node --test src/FScript.JavaScript/test/*.test.mjs + smoke-tests: @set -e; \ for script in samples/*.fss; do \ diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index c42fe24..8ade466 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -39,6 +39,30 @@ Use this when: NuGet: - `MagnusOpera.FScript.Language` +Fable: +- The project is Fable-compatible for source package consumers. +- Fable hosts should use the JavaScript facade rather than consuming raw F# discriminated unions directly. + +### `FScript.JavaScript` +Role: +- Fable-compiled JavaScript facade for Node and browser-compatible ESM hosts. + +Responsibilities: +- Compile `FScript.Language` to JavaScript through Fable. +- Expose stable JS entry points such as `run`, `parse`, `infer`, `evaluate`, `load`, and `invoke`. +- Provide a small stateful session API for browser sandbox and REPL-like JavaScript hosts. +- Convert between FScript runtime values and tagged JavaScript objects. +- Accept JavaScript-provided externs with explicit type schemes. +- Resolve imports through host-provided virtual source maps or resolver callbacks. + +Use this when: +- You want to run or embed FScript from JavaScript. +- You need repeated invocation of exported script functions from a JS host. +- You want to provide JS externs without loading the .NET runtime extern catalog. + +npm: +- `@magnusopera/fscript` + ### `FScript.Runtime` Role: - Runtime integration layer and extern catalog. @@ -118,12 +142,20 @@ Use this when: 3. Host runs parse/infer/eval programmatically. 4. Host decides output, logging, and error handling behavior. +### JavaScript host path +1. Host app imports `@magnusopera/fscript` as an ESM package. +2. Host provides optional JS externs through `extern({ name, arity, scheme, invoke })`. +3. Host executes `run` for one-shot evaluation or `load` plus `invoke` for repeated calls. +4. Host receives tagged JS values and structured JS errors. +5. Host resolves imports from virtual paths using `sources` or `resolveImport`; no file-backed resolver is available in Fable builds. + ## Dependency direction - `FScript.Language` has no dependency on `FScript.Runtime`. +- `FScript.JavaScript` depends on `FScript.Language` and Fable packages. - `FScript.Runtime` depends on `FScript.Language` types. - `FScript.CSharpInterop` depends on both `FScript.Language` and `FScript.Runtime`. - `FScript.LanguageServer` depends on `FScript.CSharpInterop`. - `FScript.TypeProvider` depends on `FScript.Language` and `FScript.Runtime`. - `FScript` depends on both `FScript.Language` and `FScript.Runtime`. -This keeps the language engine reusable while runtime capabilities remain host-configurable. +This keeps the language engine reusable while runtime capabilities remain host-configurable. The JavaScript facade is intentionally separate from `.NET` runtime externs: v1 exposes core language hosting, virtual imports, tagged values, and JS-provided externs, while filesystem, console, task, and other runtime modules stay in the .NET runtime layer. diff --git a/docs/specs/embedding-fscript-language.md b/docs/specs/embedding-fscript-language.md index 9c244cd..f27dfcb 100644 --- a/docs/specs/embedding-fscript-language.md +++ b/docs/specs/embedding-fscript-language.md @@ -25,6 +25,28 @@ Primary entry points are in module `FScript`: - `FScript.evalWithExterns : ExternalFunction list -> TypeInfer.TypedProgram -> Value` - `FScript.run : string -> Value` (parse + infer + eval without externs) +JavaScript hosts should use the Fable-compiled package surface instead of raw F# discriminated unions. The ESM package is published as `@magnusopera/fscript` and exports: + +- `run(source, options?)` +- `parse(source, options?)` +- `infer(program, externs?)` +- `evaluate(typedProgram, externs?)` +- `load(source, options?)` +- `invoke(loaded, name, args)` +- `listFunctions(loaded)` +- `listValues(loaded)` +- `getValue(loaded, name)` +- `formatValue(value)` +- `toJs(value)` +- `fromJs(value, expectedType?)` +- `createSession(options?)` +- `submit(session, source)` +- `resetSession(session)` +- `T` type and scheme builders +- `extern({ name, arity, scheme, invoke })` + +The JavaScript package targets Node and browser-compatible ESM hosts. It embeds `FScript.Language` through Fable and does not include the .NET `FScript.Runtime` extern catalog. + ## Running scripts ```fsharp @@ -60,6 +82,85 @@ let typed = FScript.inferWithExterns [ toUpperExtern ] program let result = FScript.evalWithExterns [ toUpperExtern ] typed ``` +## JavaScript embedding + +Use `run` for one-shot execution and `load` when the host will invoke exported functions repeatedly. + +```javascript +import { T, extern, run, load, invoke, getValue } from "@magnusopera/fscript"; + +const shout = extern({ + name: "Host.shout", + arity: 1, + scheme: T.scheme(T.func(T.string, T.string)), + invoke(args) { + return { kind: "string", value: `${args[0].value}!` }; + } +}); + +const value = run("Host.shout \"fable\"", { externs: [shout] }); +// value = { kind: "string", value: "fable!" } + +const loaded = load("[] let add x y = x + y\n[] let answer = 42"); +const sum = invoke(loaded, "add", [1, 2]); +const answer = getValue(loaded, "answer"); +``` + +JavaScript values crossing the host boundary use tagged objects: + +- `{ kind: "unit" }` +- `{ kind: "int", value: 42n }` +- `{ kind: "float", value: 3.14 }` +- `{ kind: "bool", value: true }` +- `{ kind: "string", value: "text" }` +- `{ kind: "list", values: [...] }` +- `{ kind: "tuple", values: [...] }` +- `{ kind: "record", fields: { ... } }` +- `{ kind: "map", entries: [{ key, value }] }` +- `{ kind: "option", value: null | taggedValue }` +- `{ kind: "union", typeName, caseName, value: null | taggedValue }` +- `{ kind: "type", name }` +- `{ kind: "opaque", valueType }` + +`int` values are emitted as JavaScript `bigint`. Inputs may use `bigint`, safe integer `number`, string, or tagged `{ kind: "int", value }` forms. Functions, externals, tasks, and union constructors are opaque values; exported functions are invoked through `invoke`. + +JavaScript errors thrown by the facade have `kind: "fscript-error"`, `phase` (`parse`, `type`, `eval`, or `host`), `message`, and span data when the underlying language stage provides it. + +### JavaScript sessions + +Browser sandbox and REPL-like hosts can use the stateful session API: + +```javascript +const session = createSession({ + rootDirectory: "/", + entryFile: "/main.fss" +}); + +submit(session, "let add x y = x + y"); +const result = submit(session, "add 20 22"); +// result = { kind: "session-result", hasValue: true, value, text: "42", retainedCount: 1 } + +resetSession(session); +``` + +Sessions retain submitted declarations and type declarations. Expression-only submissions are evaluated against retained declarations and return a formatted `text` result plus the tagged `value`. The browser session API mirrors the CLI REPL's core state model, but it does not include console-specific controls such as prompts, EOF handling, or default .NET runtime externs. + +### JavaScript imports + +Fable builds do not use the .NET file-backed import resolver. A JavaScript host must supply imports through virtual paths: + +```javascript +const loaded = load("import \"shared.fss\" as Shared\n[] let value = Shared.inc 41", { + rootDirectory: "/", + entryFile: "/main.fss", + sources: { + "/shared.fss": "let inc x = x + 1" + } +}); +``` + +The facade also accepts `resolveImport(path)` when imports should be loaded lazily. In JavaScript, imports are confined to the virtual `rootDirectory`, must resolve to `.fss` paths, and must return source text from the host-provided source map or callback. File system reads and symlink checks remain .NET-only behavior. + ## Loading once and invoking by name Use `FScript.Runtime.ScriptHost` when a host needs reusable loading and direct function invocation. diff --git a/src/FScript.JavaScript/FScript.JavaScript.fsproj b/src/FScript.JavaScript/FScript.JavaScript.fsproj new file mode 100644 index 0000000..c4e0cdf --- /dev/null +++ b/src/FScript.JavaScript/FScript.JavaScript.fsproj @@ -0,0 +1,23 @@ + + + + netstandard2.1 + true + false + disable + $(NoWarn);FS0988 + + + + + + + + + + + + + + + diff --git a/src/FScript.JavaScript/Library.fs b/src/FScript.JavaScript/Library.fs new file mode 100644 index 0000000..22cf386 --- /dev/null +++ b/src/FScript.JavaScript/Library.fs @@ -0,0 +1,563 @@ +module FScript.JavaScript + +open System +open System.Globalization +open Fable.Core +open Fable.Core.JsInterop +open FScript.Language + +type ParsedProgramHandle = + { kind: string + program: FScript.Language.Program + externs: ExternalFunction list } + +type TypedProgramHandle = + { kind: string + typedProgram: TypeInfer.TypedProgram + externs: ExternalFunction list } + +type LoadedScriptHandle = + { kind: string + typeDefs: Map + env: Env + exportedFunctionNames: string list + exportedFunctionSet: Set + exportedFunctions: Map + exportedValueNames: string list + exportedValueSet: Set + exportedValues: Map + lastValue: Value + externs: ExternalFunction list } + +type SessionHandle = + { kind: string + mutable retainedProgram: FScript.Language.Program + options: obj + externs: ExternalFunction list } + +[] +let private isNullOrUndefined (_value: obj) : bool = jsNative + +[] +let private jsTypeOf (_value: obj) : string = jsNative + +[] +let private getProperty (_target: obj) (_name: string) : obj = jsNative + +[] +let private isArray (_value: obj) : bool = jsNative + +[] +let private isInteger (_value: obj) : bool = jsNative + +[] +let private isSafeInteger (_value: obj) : bool = jsNative + +[] +let private objectKeys (_value: obj) : string array = jsNative + +[] +let private callOne (_fn: obj) (_arg: obj) : obj = jsNative + +[ { const e = new Error($0.message); Object.assign(e, $0); throw e; })()")>] +let private throwJs (_payload: obj) : 'T = jsNative + +[ { throw $0; })()")>] +let private rethrowJs (_error: obj) : 'T = jsNative + +[] +let private nullObj : obj = jsNative + +let private tryGetProperty name value = + if isNullOrUndefined value then + None + else + let property = getProperty value name + if isNullOrUndefined property then None else Some property + +let private getRequiredProperty name value = + match tryGetProperty name value with + | Some property -> property + | None -> failwith $"Missing required property '{name}'" + +let private isFScriptError value = + match tryGetProperty "kind" value with + | Some kind -> unbox kind = "fscript-error" + | None -> false + +let private positionToObj (position: Position) = + createObj + [ "file" ==> (position.File |> Option.map box |> Option.defaultValue nullObj) + "line" ==> position.Line + "column" ==> position.Column ] + +let private spanToObj (span: Span) = + createObj + [ "start" ==> positionToObj span.Start + "end" ==> positionToObj span.End ] + +let private throwLanguageError phase message span = + throwJs + (createObj + [ "kind" ==> "fscript-error" + "phase" ==> phase + "message" ==> message + "span" ==> spanToObj span ]) + +let private withStructuredErrors work = + try + work () + with + | ParseException error -> throwLanguageError "parse" error.Message error.Span + | TypeException error -> throwLanguageError "type" error.Message error.Span + | EvalException error -> throwLanguageError "eval" error.Message error.Span + | ex -> + let error = box ex + if isFScriptError error then + rethrowJs error + else + throwJs + (createObj + [ "kind" ==> "fscript-error" + "phase" ==> "host" + "message" ==> ex.Message ]) + +let private int64FromObj value = + match jsTypeOf value with + | "bigint" -> unbox value + | "number" -> + if not (isSafeInteger value) then + failwith "Expected a safe integer" + int64 (unbox value) + | "string" -> +#if FABLE_COMPILER + Int64.Parse(unbox value) +#else + Int64.Parse(unbox value, CultureInfo.InvariantCulture) +#endif + | _ -> failwith "Expected int value" + +let private intFromObj value = + match jsTypeOf value with + | "number" -> int (unbox value) + | "bigint" -> int (unbox value) + | "string" -> +#if FABLE_COMPILER + Int32.Parse(unbox value) +#else + Int32.Parse(unbox value, CultureInfo.InvariantCulture) +#endif + | _ -> failwith "Expected numeric value" + +let private mapKeyToTagged key = + match key with + | MKString value -> + createObj [ "kind" ==> "string"; "value" ==> value ] + | MKInt value -> + createObj [ "kind" ==> "int"; "value" ==> value ] + +let rec private valueToTagged value : obj = + match value with + | VUnit -> createObj [ "kind" ==> "unit" ] + | VInt value -> createObj [ "kind" ==> "int"; "value" ==> value ] + | VFloat value -> createObj [ "kind" ==> "float"; "value" ==> value ] + | VBool value -> createObj [ "kind" ==> "bool"; "value" ==> value ] + | VString value -> createObj [ "kind" ==> "string"; "value" ==> value ] + | VList values -> + createObj + [ "kind" ==> "list" + "values" ==> (values |> List.map valueToTagged |> List.toArray) ] + | VTuple values -> + createObj + [ "kind" ==> "tuple" + "values" ==> (values |> List.map valueToTagged |> List.toArray) ] + | VRecord fields -> + let fieldObject = + fields + |> Map.toList + |> List.map (fun (name, fieldValue) -> name ==> valueToTagged fieldValue) + |> createObj + createObj [ "kind" ==> "record"; "fields" ==> fieldObject ] + | VMap fields -> + let entries = + fields + |> Map.toList + |> List.map (fun (key, entryValue) -> + createObj + [ "key" ==> mapKeyToTagged key + "value" ==> valueToTagged entryValue ]) + |> List.toArray + createObj [ "kind" ==> "map"; "entries" ==> entries ] + | VOption None -> + createObj [ "kind" ==> "option"; "value" ==> nullObj ] + | VOption (Some value) -> + createObj [ "kind" ==> "option"; "value" ==> valueToTagged value ] + | VUnionCase (typeName, caseName, payload) -> + createObj + [ "kind" ==> "union" + "typeName" ==> typeName + "caseName" ==> caseName + "value" ==> (payload |> Option.map valueToTagged |> Option.defaultValue nullObj) ] + | VUnionCtor (typeName, caseName) -> + createObj + [ "kind" ==> "opaque" + "valueType" ==> "union-constructor" + "typeName" ==> typeName + "caseName" ==> caseName ] + | VTypeToken value -> + createObj [ "kind" ==> "type"; "name" ==> Types.typeToString value ] + | VTask _ -> + createObj [ "kind" ==> "opaque"; "valueType" ==> "task" ] + | VClosure _ -> + createObj [ "kind" ==> "opaque"; "valueType" ==> "function" ] + | VExternal (externalFunction, existingArgs) -> + createObj + [ "kind" ==> "opaque" + "valueType" ==> "external" + "name" ==> externalFunction.Name + "applied" ==> existingArgs.Length + "arity" ==> externalFunction.Arity ] + +let rec private valueFromJs value : Value = + if isNullOrUndefined value then + VUnit + elif isArray value then + value + |> unbox + |> Array.toList + |> List.map valueFromJs + |> VList + else + match jsTypeOf value with + | "boolean" -> VBool (unbox value) + | "bigint" -> VInt (unbox value) + | "number" -> + if isInteger value && isSafeInteger value then + VInt (int64 (unbox value)) + else + VFloat (unbox value) + | "string" -> VString (unbox value) + | "object" -> + match tryGetProperty "kind" value with + | Some kindValue -> + match unbox kindValue with + | "unit" -> VUnit + | "int" -> getRequiredProperty "value" value |> int64FromObj |> VInt + | "float" -> getRequiredProperty "value" value |> unbox |> VFloat + | "bool" -> getRequiredProperty "value" value |> unbox |> VBool + | "string" -> getRequiredProperty "value" value |> unbox |> VString + | "list" -> + getRequiredProperty "values" value + |> unbox + |> Array.toList + |> List.map valueFromJs + |> VList + | "tuple" -> + getRequiredProperty "values" value + |> unbox + |> Array.toList + |> List.map valueFromJs + |> VTuple + | "record" -> + let fields = getRequiredProperty "fields" value + objectKeys fields + |> Array.toList + |> List.map (fun name -> name, getProperty fields name |> valueFromJs) + |> Map.ofList + |> VRecord + | "map" -> + getRequiredProperty "entries" value + |> unbox + |> Array.toList + |> List.map (fun entry -> + let keyValue = getRequiredProperty "key" entry |> valueFromJs + let key = + match keyValue with + | VString text -> MKString text + | VInt number -> MKInt number + | _ -> failwith "Map key must be string or int" + key, getRequiredProperty "value" entry |> valueFromJs) + |> Map.ofList + |> VMap + | "option" -> + match tryGetProperty "value" value with + | None -> VOption None + | Some optionValue when isNullOrUndefined optionValue -> VOption None + | Some optionValue -> VOption (Some (valueFromJs optionValue)) + | "union" -> + let payload = + match tryGetProperty "value" value with + | None -> None + | Some payloadValue when isNullOrUndefined payloadValue -> None + | Some payloadValue -> Some (valueFromJs payloadValue) + VUnionCase( + getRequiredProperty "typeName" value |> unbox, + getRequiredProperty "caseName" value |> unbox, + payload) + | other -> failwith $"Unsupported FScript tagged value kind '{other}'" + | None -> + objectKeys value + |> Array.toList + |> List.map (fun name -> name, getProperty value name |> valueFromJs) + |> Map.ofList + |> VRecord + | actual -> failwith $"Unsupported JavaScript value type '{actual}'" + +let private externsFromArray value = + if isNullOrUndefined value then + [] + else + value |> unbox |> Array.toList + +let private externsFromOptions options = + match tryGetProperty "externs" options with + | Some value -> externsFromArray value + | None -> [] + +let private optionString name fallback options = + match tryGetProperty name options with + | Some value -> unbox value + | None -> fallback + +let private resolverFromOptions options = + let sourceMap = tryGetProperty "sources" options + let callback = tryGetProperty "resolveImport" options + fun path -> + match callback with + | Some resolve -> + let value = callOne resolve (box path) + if isNullOrUndefined value then + None + else + Some (unbox value) + | None -> + match sourceMap with + | Some sources -> + let value = getProperty sources path + if isNullOrUndefined value then None else Some (unbox value) + | None -> None + +let private parseSource source options externs : ParsedProgramHandle = + let rootDirectory = optionString "rootDirectory" "/" options + let entryFile = optionString "entryFile" "/main.fss" options + let resolver = resolverFromOptions options + let program = + IncludeResolver.parseProgramFromSourceWithIncludesResolver + rootDirectory + entryFile + source + resolver + { kind = "program"; program = program; externs = externs } + +let private isCallable value = + match value with + | VClosure _ + | VExternal _ + | VUnionCtor _ -> true + | _ -> false + +let private declaredExportedNames (program: TypeInfer.TypedProgram) = + program + |> List.collect (function + | TypeInfer.TSLet(name, _, _, _, isExported, _) when isExported -> [ name ] + | TypeInfer.TSLetRecGroup(bindings, isExported, _) when isExported -> bindings |> List.map (fun (name, _, _, _) -> name) + | _ -> []) + +let private hasExpression statements = + statements + |> List.exists (function + | SExpr _ -> true + | _ -> false) + +let private retainedStatements statements = + statements + |> List.filter (function + | SExpr _ -> false + | _ -> true) + +let private loadTyped typed externs = + let state = Eval.evalProgramWithExternsState externs typed + let exportedNames = declaredExportedNames typed |> List.distinct |> List.sort + let functionNames = + exportedNames + |> List.filter (fun name -> + match state.Env.TryFind name with + | Some value -> isCallable value + | None -> false) + let functionSet = functionNames |> Set.ofList + let functions = + functionNames + |> List.choose (fun name -> state.Env.TryFind name |> Option.map (fun value -> name, value)) + |> Map.ofList + let valueNames = + exportedNames + |> List.filter (fun name -> + match state.Env.TryFind name with + | Some value -> not (isCallable value) + | None -> false) + let valueSet = valueNames |> Set.ofList + let values = + valueNames + |> List.choose (fun name -> state.Env.TryFind name |> Option.map (fun value -> name, value)) + |> Map.ofList + { kind = "loaded" + typeDefs = state.TypeDefs + env = state.Env + exportedFunctionNames = functionNames + exportedFunctionSet = functionSet + exportedFunctions = functions + exportedValueNames = valueNames + exportedValueSet = valueSet + exportedValues = values + lastValue = state.LastValue + externs = externs } + +let parse (source: string) (options: obj) : obj = + withStructuredErrors (fun () -> + let externs = externsFromOptions options + parseSource source options externs |> box) + +let infer (program: obj) (externs: obj) : obj = + withStructuredErrors (fun () -> + let parsed = unbox program + let externs = + if isNullOrUndefined externs then parsed.externs else externsFromArray externs + ({ kind = "typedProgram" + typedProgram = TypeInfer.inferProgramWithExterns externs parsed.program + externs = externs }: TypedProgramHandle) + |> box) + +let evaluate (typedProgram: obj) (externs: obj) : obj = + withStructuredErrors (fun () -> + let typed = unbox typedProgram + let externs = + if isNullOrUndefined externs then typed.externs else externsFromArray externs + Eval.evalProgramWithExterns externs typed.typedProgram |> valueToTagged) + +let load (source: string) (options: obj) : obj = + withStructuredErrors (fun () -> + let externs = externsFromOptions options + let parsed = parseSource source options externs + let typed = TypeInfer.inferProgramWithExterns externs parsed.program + loadTyped typed externs |> box) + +let run (source: string) (options: obj) : obj = + withStructuredErrors (fun () -> + let loaded = load source options |> unbox + valueToTagged loaded.lastValue) + +let createSession (options: obj) : obj = + withStructuredErrors (fun () -> + ({ kind = "session" + retainedProgram = [] + options = options + externs = externsFromOptions options }: SessionHandle) + |> box) + +let resetSession (session: obj) : obj = + withStructuredErrors (fun () -> + let session = unbox session + session.retainedProgram <- [] + box session) + +let submit (session: obj) (source: string) : obj = + withStructuredErrors (fun () -> + let session = unbox session + let parsed = parseSource source session.options session.externs + let candidate = session.retainedProgram @ parsed.program + let typed = TypeInfer.inferProgramWithExterns session.externs candidate + let state = Eval.evalProgramWithExternsState session.externs typed + let submittedHasExpression = hasExpression parsed.program + session.retainedProgram <- session.retainedProgram @ retainedStatements parsed.program + createObj + [ "kind" ==> "session-result" + "hasValue" ==> submittedHasExpression + "value" ==> (if submittedHasExpression then valueToTagged state.LastValue else nullObj) + "text" ==> (if submittedHasExpression then Pretty.valueToString state.LastValue else "") + "retainedCount" ==> session.retainedProgram.Length ]) + +let listFunctions (loaded: obj) : string array = + withStructuredErrors (fun () -> + let loaded = unbox loaded + loaded.exportedFunctionNames |> List.toArray) + +let listValues (loaded: obj) : string array = + withStructuredErrors (fun () -> + let loaded = unbox loaded + loaded.exportedValueNames |> List.toArray) + +let getValue (loaded: obj) (name: string) : obj = + withStructuredErrors (fun () -> + let loaded = unbox loaded + if not (loaded.exportedValueSet.Contains name) then + raise (EvalException { Message = $"Unknown exported value '{name}'"; Span = Span.mk (Span.pos 0 0) (Span.pos 0 0) }) + loaded.exportedValues[name] |> valueToTagged) + +let invoke (loaded: obj) (name: string) (args: obj) : obj = + withStructuredErrors (fun () -> + let loaded = unbox loaded + if not (loaded.exportedFunctionSet.Contains name) then + if loaded.exportedValueSet.Contains name then + raise (EvalException { Message = $"'{name}' is a value and cannot be invoked"; Span = Span.mk (Span.pos 0 0) (Span.pos 0 0) }) + else + raise (EvalException { Message = $"Unknown exported function '{name}'"; Span = Span.mk (Span.pos 0 0) (Span.pos 0 0) }) + let fnValue = loaded.exportedFunctions[name] + let values = + if isNullOrUndefined args then + [] + else + args |> unbox |> Array.toList |> List.map valueFromJs + Eval.invokeValue loaded.typeDefs fnValue values |> valueToTagged) + +let formatValue (value: obj) : string = + withStructuredErrors (fun () -> valueFromJs value |> Pretty.valueToString) + +let toJs (value: obj) : obj = + withStructuredErrors (fun () -> value |> unbox |> valueToTagged) + +let fromJs (value: obj) (_expectedType: obj) : obj = + withStructuredErrors (fun () -> valueFromJs value |> box) + +let T = + createObj + [ "unit" ==> TUnit + "int" ==> TInt + "float" ==> TFloat + "bool" ==> TBool + "string" ==> TString + "typeToken" ==> TTypeToken + "var" ==> fun value -> TVar (intFromObj value) + "list" ==> fun value -> TList (unbox value) + "task" ==> fun value -> TTask (unbox value) + "option" ==> fun value -> TOption (unbox value) + "map" ==> fun value -> TMap(TString, unbox value) + "tuple" ==> fun values -> values |> unbox |> Array.toList |> List.map unbox |> TTuple + "record" ==> fun fields -> + objectKeys fields + |> Array.toList + |> List.map (fun name -> name, getProperty fields name |> unbox) + |> Map.ofList + |> TRecord + "func" ==> fun left right -> TFun(unbox left, unbox right) + "scheme" ==> fun value -> Forall([], unbox value) + "forall" ==> fun vars value -> + let vars = vars |> unbox |> Array.toList |> List.map intFromObj + Forall(vars, unbox value) ] + +let ``extern`` (definition: obj) : ExternalFunction = + withStructuredErrors (fun () -> + let name = getRequiredProperty "name" definition |> unbox + let arity = getRequiredProperty "arity" definition |> intFromObj + let scheme = getRequiredProperty "scheme" definition |> unbox + let invoke = getRequiredProperty "invoke" definition + { Name = name + Arity = arity + Scheme = scheme + Impl = + fun _ args -> + args + |> List.map valueToTagged + |> List.toArray + |> box + |> callOne invoke + |> valueFromJs }) diff --git a/src/FScript.JavaScript/README.md b/src/FScript.JavaScript/README.md new file mode 100644 index 0000000..5e48784 --- /dev/null +++ b/src/FScript.JavaScript/README.md @@ -0,0 +1,5 @@ +# @magnusopera/fscript + +Fable-compiled JavaScript facade for the FScript language engine. + +This package exposes parser, inference, evaluation, exported-function invocation, REPL-like sessions, virtual imports, and JS-provided externs for Node and browser-compatible ESM hosts. diff --git a/src/FScript.JavaScript/package.json b/src/FScript.JavaScript/package.json new file mode 100644 index 0000000..49d2cf7 --- /dev/null +++ b/src/FScript.JavaScript/package.json @@ -0,0 +1,16 @@ +{ + "name": "@magnusopera/fscript", + "version": "0.0.0", + "type": "module", + "main": "./dist/Library.js", + "exports": "./dist/Library.js", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "dotnet fable FScript.JavaScript.fsproj --outDir dist --sourceMaps false", + "test": "node --test test/*.test.mjs" + }, + "license": "MIT" +} diff --git a/src/FScript.JavaScript/test/fable.test.mjs b/src/FScript.JavaScript/test/fable.test.mjs new file mode 100644 index 0000000..c644995 --- /dev/null +++ b/src/FScript.JavaScript/test/fable.test.mjs @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + T, + extern, + run, + load, + invoke, + listFunctions, + listValues, + getValue, + parse, + infer, + evaluate, + formatValue, + createSession, + resetSession, + submit +} from "../dist/Library.js"; + +test("run returns tagged primitive values", () => { + const result = run("let x = 41\nx + 1"); + assert.equal(result.kind, "int"); + assert.equal(result.value, 42n); +}); + +test("load and invoke exported functions repeatedly", () => { + const script = "[] let add x y = x + y\n[] let answer = 42"; + const loaded = load(script); + assert.deepEqual(listFunctions(loaded), ["add"]); + assert.deepEqual(listValues(loaded), ["answer"]); + assert.equal(getValue(loaded, "answer").value, 42n); + assert.equal(invoke(loaded, "add", [1, 2]).value, 3n); + assert.equal(invoke(loaded, "add", [10n, 20n]).value, 30n); +}); + +test("virtual imports resolve from an in-memory source map", () => { + const loaded = load("import \"shared.fss\" as Shared\n[] let value = Shared.inc 41", { + rootDirectory: "/", + entryFile: "/main.fss", + sources: { + "/shared.fss": "let inc x = x + 1" + } + }); + assert.equal(getValue(loaded, "value").value, 42n); +}); + +test("parse, infer, and evaluate handles compose", () => { + const program = parse("let x = 20\nx + 22"); + const typed = infer(program); + const result = evaluate(typed); + assert.equal(result.kind, "int"); + assert.equal(result.value, 42n); + assert.equal(formatValue(result), "42"); +}); + +test("session submissions retain declarations", () => { + const session = createSession(); + const first = submit(session, "let add x y = x + y"); + assert.equal(first.hasValue, false); + assert.equal(first.retainedCount, 1); + const second = submit(session, "add 20 22"); + assert.equal(second.hasValue, true); + assert.equal(second.value.value, 42n); + resetSession(session); + assert.throws(() => submit(session, "add 1 2"), error => { + assert.equal(error.kind, "fscript-error"); + assert.equal(error.phase, "type"); + return true; + }); +}); + +test("errors are structured and span-aware", () => { + assert.throws( + () => run("let = 1"), + error => { + assert.equal(error.kind, "fscript-error"); + assert.equal(error.phase, "parse"); + assert.equal(typeof error.message, "string"); + assert.equal(error.span.start.line, 1); + return true; + } + ); +}); + +test("JavaScript externs round-trip typed arguments and results", () => { + const shout = extern({ + name: "Host.shout", + arity: 1, + scheme: T.scheme(T.func(T.string, T.string)), + invoke(args) { + assert.deepEqual(args, [{ kind: "string", value: "fable" }]); + return { kind: "string", value: `${args[0].value}!` }; + } + }); + const result = run("Host.shout \"fable\"", { externs: [shout] }); + assert.deepEqual(result, { kind: "string", value: "fable!" }); +}); diff --git a/src/FScript.Language/BuiltinFunctions.fs b/src/FScript.Language/BuiltinFunctions.fs index b1b898b..f3029e0 100644 --- a/src/FScript.Language/BuiltinFunctions.fs +++ b/src/FScript.Language/BuiltinFunctions.fs @@ -14,6 +14,28 @@ module BuiltinFunctions = | Some value -> value | None -> failwith $"Missing builtin signature for '{name}'" + let private tryParseInt64 (text: string) = +#if FABLE_COMPILER + match Int64.TryParse(text) with + | true, value -> Some value + | _ -> None +#else + match Int64.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture) with + | true, value -> Some value + | _ -> None +#endif + + let private tryParseDouble (text: string) = +#if FABLE_COMPILER + match Double.TryParse(text) with + | true, value -> Some value + | _ -> None +#else + match Double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, value -> Some value + | _ -> None +#endif + let private expectString functionName args = match args with | [ VString value ] -> value @@ -107,9 +129,9 @@ module BuiltinFunctions = Impl = (fun _ args -> let text = expectString "Int.tryParse" args - match Int64.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture) with - | true, value -> VOption (Some (VInt value)) - | _ -> VOption None) } + match tryParseInt64 text with + | Some value -> VOption (Some (VInt value)) + | None -> VOption None) } let private builtinFloatTryParse : ExternalFunction = { Name = "Float.tryParse" @@ -118,9 +140,9 @@ module BuiltinFunctions = Impl = (fun _ args -> let text = expectString "Float.tryParse" args - match Double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with - | true, value -> VOption (Some (VFloat value)) - | _ -> VOption None) } + match tryParseDouble text with + | Some value -> VOption (Some (VFloat value)) + | None -> VOption None) } let private builtinBoolTryParse : ExternalFunction = { Name = "Bool.tryParse" diff --git a/src/FScript.Language/Eval.fs b/src/FScript.Language/Eval.fs index b0db01e..40f9741 100644 --- a/src/FScript.Language/Eval.fs +++ b/src/FScript.Language/Eval.fs @@ -1,7 +1,10 @@ namespace FScript.Language module Eval = +#if FABLE_COMPILER +#else open System.Threading.Tasks +#endif type ProgramState = { TypeDefs: Map @@ -23,8 +26,15 @@ module Eval = { Message = $"Task failed: {ex.Message}" Span = unknownSpan } + let private withRuntimeLock (runtime: RuntimeState) f = +#if FABLE_COMPILER + f () +#else + lock runtime.SyncRoot f +#endif + let private raiseIfBackgroundFailed (runtime: RuntimeState) = - lock runtime.SyncRoot (fun () -> + withRuntimeLock runtime (fun () -> match runtime.BackgroundFailure with | Some error -> raise (EvalException error) | None -> ()) @@ -343,6 +353,9 @@ module Eval = and private spawnTaskValue (runtime: RuntimeState) (typeDefs: Map) (thunk: Value) : Value = raiseIfBackgroundFailed runtime +#if FABLE_COMPILER + raise (EvalException { Message = "Task.spawn is not supported in Fable builds"; Span = unknownSpan }) +#else let worker = try Task.Run(fun () -> @@ -352,13 +365,13 @@ module Eval = with | EvalException error -> let taskError = wrapTaskFailure error - lock runtime.SyncRoot (fun () -> + withRuntimeLock runtime (fun () -> if runtime.BackgroundFailure.IsNone then runtime.BackgroundFailure <- Some taskError) TaskFailed taskError | ex -> let taskError = wrapUnexpectedTaskFailure ex - lock runtime.SyncRoot (fun () -> + withRuntimeLock runtime (fun () -> if runtime.BackgroundFailure.IsNone then runtime.BackgroundFailure <- Some taskError) TaskFailed taskError) @@ -367,11 +380,15 @@ module Eval = raise (EvalException (wrapUnexpectedTaskFailure ex)) let handle = { Worker = worker; Awaited = false } - lock runtime.SyncRoot (fun () -> runtime.PendingTasks.Add(handle)) + withRuntimeLock runtime (fun () -> runtime.PendingTasks.Add(handle)) VTask handle +#endif and private awaitTaskValue (runtime: RuntimeState) (taskValue: Value) : Value = raiseIfBackgroundFailed runtime +#if FABLE_COMPILER + raise (EvalException { Message = "Task.await is not supported in Fable builds"; Span = unknownSpan }) +#else match taskValue with | VTask handle -> handle.Awaited <- true @@ -380,12 +397,13 @@ module Eval = raiseIfBackgroundFailed runtime value | TaskFailed error -> - lock runtime.SyncRoot (fun () -> + withRuntimeLock runtime (fun () -> if runtime.BackgroundFailure.IsNone then runtime.BackgroundFailure <- Some error) raise (EvalException error) | _ -> raise (EvalException { Message = "Task.await expects a task"; Span = unknownSpan }) +#endif and private applyFunctionValue (runtime: RuntimeState) diff --git a/src/FScript.Language/FScript.Language.fsproj b/src/FScript.Language/FScript.Language.fsproj index 86d0317..baf3c4d 100644 --- a/src/FScript.Language/FScript.Language.fsproj +++ b/src/FScript.Language/FScript.Language.fsproj @@ -9,7 +9,8 @@ Magnus Opera FScript Language Engine Embeddable FScript language engine: parser, type inference, and evaluator. - fscript;language;interpreter;fsharp;ml + fscript;language;interpreter;fsharp;ml;fable-dotnet;fable-javascript + library LICENSE README.md FScriptIcon.png @@ -43,4 +44,11 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/src/FScript.Language/IncludeResolver.fs b/src/FScript.Language/IncludeResolver.fs index 42f62a9..9658a58 100644 --- a/src/FScript.Language/IncludeResolver.fs +++ b/src/FScript.Language/IncludeResolver.fs @@ -2,10 +2,133 @@ namespace FScript.Language open System open System.Collections.Generic +#if FABLE_COMPILER +#else open System.IO open System.Runtime.InteropServices +#endif module IncludeResolver = +#if FABLE_COMPILER + let private pathComparison = StringComparison.Ordinal + let private pathsEqual left right = + String.Equals(left, right, pathComparison) + + let private makeStringDictionary () = + Dictionary() + + let private makeStringHashSet () = + HashSet() + + let private normalizeSeparators (path: string) = + if String.IsNullOrEmpty(path) then + "" + else + path.Replace('\\', '/') + + let private isPathRooted (path: string) = + (normalizeSeparators path).StartsWith("/", StringComparison.Ordinal) + + let private trimTrailingDirectorySeparators (path: string) = + let normalized = normalizeSeparators path + if String.IsNullOrEmpty(normalized) || normalized = "/" then + normalized + else + normalized.TrimEnd('/') + + let private normalizePath (path: string) = + let normalized = normalizeSeparators path + let isRooted = normalized.StartsWith("/", StringComparison.Ordinal) + let parts = normalized.Split([| '/' |], StringSplitOptions.RemoveEmptyEntries) + + let folded = + parts + |> Array.fold (fun acc part -> + match part with + | "." -> acc + | ".." -> + match acc with + | [] when isRooted -> [] + | [] -> [ ".." ] + | ".." :: _ -> part :: acc + | _ :: rest -> rest + | _ -> part :: acc) [] + |> List.rev + + let body = String.concat "/" folded + if isRooted then + if String.IsNullOrEmpty(body) then "/" else "/" + body + elif String.IsNullOrEmpty(body) then + "." + else + body + + let private normalizeFilePath path = + normalizePath path + + let private getDirectoryName (path: string) = + let normalized = trimTrailingDirectorySeparators path + if String.IsNullOrEmpty(normalized) || normalized = "/" then + "" + else + match normalized.LastIndexOf('/') with + | -1 -> "" + | 0 -> "/" + | index -> normalized.Substring(0, index) + + let private combinePath (directory: string) (path: string) = + if String.IsNullOrEmpty(directory) then + path + elif isPathRooted path then + path + elif directory.EndsWith("/", StringComparison.Ordinal) then + directory + path + else + directory + "/" + path + + let private normalizeDirectoryPath (path: string) = + let full = normalizePath path + if full.EndsWith("/", StringComparison.Ordinal) then full else full + "/" + + let private ensureFssPath (path: string) (span: Span) = + if not (path.EndsWith(".fss", StringComparison.OrdinalIgnoreCase)) then + raise (ParseException { Message = "Only '.fss' files can be used with 'import'"; Span = span }) + + let private ensureWithinRoot (rootDirectoryWithSeparator: string) (path: string) (span: Span) = + let fullPath = normalizePath path + let fullRoot = trimTrailingDirectorySeparators rootDirectoryWithSeparator + let rootWithSeparator = + if fullRoot.EndsWith("/", StringComparison.Ordinal) then fullRoot else fullRoot + "/" + let isRootItself = String.Equals(fullPath, fullRoot, pathComparison) + let isUnderRoot = fullPath.StartsWith(rootWithSeparator, pathComparison) + if not (isRootItself || isUnderRoot) then + raise (ParseException { Message = $"Imported file '{fullPath}' is outside of sandbox root"; Span = span }) + fullPath + + let private getFileNameWithoutExtension (sourceName: string) = + let normalized = trimTrailingDirectorySeparators sourceName + let fileName = + match normalized.LastIndexOf('/') with + | -1 -> normalized + | index -> normalized.Substring(index + 1) + match fileName.LastIndexOf('.') with + | index when index > 0 -> fileName.Substring(0, index) + | _ -> fileName + + let private resolveImportPath (currentFile: string) (importPath: string) (rootDirectoryWithSeparator: string) (span: Span) = + if String.IsNullOrWhiteSpace(importPath) then + raise (ParseException { Message = "Import path cannot be empty"; Span = span }) + + ensureFssPath importPath span + + let currentDirectory = getDirectoryName currentFile + let candidate = + if isPathRooted importPath then importPath + elif String.IsNullOrEmpty(currentDirectory) then importPath + else combinePath currentDirectory importPath + + ensureWithinRoot rootDirectoryWithSeparator candidate span +#else let private pathComparison = if RuntimeInformation.IsOSPlatform(OSPlatform.Windows) then StringComparison.OrdinalIgnoreCase @@ -18,9 +141,18 @@ module IncludeResolver = else StringComparer.Ordinal + let private makeStringDictionary () = + Dictionary(pathStringComparer) + + let private makeStringHashSet () = + HashSet(pathStringComparer) + let private pathsEqual left right = String.Equals(left, right, pathComparison) + let private normalizeFilePath path = + Path.GetFullPath(path) + let private isDirectorySeparator c = c = Path.DirectorySeparatorChar || c = Path.AltDirectorySeparatorChar @@ -112,6 +244,10 @@ module IncludeResolver = ensureWithinRoot rootDirectoryWithSeparator candidate span + let private getFileNameWithoutExtension (sourceName: string) = + Path.GetFileNameWithoutExtension(sourceName) +#endif + let private isValidAliasName (name: string) = let startsValid c = Char.IsLetter(c) || c = '_' let partValid c = Char.IsLetterOrDigit(c) || c = '_' @@ -122,7 +258,7 @@ module IncludeResolver = let private tryGetSourceModulePrefix (sourceName: string) = let stem = try - Path.GetFileNameWithoutExtension(sourceName) + getFileNameWithoutExtension sourceName with | _ -> sourceName @@ -461,7 +597,7 @@ module IncludeResolver = |> List.collect (fun (alias, importPath, span) -> let resolvedPath = resolveImportPath currentFile importPath rootDirectoryWithSeparator span let childPrefix = aliasMappings[alias] - (!loadFileRef) stack false resolvedPath (Some childPrefix)) + loadFileRef.Value stack false resolvedPath (Some childPrefix)) let localStatements = localCode |> Seq.toList let rewrittenLocalStatements = @@ -479,7 +615,7 @@ module IncludeResolver = let nextPrefixSegment () = counter <- counter + 1 $"__imp{counter}" - let pathPrefixes = Dictionary(pathStringComparer) + let pathPrefixes = makeStringDictionary () let getOrCreatePrefix (path: string) = match pathPrefixes.TryGetValue(path) with | true, prefix -> prefix @@ -498,6 +634,10 @@ module IncludeResolver = expandProgram dummyRoot fileSpan getOrCreatePrefix loadRef [] false sourceName prefix program let parseProgramFromFile (rootDirectory: string) (entryFile: string) : Program = +#if FABLE_COMPILER + let p = Span.posInFile entryFile 1 1 + raise (ParseException { Message = "File-backed imports are not supported in Fable builds; use resolver-backed source imports"; Span = Span.mk p p }) +#else let rootDirectoryWithSeparator = normalizeDirectoryPath rootDirectory let fileSpan path = let p = Span.posInFile path 1 1 @@ -506,7 +646,7 @@ module IncludeResolver = let nextPrefixSegment () = counter <- counter + 1 $"__imp{counter}" - let pathPrefixes = Dictionary(pathStringComparer) + let pathPrefixes = makeStringDictionary () let getOrCreatePrefix (path: string) = match pathPrefixes.TryGetValue(path) with | true, prefix -> prefix @@ -514,10 +654,10 @@ module IncludeResolver = let prefix = nextPrefixSegment () pathPrefixes[path] <- prefix prefix - let emittedFiles = HashSet(pathStringComparer) + let emittedFiles = makeStringHashSet () let rec loadFile (stack: string list) (isMainFile: bool) (filePath: string) (prefix: string option) : Program = - let fullFilePath = Path.GetFullPath(filePath) + let fullFilePath = normalizeFilePath filePath let initialSpan = fileSpan fullFilePath ensureFssPath fullFilePath initialSpan let sandboxedPath = ensureWithinRoot rootDirectoryWithSeparator fullFilePath initialSpan @@ -538,6 +678,7 @@ module IncludeResolver = expandProgram rootDirectoryWithSeparator fileSpan getOrCreatePrefix loadRef (sandboxedPath :: stack) isMainFile sandboxedPath prefix program loadFile [] true entryFile None +#endif let private parseProgramFromSourceWithIncludesCore (rootDirectory: string) @@ -554,7 +695,7 @@ module IncludeResolver = let nextPrefixSegment () = counter <- counter + 1 $"__imp{counter}" - let pathPrefixes = Dictionary(pathStringComparer) + let pathPrefixes = makeStringDictionary () let getOrCreatePrefix (path: string) = match pathPrefixes.TryGetValue(path) with | true, prefix -> prefix @@ -562,15 +703,15 @@ module IncludeResolver = let prefix = nextPrefixSegment () pathPrefixes[path] <- prefix prefix - let emittedFiles = HashSet(pathStringComparer) + let emittedFiles = makeStringHashSet () - let entryFullPath = Path.GetFullPath(entryFile) + let entryFullPath = normalizeFilePath entryFile let entrySpan = fileSpan entryFullPath ensureFssPath entryFullPath entrySpan let entrySandboxedPath = ensureWithinRoot rootDirectoryWithSeparator entryFullPath entrySpan let rec loadFile (stack: string list) (isMainFile: bool) (filePath: string) (prefix: string option) : Program = - let fullFilePath = Path.GetFullPath(filePath) + let fullFilePath = normalizeFilePath filePath let initialSpan = fileSpan fullFilePath ensureFssPath fullFilePath initialSpan let sandboxedPath = ensureWithinRoot rootDirectoryWithSeparator fullFilePath initialSpan @@ -596,7 +737,11 @@ module IncludeResolver = | None -> raise (ParseException { Message = $"Imported file '{sandboxedPath}' could not be resolved"; Span = fileSpan sandboxedPath }) | None -> +#if FABLE_COMPILER + raise (ParseException { Message = $"Imported file '{sandboxedPath}' could not be resolved; Fable hosts must provide a resolver"; Span = fileSpan sandboxedPath }) +#else File.ReadAllText(sandboxedPath) +#endif let program = Parser.parseProgramWithSourceName (Some sandboxedPath) source let loadRef = ref loadFile expandProgram rootDirectoryWithSeparator fileSpan getOrCreatePrefix loadRef (sandboxedPath :: stack) isMainFile sandboxedPath prefix program diff --git a/src/FScript.Language/Lexer.fs b/src/FScript.Language/Lexer.fs index 977fb45..69e5826 100644 --- a/src/FScript.Language/Lexer.fs +++ b/src/FScript.Language/Lexer.fs @@ -6,6 +6,13 @@ module Lexer = let private isIdentStart c = Char.IsLetter c || c = '_' let private isIdentPart c = Char.IsLetterOrDigit c || c = '_' || c = '\'' + let private parseFloatLiteral (text: string) = +#if FABLE_COMPILER + Double.Parse(text) +#else + Double.Parse(text, Globalization.CultureInfo.InvariantCulture) +#endif + let private mkSpan sourceName line col length = let startPos = { File = sourceName; Line = line; Column = col } let endPos = { File = sourceName; Line = line; Column = col + length } @@ -282,7 +289,7 @@ module Lexer = let text = src.Substring(start, idx - start) let span = mkSpan sourceName line startCol (idx - start) if hasDot then - addToken (FloatLit (Double.Parse(text, Globalization.CultureInfo.InvariantCulture))) span tokens + addToken (FloatLit (parseFloatLiteral text)) span tokens else addToken (IntLit (Int64.Parse(text))) span tokens i <- idx diff --git a/src/FScript.Language/Value.fs b/src/FScript.Language/Value.fs index 6277b31..2f404da 100644 --- a/src/FScript.Language/Value.fs +++ b/src/FScript.Language/Value.fs @@ -1,6 +1,9 @@ namespace FScript.Language +#if FABLE_COMPILER +#else open System.Threading.Tasks +#endif type MapKey = | MKString of string @@ -29,8 +32,12 @@ and TaskOutcome = | TaskFailed of EvalError and TaskHandle = +#if FABLE_COMPILER + { mutable Awaited: bool } +#else { Worker: Task mutable Awaited: bool } +#endif and ExternalCallContext = { Apply: Value -> Value -> Value diff --git a/website/.gitignore b/website/.gitignore index b2d6de3..77d9366 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -7,6 +7,7 @@ # Generated files .docusaurus .cache-loader +/src/generated # Misc .DS_Store diff --git a/website/docs/embedding/fable-javascript.md b/website/docs/embedding/fable-javascript.md new file mode 100644 index 0000000..3a54c0e --- /dev/null +++ b/website/docs/embedding/fable-javascript.md @@ -0,0 +1,151 @@ +--- +id: fable-javascript +title: Fable and JavaScript Hosts +slug: /embedding/fable-javascript +sidebar_label: Fable and JavaScript +--- + +FScript can run in JavaScript hosts through the Fable-compiled `@magnusopera/fscript` package. The package targets Node and browser-compatible ESM environments and exposes the core language pipeline without the .NET runtime extern catalog. + +Use this package when a JavaScript application needs to parse, type-check, evaluate, load, or invoke FScript code directly in-process. + +## Install + +```bash +npm install @magnusopera/fscript +``` + +```javascript +import { + T, + extern, + run, + load, + invoke, + getValue, + createSession, + submit, +} from "@magnusopera/fscript"; +``` + +## Run a script + +`run` parses, infers, and evaluates a source string. + +```javascript +const result = run("let x = 41\nx + 1"); + +console.log(result); +// { kind: "int", value: 42n } +``` + +FScript `int` values are returned as JavaScript `bigint`. Safe JavaScript integer numbers are accepted as input arguments. + +## Load and invoke exports + +Use `[]` on top-level bindings that the host should call repeatedly. + +```javascript +const loaded = load(` +[] let add x y = x + y +[] let answer = 42 +`); + +const sum = invoke(loaded, "add", [1, 2]); +const answer = getValue(loaded, "answer"); +``` + +`listFunctions(loaded)` and `listValues(loaded)` return the exported callable and value names. + +## Register JavaScript externs + +Externs are host functions with explicit type schemes. Arguments are passed as tagged FScript values, and the return value must be a tagged value or a supported plain JavaScript value. + +```javascript +const shout = extern({ + name: "Host.shout", + arity: 1, + scheme: T.scheme(T.func(T.string, T.string)), + invoke(args) { + return { kind: "string", value: `${args[0].value}!` }; + }, +}); + +const result = run('Host.shout "fable"', { externs: [shout] }); +``` + +## Resolve imports from virtual sources + +Browser and Node Fable hosts do not use the .NET file-backed resolver. Imports must resolve through virtual paths supplied by the host. + +```javascript +const loaded = load( + 'import "shared.fss" as Shared\n[] let value = Shared.inc 41', + { + rootDirectory: "/", + entryFile: "/main.fss", + sources: { + "/shared.fss": "let inc x = x + 1", + }, + }, +); +``` + +The host may also provide `resolveImport(path)` for lazy source lookup. Imported paths must stay under `rootDirectory` and end in `.fss`. + +## Use a browser session + +The JavaScript facade includes a small stateful session API for browser sandbox and REPL-like hosts. It retains declarations between submissions and returns a result only when the submission contains an expression. + +```javascript +const session = createSession({ + rootDirectory: "/", + entryFile: "/main.fss", +}); + +submit(session, "let add x y = x + y"); +const result = submit(session, "add 20 22"); + +console.log(result.text); +// "42" +``` + +`resetSession(session)` clears retained declarations. + +## Tagged values + +Values crossing the JavaScript boundary are represented structurally: + +| FScript value | JavaScript shape | +| --- | --- | +| `()` | `{ kind: "unit" }` | +| `42` | `{ kind: "int", value: 42n }` | +| `3.14` | `{ kind: "float", value: 3.14 }` | +| `true` | `{ kind: "bool", value: true }` | +| `"text"` | `{ kind: "string", value: "text" }` | +| `[1; 2]` | `{ kind: "list", values: [...] }` | +| `(1, "x")` | `{ kind: "tuple", values: [...] }` | +| `{ name = "Ada" }` | `{ kind: "record", fields: { ... } }` | +| `Some 1` / `None` | `{ kind: "option", value: taggedValue \| null }` | + +Functions, externals, tasks, and union constructors are opaque at the JavaScript boundary. Exported functions should be called through `invoke`. + +## Errors + +Language errors are thrown as JavaScript `Error` objects with structured fields: + +```javascript +try { + run("let = 1"); +} catch (error) { + console.log(error.kind); // "fscript-error" + console.log(error.phase); // "parse", "type", "eval", or "host" + console.log(error.span); +} +``` + +## Current limits + +The Fable package exposes the core language engine, virtual imports, exported invocation, tagged values, sessions, and JavaScript-provided externs. It does not include .NET runtime externs such as `Fs.*`, `Console.*`, `Task.spawn`, or `Task.await`. + +Try the browser runtime in the [FScript sandbox](/sandbox). diff --git a/website/docs/embedding/overview.md b/website/docs/embedding/overview.md index c0624b5..57ea1ca 100644 --- a/website/docs/embedding/overview.md +++ b/website/docs/embedding/overview.md @@ -24,7 +24,8 @@ FScript is designed for host applications that embed a scripting language safely ## Recommended reading order 1. [Real-World Embedding (Load, Resolve Type, Execute)](./real-world-embedding) -2. [F# Type Provider and Use Cases](./type-provider) -3. [Register Extern Functions](./register-externs) -4. [Resolver and Includes](./resolver-and-includes) -5. [Sandbox and Safety](./sandbox-and-safety) +2. [Fable and JavaScript Hosts](./fable-javascript) +3. [F# Type Provider and Use Cases](./type-provider) +4. [Register Extern Functions](./register-externs) +5. [Resolver and Includes](./resolver-and-includes) +6. [Sandbox and Safety](./sandbox-and-safety) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index e8cd849..3219ddd 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -74,6 +74,11 @@ const config: Config = { position: 'left', dropdownActiveClassDisabled: true, }, + { + to: '/sandbox', + label: 'Sandbox', + position: 'left', + }, { href: 'https://github.com/MagnusOpera/FScript', label: 'GitHub', diff --git a/website/package.json b/website/package.json index 4b136b2..f552d4b 100644 --- a/website/package.json +++ b/website/package.json @@ -4,7 +4,10 @@ "private": true, "scripts": { "docusaurus": "docusaurus", + "prepare:fscript": "dotnet tool restore && dotnet fable ../src/FScript.JavaScript/FScript.JavaScript.fsproj --outDir src/generated/fscript --sourceMaps false", + "prestart": "npm run prepare:fscript", "start": "docusaurus start", + "prebuild": "npm run prepare:fscript", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", @@ -12,6 +15,7 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", + "pretypecheck": "npm run prepare:fscript", "typecheck": "tsc", "version-docs": "node ./scripts/version-docs.mjs" }, diff --git a/website/sidebars.ts b/website/sidebars.ts index 576ad79..34407be 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -36,6 +36,7 @@ const sidebars: SidebarsConfig = { items: [ 'embedding/embedding-overview', 'embedding/real-world-embedding', + 'embedding/fable-javascript', 'embedding/type-provider', 'embedding/register-externs', 'embedding/resolver-and-includes', diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index c8e6b3d..055b729 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -24,8 +24,13 @@ const features: Feature[] = [ to: '/manual/examples/guided-examples', }, { - title: 'Embed in .NET Hosts', - description: 'Expose host capabilities through externs with explicit typing and safety boundaries.', + title: 'Try the Sandbox', + description: 'Run FScript directly in the browser with the Fable JavaScript runtime.', + to: '/sandbox', + }, + { + title: 'Embed in Hosts', + description: 'Expose .NET or JavaScript host capabilities through typed externs and clear safety boundaries.', to: '/manual/embedding/overview', }, ]; @@ -55,6 +60,9 @@ function HomepageHeader() { Read the Manual + + Open Sandbox + Browse Examples diff --git a/website/src/pages/sandbox.module.css b/website/src/pages/sandbox.module.css new file mode 100644 index 0000000..6cc268f --- /dev/null +++ b/website/src/pages/sandbox.module.css @@ -0,0 +1,250 @@ +.page { + min-height: calc(100vh - var(--ifm-navbar-height)); + background: var(--ifm-background-color); +} + +.shell { + width: min(1200px, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 3rem; +} + +.header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.title { + margin-bottom: 0.25rem; + font-size: 2rem; +} + +.subtitle { + margin: 0; + color: var(--ifm-color-emphasis-700); +} + +.toolbar { + display: flex; + align-items: flex-end; + gap: 0.75rem; + flex-wrap: wrap; +} + +.selectLabel { + display: grid; + gap: 0.25rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.82rem; + font-weight: 650; +} + +.select { + min-width: 9rem; + height: 2.35rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + background: var(--ifm-background-surface-color); + color: var(--ifm-font-color-base); + padding: 0 0.7rem; +} + +.segmented { + display: inline-grid; + grid-template-columns: repeat(2, minmax(5.25rem, 1fr)); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + overflow: hidden; + background: var(--ifm-background-surface-color); +} + +.segment, +.segmentActive { + height: 2.35rem; + border: 0; + padding: 0 0.75rem; + color: var(--ifm-color-emphasis-800); + background: transparent; + font-weight: 650; + cursor: pointer; +} + +.segmentActive { + color: #ffffff; + background: #2563a9; +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.85fr); + gap: 1rem; + align-items: stretch; +} + +.editorPanel, +.history { + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + background: var(--ifm-background-surface-color); + overflow: hidden; +} + +.panelHeader { + min-height: 3rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); + font-size: 0.9rem; + font-weight: 700; +} + +.primaryButton, +.secondaryButton { + min-width: 5.5rem; + min-height: 2.1rem; + border: 1px solid transparent; + border-radius: 6px; + padding: 0 0.85rem; + font-weight: 750; + cursor: pointer; +} + +.primaryButton { + color: #ffffff; + background: #1f6fb2; +} + +.primaryButton:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.secondaryButton { + color: var(--ifm-color-emphasis-800); + background: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-300); +} + +.editor, +.importEditor { + display: block; + width: 100%; + border: 0; + resize: vertical; + background: #111827; + color: #f8fafc; + font: 0.95rem/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; + padding: 1rem; + tab-size: 2; +} + +.editor:focus, +.importEditor:focus { + outline: 2px solid #4ea8de; + outline-offset: -2px; +} + +.editor { + min-height: 32rem; +} + +.importEditor { + min-height: 12rem; +} + +.output { + border-top: 1px solid var(--ifm-color-emphasis-200); + padding: 0.85rem; +} + +.output[data-status='ok'] { + border-top-color: #2e7d5b; +} + +.output[data-status='error'] { + border-top-color: #b42318; +} + +.outputHeader { + margin-bottom: 0.5rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +.outputText, +.outputDetail, +.historyItem { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 0.9rem/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; +} + +.outputText { + color: var(--ifm-font-color-base); +} + +.outputDetail { + margin-top: 0.75rem; + padding: 0.75rem; + border-radius: 6px; + background: var(--ifm-color-emphasis-100); + color: var(--ifm-color-emphasis-800); +} + +.history { + margin-top: 1rem; + padding: 0.85rem; +} + +.historyItem + .historyItem { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--ifm-color-emphasis-200); +} + +@media (max-width: 900px) { + .header { + align-items: stretch; + flex-direction: column; + } + + .toolbar { + justify-content: space-between; + } + + .workspace { + grid-template-columns: 1fr; + } + + .editor { + min-height: 24rem; + } +} + +@media (max-width: 520px) { + .shell { + width: min(100% - 1rem, 1200px); + padding-top: 1rem; + } + + .toolbar, + .selectLabel, + .select, + .segmented { + width: 100%; + } + + .title { + font-size: 1.65rem; + } +} diff --git a/website/src/pages/sandbox.tsx b/website/src/pages/sandbox.tsx new file mode 100644 index 0000000..a9fdcf4 --- /dev/null +++ b/website/src/pages/sandbox.tsx @@ -0,0 +1,322 @@ +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import Layout from '@theme/Layout'; +import Heading from '@theme/Heading'; + +import styles from './sandbox.module.css'; + +type TaggedValue = { + kind: string; + value?: unknown; + values?: TaggedValue[]; + fields?: Record; + entries?: Array<{key: TaggedValue; value: TaggedValue}>; +}; + +type SessionResult = { + kind: 'session-result'; + hasValue: boolean; + value: TaggedValue | null; + text: string; + retainedCount: number; +}; + +type FScriptModule = { + run(source: string, options?: unknown): TaggedValue; + createSession(options?: unknown): unknown; + resetSession(session: unknown): unknown; + submit(session: unknown, source: string): SessionResult; + formatValue(value: unknown): string; +}; + +type OutputState = + | {status: 'idle'; text: string; detail?: string} + | {status: 'ok'; text: string; detail?: string} + | {status: 'error'; text: string; detail?: string}; + +type Example = { + label: string; + source: string; + shared: string; +}; + +const examples: Example[] = [ + { + label: 'Values', + source: `let add x y = x + y +let numbers = [1; 2; 3; 4] +let total = List.fold add 0 numbers +total`, + shared: `let inc x = x + 1`, + }, + { + label: 'Imports', + source: `import "shared.fss" as Shared + +[] let answer = Shared.inc 41 +answer`, + shared: `let inc x = x + 1`, + }, + { + label: 'Session', + source: `let twice x = x * 2`, + shared: `let inc x = x + 1`, + }, +]; + +const defaultOutput: OutputState = { + status: 'idle', + text: 'Ready.', +}; + +function stringifyTagged(value: unknown) { + return JSON.stringify( + value, + (_key, item) => (typeof item === 'bigint' ? `${item.toString()}n` : item), + 2, + ); +} + +function formatError(error: unknown) { + if (typeof error === 'object' && error !== null) { + const err = error as { + kind?: string; + phase?: string; + message?: string; + span?: {start?: {file?: string; line?: number; column?: number}}; + }; + if (err.kind === 'fscript-error') { + const position = err.span?.start; + const location = + position && typeof position.line === 'number' + ? ` ${position.file ?? ''}:${position.line}:${position.column ?? 1}` + : ''; + return { + text: `${err.phase ?? 'host'} error${location}`, + detail: err.message ?? 'FScript error', + }; + } + if ('message' in err && typeof err.message === 'string') { + return {text: 'JavaScript host error', detail: err.message}; + } + } + return {text: 'JavaScript host error', detail: String(error)}; +} + +export default function Sandbox() { + const [fscript, setFscript] = useState(null); + const [source, setSource] = useState(examples[0].source); + const [sharedSource, setSharedSource] = useState(examples[0].shared); + const [mode, setMode] = useState<'program' | 'session'>('program'); + const [output, setOutput] = useState(defaultOutput); + const [history, setHistory] = useState([]); + const sessionRef = useRef(null); + + const options = useMemo( + () => ({ + rootDirectory: '/', + entryFile: '/main.fss', + sources: { + '/shared.fss': sharedSource, + }, + }), + [sharedSource], + ); + + useEffect(() => { + let cancelled = false; + + async function loadRuntime() { + try { + // The module is generated by npm scripts before Docusaurus starts or builds. + const module = (await import('../generated/fscript/Library.js')) as FScriptModule; + if (!cancelled) { + setFscript(module); + sessionRef.current = module.createSession(options); + } + } catch (error) { + if (!cancelled) { + const formatted = formatError(error); + setOutput({status: 'error', text: formatted.text, detail: formatted.detail}); + } + } + } + + loadRuntime(); + + return () => { + cancelled = true; + }; + }, [options]); + + function selectExample(label: string) { + const selected = examples.find((item) => item.label === label) ?? examples[0]; + setSource(selected.source); + setSharedSource(selected.shared); + setOutput(defaultOutput); + setHistory([]); + if (fscript) { + sessionRef.current = fscript.createSession({ + rootDirectory: '/', + entryFile: '/main.fss', + sources: {'/shared.fss': selected.shared}, + }); + } + } + + function runProgram() { + if (!fscript) { + setOutput({status: 'idle', text: 'FScript is still loading.'}); + return; + } + + try { + const value = fscript.run(source, options); + setOutput({status: 'ok', text: fscript.formatValue(value), detail: stringifyTagged(value)}); + } catch (error) { + const formatted = formatError(error); + setOutput({status: 'error', text: formatted.text, detail: formatted.detail}); + } + } + + function ensureSession() { + if (!fscript) { + return null; + } + if (!sessionRef.current) { + sessionRef.current = fscript.createSession(options); + } + return sessionRef.current; + } + + function submitToSession() { + if (!fscript) { + setOutput({status: 'idle', text: 'FScript is still loading.'}); + return; + } + + const session = ensureSession(); + if (!session) { + return; + } + + try { + const result = fscript.submit(session, source); + const text = result.hasValue ? result.text : `Stored ${result.retainedCount} declaration(s).`; + setOutput({ + status: 'ok', + text, + detail: result.value ? stringifyTagged(result.value) : undefined, + }); + setHistory((items) => [`> ${source}\n${text}`, ...items].slice(0, 8)); + } catch (error) { + const formatted = formatError(error); + setOutput({status: 'error', text: formatted.text, detail: formatted.detail}); + } + } + + function resetSession() { + if (fscript && sessionRef.current) { + fscript.resetSession(sessionRef.current); + } + setHistory([]); + setOutput({status: 'idle', text: 'Session reset.'}); + } + + return ( + +
+
+
+
+ + FScript Sandbox + +

Browser execution powered by the Fable JavaScript facade.

+
+
+ +
+ + +
+
+
+ +
+
+
+ Main + +
+