diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb84acc2d29..d3e37552acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -382,6 +382,18 @@ jobs: if: matrix.build_playground run: yarn workspace playground test + - name: Stage dev playground compiler bundle + if: ${{ matrix.build_playground && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + run: yarn workspace dev-playground stage-master-bundle + + - name: "Upload artifacts: dev playground compiler bundle" + if: ${{ matrix.build_playground && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + uses: actions/upload-artifact@v7 + with: + name: dev-playground-master-bundle + path: packages/dev-playground/public/playground-bundles/master + if-no-files-found: error + - name: Setup Rclone if: ${{ matrix.build_playground && startsWith(github.ref, 'refs/tags/v') }} uses: cometkim/rclone-actions/setup-rclone@main @@ -430,6 +442,62 @@ jobs: name: api path: scripts/res/apiDocs/ + dev-playground: + needs: + - build-compiler + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + runs-on: ubuntu-24.04 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }}dev-playground/ + env: + VITE_DEFAULT_COMPILER_VERSION: master + VITE_COMPILER_VERSIONS: '[{"id":"master","label":"master"}]' + GITHUB_PAGES_PATH: dev-playground + PLAYGROUND_BUNDLE_ID: master + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + cache: yarn + node-version-file: .nvmrc + + - name: Install npm packages + run: yarn install + + - name: Download dev playground compiler bundle + uses: actions/download-artifact@v8 + with: + name: dev-playground-master-bundle + path: packages/dev-playground/public/playground-bundles/master + + - name: Configure GitHub Pages + id: pages + uses: actions/configure-pages@v6 + + - name: Build dev playground Pages site + env: + VITE_BASE: ${{ steps.pages.outputs.base_path }}/dev-playground/ + run: | + yarn workspace dev-playground build + yarn workspace dev-playground prepare-pages-site + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: packages/dev-playground/pages-site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + pkg-pr-new: needs: - build-compiler diff --git a/.gitignore b/.gitignore index 0b5c90556f6..ba748abd224 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,12 @@ playground/*.cmj playground/*.cmi playground/.netrc playground/compiler.*js +packages/dev-playground/dist/ +packages/dev-playground/pages-site/ +packages/dev-playground/lib/ +packages/dev-playground/src/*.res.mjs +packages/dev-playground/public/playground-bundles/* +!packages/dev-playground/public/playground-bundles/.gitignore rewatch/target/ rewatch/rewatch diff --git a/CHANGELOG.md b/CHANGELOG.md index 85350be7e05..4154d86eef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ #### :house: Internal - Remove `Primitive_option.toUndefined`; use `valFromOption` for optional ffi args. https://github.com/rescript-lang/rescript/pull/8380 +- Add a developer playground for testing the current compiler bundle locally and deploy the latest `master` build to GitHub Pages. https://github.com/rescript-lang/rescript/pull/8435 - Expand `super_errors` fixture coverage for warnings and errors. https://github.com/rescript-lang/rescript/pull/8429 - Run `super_errors` fixtures in parallel (~2.4× faster locally). https://github.com/rescript-lang/rescript/pull/8430 - Expand `super_errors` fixture coverage for the remaining reachable single-file error variants. https://github.com/rescript-lang/rescript/pull/8432 diff --git a/Makefile b/Makefile index 78237348502..9b096b7c00e 100644 --- a/Makefile +++ b/Makefile @@ -204,6 +204,15 @@ $(PLAYGROUND_CMI_BUILD_STAMP): $(RUNTIME_BUILD_STAMP) playground-test: playground yarn workspace playground test +dev-playground-stage: playground + yarn workspace dev-playground stage-local-bundle + +dev-playground: dev-playground-stage + yarn workspace dev-playground dev + +dev-playground-build: dev-playground-stage + yarn workspace dev-playground build + # Builds the playground, runs some e2e tests and releases the playground to the # Cloudflare R2 (requires Rclone `rescript:` remote) playground-release: playground-test diff --git a/compiler/jsoo/jsoo_playground_main.ml b/compiler/jsoo/jsoo_playground_main.ml index d21a5bb1332..17abab28a66 100644 --- a/compiler/jsoo/jsoo_playground_main.ml +++ b/compiler/jsoo/jsoo_playground_main.ml @@ -51,8 +51,9 @@ * modules in the playground. * v5: Removed .ml support. * v6: Added `config.experimental_features` and `config.jsx_preserve_mode` to the BundleConfig. + * v7: Added opt-in debug dump output APIs for developer playground tooling. * *) -let api_version = "6" +let api_version = "7" module Js = Js_of_ocaml.Js @@ -298,6 +299,8 @@ let rescript_parse ~filename src = structure module Printer = struct + let to_string printer value = Format.asprintf "%a@." printer value + let print_expr typ = Printtyp.reset_names (); Printtyp.reset_and_mark_loops typ; @@ -312,6 +315,23 @@ module Printer = struct (Printtyp.tree_of_type_declaration (Ident.create name) decl rec_status)) end +module DebugOutput = struct + type t = ParseTree | TypedTree | Lambda | Lam + + let from_string = function + | "parsetree" -> Some ParseTree + | "typedtree" -> Some TypedTree + | "lambda" -> Some Lambda + | "lam" -> Some Lam + | _ -> None + + let from_js_array value = + value |> Js.to_array |> Array.to_list + |> List.filter_map (fun item -> from_string (Js.to_string item)) + + let has output outputs = List.exists (fun item -> item = output) outputs +end + module Compile = struct (* Apparently it's not possible to retrieve the loc info from * Location.error_of_exn properly, so we need to do some extra @@ -472,7 +492,12 @@ module Compile = struct List.iter Iter.iter_structure_item structure.str_items; Js.array (!acc |> Array.of_list) - let implementation ~(config : BundleConfig.t) ~lang str = + let optional_string_attr name = function + | Some value -> Js.Unsafe.[|(name, inject @@ Js.string value)|] + | None -> [||] + + let implementation ?(debug_outputs = []) ~(config : BundleConfig.t) ~lang str + = let { BundleConfig.module_system; warn_flags; @@ -505,6 +530,11 @@ module Compile = struct (* default *) let ast = impl str in let ast = Ppx_entry.rewrite_implementation ast in + let debug_parsetree = + if DebugOutput.has DebugOutput.ParseTree debug_outputs then + Some (Printer.to_string Printast.implementation ast) + else None + in let typed_tree = let a, b, _, signature = Typemod.type_implementation_more modulename modulename modulename env @@ -514,19 +544,38 @@ module Compile = struct types_signature := signature; (a, b) in + let debug_typedtree = + if DebugOutput.has DebugOutput.TypedTree debug_outputs then + Some + (Printer.to_string Printtyped.implementation_with_coercion + typed_tree) + else None + in typed_tree |> Translmod.transl_implementation modulename - |> (* Printlambda.lambda ppf *) fun (lam, exports) -> + |> fun (lambda, exports) -> + let debug_lambda = + if DebugOutput.has DebugOutput.Lambda debug_outputs then + Some (Printer.to_string Printlambda.lambda lambda) + else None + in + let debug_lam = + if DebugOutput.has DebugOutput.Lam debug_outputs then + let export_ident_sets = Set_ident.of_list exports in + let lam, _ = Lam_convert.convert export_ident_sets lambda in + Some (Lam_print.lambda_to_string lam) + else None + in let buffer = Buffer.create 1000 in let () = Js_dump_program.pp_deps_program ~output_prefix:"" (* does not matter here *) module_system - (Lam_compile_main.compile "" exports lam) + (Lam_compile_main.compile "" exports lambda) (Ext_pp.from_buffer buffer) in let v = Buffer.contents buffer in let type_hints = collect_type_hints typed_tree in - Js.Unsafe.( - obj + let attrs = + Js.Unsafe. [| ("js_code", inject @@ Js.string v); ( "warnings", @@ -536,7 +585,18 @@ module Compile = struct |> Js.array |> inject) ); ("type_hints", inject @@ type_hints); ("type", inject @@ Js.string "success"); - |]) + |] + in + Js.Unsafe.( + obj + (Array.concat + [ + attrs; + optional_string_attr "parsetree" debug_parsetree; + optional_string_attr "typedtree" debug_typedtree; + optional_string_attr "lambda" debug_lambda; + optional_string_attr "lam" debug_lam; + ])) with e -> ( match e with | Arg.Bad msg -> ErrorRet.make_warning_flag_error ~warn_flags msg @@ -582,6 +642,12 @@ module Export = struct inject @@ Js.wrap_meth_callback (fun _ code -> Compile.implementation ~config ~lang (Js.to_string code)) ); + ( "compileWithDebug", + inject + @@ Js.wrap_meth_callback (fun _ code debug_outputs -> + Compile.implementation + ~debug_outputs:(DebugOutput.from_js_array debug_outputs) + ~config ~lang (Js.to_string code)) ); ("version", inject @@ Js.string Bs_version.version); |] in diff --git a/docs/dev-playground.md b/docs/dev-playground.md new file mode 100644 index 00000000000..f1ae74737d5 --- /dev/null +++ b/docs/dev-playground.md @@ -0,0 +1,591 @@ +# Per-PR Developer Playground Plan + +## Goal + +Build and publish a lightweight developer playground for every pull request, using the existing browser compiler bundle as the source of truth and a small Xote/Vite frontend as the UI shell. + +The playground should serve two workflows: + +- Local compiler development: run the exact branch compiler in a browser at localhost. +- Pull request review: open a stable `github.io` URL for a PR and inspect compiler output without checking out the branch. + +This is separate from the end-user playground on `rescript-lang.org/try`. The developer playground can expose compiler internals, experimental settings, unstable tabs, and per-PR builds. + +## Current Status + +The first implementation step has landed in `e4e63c78b Developer playground`. + +Implemented: + +- `packages/dev-playground/` exists as a Xote/Vite/ReScript frontend shell. +- The UI shell is pinned to `rescript@12.3.0`. +- `make dev-playground` builds the browser compiler bundle, stages it under the dev playground, and starts the local Vite server. +- `make dev-playground-build` verifies the local staged bundle and frontend production build. +- `compiler/jsoo/jsoo_playground_main.ml` exposes additive API version `7`. +- `rescript.compile(source)` stays compatible with the existing end-user playground. +- `rescript.compileWithDebug(source, outputs)` exposes requested internal artifacts for the developer playground. +- The local playground supports source editing, line numbers, lightweight ReScript highlighting, output tabs, settings, URL state, and loading the current checkout's compiler bundle through `playground-bundles/local`. + +This follow-up adds the master-only GitHub Pages deployment: + +- `.github/workflows/ci.yml` reuses the existing playground compiler build and deploys on pushes to `master`. +- The browser compiler bundle is uploaded from the existing Ubuntu ARM playground CI entry and consumed by a follow-up Pages deploy job. +- The deployed site is staged under `/dev-playground/`. +- The deployed compiler selector contains only the `master` bundle. + +The remaining work is intentionally left for subsequent PRs: + +- PR bundle build/publish workflows. +- PR bundle cleanup when a PR is closed or merged. +- A longer-term storage decision for many PR bundles. +- A shared highlighting source of truth for TextMate, tree-sitter, and the playground. + +The current highlighter is intentionally lightweight and should not become a separate source of truth for ReScript syntax. + +## Starting Point + +The repo already builds a browser compiler bundle: + +- `make playground` builds `packages/playground/compiler.js` through the `browser` dune profile. +- `packages/playground/scripts/generate_cmijs.mjs` emits side-loadable `cmij.js` package metadata. +- `packages/playground/serve-bundle.mjs` can serve a local bundle through `/playground-bundles//...`. +- CI already builds and tests the playground bundle on the ARM Linux matrix entry, and uploads release-tag bundles to Cloudflare R2. + +The external prototype at `https://github.com/mununki/rescript-playground-xote/tree/main` should be the implementation baseline, not just inspiration: + +- Xote + Vite frontend with a small footprint. +- Versioned bundle loading from `public/playground-bundles//`. +- Tabs for parsetree, typedtree, lambda, lam, JavaScript, and settings. +- URL-encoded source/settings for shareable examples. + +Keep its layout, interaction model, and visual structure unless there is a concrete compiler-developer requirement that it cannot satisfy. The goal is to bring that app into this repo and adapt the bundle/catalog plumbing, not to redesign the playground. + +The current compiler bundle API is narrower than that prototype assumes. Upstream `compiler/jsoo/jsoo_playground_main.ml` currently exposes compile output, warnings, type hints, formatting, and settings such as module system, warn flags, open modules, experimental features, and JSX preserve mode. A developer playground needs a deliberate expansion of that API rather than adding UI-only placeholders. + +## Proposed Repository Shape + +The first step added a new workspace package: + +```txt +packages/dev-playground/ + package.json + rescript.json + vite.config.js + index.html + src/ + Main.res + CompilerApi.res + CompilerRuntime.js + UrlState.js + styles.css + scripts/ + stage-local-bundle.mjs + build-catalog.mjs + validate-dist.mjs +``` + +`build-catalog.mjs` and `validate-dist.mjs` are still deployment follow-ups. Keep `packages/playground/` as the compiler-bundle package. It should remain responsible for producing `compiler.js` and cmij package files. The new `packages/dev-playground/` package should remain a frontend shell that consumes those assets. + +This split keeps the end-user bundle pipeline reusable and prevents UI work from being coupled to compiler bundle generation. + +Seed `packages/dev-playground/` from the shared Xote implementation and keep the initial patch mechanical: + +- preserve the two-column source/output layout, +- preserve the tab strip and settings panel structure, +- preserve URL state handling and share links, +- preserve the existing lightweight editor approach, +- replace only the hardcoded vendored bundle list with local/master/PR catalog loading, +- add new output tabs through the artifact registry rather than changing the main layout. + +The frontend source should still be ReScript. The deployment dependency should be deliberate: + +- The compiler payload under review must always be the `compiler.js` and cmij bundle built from the same branch/SHA as the pull request. +- The static UI shell should be built with `rescript@12.3.0`, the latest stable compiler, so a PR compiler regression does not prevent publishing a playground that can display that regression. +- Local development can use the workspace compiler for convenience, but the UI should avoid depending on brand-new language features unless the goal is specifically to dogfood them. + +The `packages/dev-playground/package.json` dependencies should therefore pin: + +```json +{ + "dependencies": { + "@rescript/runtime": "12.3.0", + "rescript": "12.3.0", + "vite": "...", + "xote": "..." + } +} +``` + +## Local Developer Workflow + +The local workflow is implemented through Makefile targets equivalent to: + +```make +dev-playground: playground + yarn workspace dev-playground stage-local-bundle + yarn workspace dev-playground dev + +dev-playground-build: playground + yarn workspace dev-playground stage-local-bundle + yarn workspace dev-playground build +``` + +Expected commands: + +```sh +make dev-playground +make dev-playground-build +``` + +The `stage-local-bundle` script should copy or symlink the local bundle into: + +```txt +packages/dev-playground/public/playground-bundles/local/ + compiler.js + compiler-builtins/cmij.js + compiler-builtins/stdlib/*.js + @rescript/react/cmij.js +``` + +The local UI should default to `local`. Local development must not require network access or a released bundle catalog. + +## Compiler Bundle API + +Introduce an API version bump in `compiler/jsoo/jsoo_playground_main.ml`. +The initial v7 API should stay additive: + +- Keep `rescript.compile(source)` unchanged for the end-user playground and existing CDN bundles. +- Add `rescript.compileWithDebug(source, outputs)` for developer tooling. +- Return the same success/error shape as `compile`, plus only the requested debug output string fields. + +This keeps `rescript-lang.org/try` compatible while allowing the developer playground to feature-detect `api_version >= 7`. + +Suggested result shape: + +```rescript +type diagnostic = { + fullMsg: string, + shortMsg: string, + row: option, + column: option, +} + +type typeHint + +type artifactContent = + | Text(string) + | Json(string) + | Files(array<(string, string)>) + +type artifact = { + title: string, + content: artifactContent, + mime: option, +} + +type compileResult = + | Success({ + jsCode: string, + warnings: array, + typeHints: array, + artifacts: Dict.t, + }) + | Error({ + kind: string, + warnings: array, + errors: array, + message: string, + }) +} +``` + +The raw `compiler.js` boundary can stay a plain JavaScript object for compatibility. The frontend should normalize that object into ReScript types close to the shape above before rendering tabs. The artifact map can be a frontend adapter over the current v7 fields first, and become a raw compiler result field later if that proves useful. + +Initial artifact keys: + +- `parsetree` +- `typedtree` +- `lambda` +- `lam` +- `js` +- `warnings` +- `type_hints` + +Planned artifact keys: + +- `source_map` +- `gentype` +- `cmt_summary` +- `compiler_args` +- `build_graph` +- `analysis` + +Avoid adding one top-level field per future output. A map of named artifacts lets compiler developers add tabs without forcing frontend rewrites. + +## Highlighting Source of Truth + +The local playground currently uses a small in-app tokenizer so the editor remains lightweight. That is acceptable for the first local workflow, but it should not become an independent grammar. + +Longer term, ReScript should have one shared highlighting specification that can drive: + +- the VS Code TextMate grammar, +- tree-sitter highlight queries, +- the playground's lightweight tokenizer tables. + +Do not try to derive TextMate directly from tree-sitter, or tree-sitter directly from TextMate. They have different constraints: TextMate is regex/scope based and works well on incomplete text, while tree-sitter is parse-node and query based. Instead, derive both from shared language facts and a shared highlight taxonomy. + +Recommended follow-up shape: + +```txt +tools/highlighting/ + spec.json + generate_tm_language.mjs + generate_tree_sitter_queries.mjs + generate_playground_tables.mjs +``` + +The shared spec should cover at least: + +- keywords and constants, preferably generated or checked against `compiler/syntax/src/res_token.ml`, +- operators and punctuation classes, +- attributes and extension points, +- comments, doc comments, strings, template literals, regex literals, chars, and numbers, +- constructors, polymorphic variants, modules, labels, JSX tags, and raw JS blocks, +- the final taxonomy used by themes and semantic tokens. + +Semantic highlighting remains a separate layer. `analysis/src/SemanticTokens.ml` already documents that grammar highlighting and semantic tokens need to stay in sync, so the shared highlighting spec should include the semantic-token legend or validate against it. + +For the developer playground, the next practical step is to make its tokenizer consume generated lightweight tables once such a spec exists. Until then, only fix obvious visual gaps; avoid growing the tokenizer into a parallel grammar. + +## Supporting `rescript.json` + +The first version should support the settings that map cleanly to one-file browser compilation: + +- `package-specs.module`: `esmodule` or `commonjs` +- `suffix` +- `namespace` +- `warnings` +- `jsx` +- `uncurried` +- `compiler-flags`, for a curated safe subset +- `open_modules` +- `experimental_features` +- `gentypeconfig`, once GenType output is wired + +The UI should expose a `rescript.json` editor next to the source editor. Internally, parse the JSON into a normalized playground config and show unsupported fields as warnings, not hard failures. + +Fields that require a real project graph should be explicitly marked as staged support: + +- `sources` +- `dependencies` +- `dev-dependencies` +- `ppx-flags` +- generators/resources +- `reanalyze` + +For these, the playground should initially support a small virtual project model: + +```txt +/rescript.json +/src/Playground.res +/src/Playground.resi +/src/OtherModule.res +``` + +The virtual project model is the prerequisite for reliable module dependencies, interface files, source maps, GenType, and analysis. + +## Output Tabs + +Use a dynamic tab registry driven by the compiler result artifacts: + +- Always show source, JavaScript, problems, and settings. +- Show compiler internals only when present in `result.artifacts`. +- Preserve tab state through URL parameters. +- Allow each tab to choose a renderer: plain text, JSON tree, generated files, diagnostics list, or source-map explorer. + +This keeps the UI useful when switching between the local bundle, the latest `master` bundle, and PR bundles with different API versions. + +## Source Maps + +Source maps should be a second-phase compiler artifact: + +1. Extend the browser compiler API to optionally emit source map text alongside `js_code`. +2. Add a `source_map` artifact containing raw JSON. +3. Add a frontend source-map tab with: + - raw JSON view, + - generated-to-source mapping table, + - click sync between ReScript source, generated JavaScript, and mapping entries. + +The source-map tab should be hidden unless the compiler returns the artifact. + +## GenType + +GenType support should be built around generated files, not a single text field. + +Proposed artifact: + +```rescript +{ + title: "GenType", + content: Files([ + ("Playground.gen.tsx", "..."), + ("Playground.gen.ts", "..."), + ]), + mime: None, +} +``` + +Implementation path: + +1. Support `gentypeconfig` parsing in the playground config. +2. Teach the browser compiler flow to run the GenType path over in-memory typed output, or write into the js_of_ocaml virtual filesystem if that is less invasive. +3. Display generated files as tabs inside the GenType panel. +4. Add tests that compile a file with `@genType` and assert generated TypeScript output exists. + +## Analysis Binary and Completion + +Treat analysis as optional and behind a separate milestone. The current analysis binary reads `.cmt` and `.cmti` files, so browser support likely requires either a virtual filesystem integration or a dedicated js_of_ocaml analysis bundle. + +Recommended approach: + +1. First expose enough compiler artifacts to generate `.cmt`-equivalent data in memory or in the jsoo filesystem. +2. Build a separate `analysis.js` bundle only when requested by the UI. +3. Lazy-load it from the playground when the user enables completions. +4. Keep the main playground bundle usable without analysis loaded. + +Success criteria: + +- Main playground bundle size does not materially grow when analysis is disabled. +- Completion works for the virtual project model. +- Analysis failures are isolated to the completion panel and never break compile output. + +If the analysis bundle is too large, keep completion local-only through a Node helper process instead of shipping it to Pages. + +## GitHub Pages Deployment + +Use GitHub Pages for the UI shell and PR bundle catalog: + +```txt +https://rescript-lang.github.io/rescript/dev-playground/ +https://rescript-lang.github.io/rescript/dev-playground/pr/// +https://rescript-lang.github.io/rescript/dev-playground/pr//latest/ +``` + +Recommended Pages layout: + +```txt +dev-playground/ + index.html + assets/... + catalog.json + playground-bundles/ + master/ + pr/ + 1234/ + latest.json + / + catalog-entry.json + playground-bundles/local/... +``` + +The initial deployed catalog should contain only: + +- the latest `master` compiler bundle, +- PR builds generated after this feature lands. + +Do not backfill released compiler bundles at the start. The shell should read `catalog.json` and `pr//latest.json` to populate a build selector from those entries only. + +For forked PRs, avoid running privileged deployment on untrusted code. Use a two-step flow: + +1. Build and upload a normal Actions artifact in the pull request workflow. +2. A trusted `workflow_run` job, scoped to artifacts only, publishes the static artifact to Pages. + +## Deployment Structure + +This should be implemented with GitHub Actions. There is no server component: Actions builds static files, GitHub Pages serves them. + +For the first PR, keep GitHub Pages to the UI shell and the latest `master` compiler bundle. That is small and operationally simple. + +Do not plan on storing unbounded PR bundles in GitHub Pages. GitHub Pages has a published site size limit, deployment timeout, and soft bandwidth limit, so it is a poor fit for a growing archive of compiler bundles. PR bundle publishing should use one of these policies: + +- Short-term Pages storage: keep only the latest bundle per open PR, delete it when the PR closes, and enforce a hard cap on total PR bundles. +- Preferred longer-term storage: keep the UI and `master` bundle on GitHub Pages, but upload PR compiler bundles to Cloudflare R2/CDN under a `dev-playground/pr///` prefix, then write those bundle URLs into `catalog.json`. +- In either storage model, PR bundles must be cleaned up when the PR is merged or closed. + +Use a generated `gh-pages` branch as the persistent state store for deployed playground files if PR bundles are stored on Pages. A plain Pages artifact deployment is a full-site snapshot, so it is awkward for appending PR bundle folders over time unless the workflow can reconstruct all previous PR builds. The `gh-pages` branch gives publish jobs a simple source of truth: + +1. Check out `gh-pages` into a staging directory. +2. Copy new generated files into that staging directory. +3. Update `catalog.json` and any `latest.json` files. +4. Commit and push `gh-pages`. +5. Upload the staged directory as a Pages artifact and deploy it. + +Recommended setup: + +- Configure the repository's Pages source to GitHub Actions. +- Keep the generated site out of `master`. +- Use a serialized deployment concurrency group, for example `dev-playground-pages`, so two PR publish jobs cannot update `catalog.json` at the same time. +- Use real copied files for deployment artifacts. Local symlinks are fine for development, but Pages artifacts must not contain symbolic or hard links. + +The master-only deployment is implemented in: + +```txt +.github/workflows/ci.yml +``` + +Suggested future PR workflow split: + +```txt +.github/workflows/dev-playground-pr-build.yml +.github/workflows/dev-playground-pr-publish.yml +.github/workflows/dev-playground-pr-cleanup.yml +``` + +`ci.yml`: + +1. Runs on push to `master`. +2. Reuses the existing Ubuntu ARM playground CI entry to build and test the browser compiler bundle. +3. Uploads `dev-playground-master-bundle` as a normal Actions artifact. +4. Builds the UI shell with `rescript@12.3.0` in a follow-up deploy job. +5. Stages a static Pages artifact containing: + - `dev-playground/index.html` + - `dev-playground/assets/` + - `dev-playground/playground-bundles/master/` + - `dev-playground/catalog.json` +6. Deploys the artifact through GitHub Pages Actions. + +`dev-playground-pr-build.yml`: + +1. Runs on pull requests. +2. Builds and tests only the PR compiler bundle and cmij files. +3. Uploads a normal Actions artifact containing: + - `compiler.js` + - staged `packages/` + - `catalog-entry.json` +4. Does not deploy and does not receive Pages write permissions. + +`dev-playground-pr-publish.yml`: + +1. Runs from a trusted context after the PR build workflow completes. +2. Downloads the PR artifact. +3. Does not execute code from the artifact. +4. Copies files into Pages or uploads them to R2/CDN: + + ```txt + dev-playground/pr///playground-bundles/local/ + dev-playground/pr//latest.json + ``` + +5. Updates `dev-playground/catalog.json`. +6. Commits and pushes `gh-pages`. +7. Deploys the full staged `gh-pages` directory to Pages. + +For forked PRs, either require a maintainer label before publishing or initially skip automatic publishing. A PR compiler bundle is executable JavaScript under the `github.io` origin, so publishing arbitrary fork output should be an explicit project policy. + +`dev-playground-pr-cleanup.yml`: + +1. Runs on `pull_request_target` for `closed` PRs. +2. Removes `dev-playground/pr//` from `gh-pages` if PR bundles were stored on Pages. +3. Deletes the `dev-playground/pr//` prefix from R2/CDN if PR bundles were stored there. +4. Removes the PR entry from `dev-playground/catalog.json`. +5. Deploys the updated Pages site. + +Run cleanup for both merged and unmerged closes. A merged PR is represented by `pull_request.closed` with `pull_request.merged == true`; cleanup should not depend on that flag except for logging. + +## CI Workflow + +Keep the developer playground workflows separate from the existing release upload path. + +Do not add a separate CI-only job for the local playground. The master-only deployment workflow builds and validates the artifacts it publishes, which is enough for this stage. + +Master-only deployment requirements are covered by `.github/workflows/ci.yml`: + +- Build the UI shell with `rescript@12.3.0`. +- Build the `master` browser compiler bundle. +- Publish only `dev-playground/`, `catalog.json`, and `playground-bundles/master/`. +- Do not publish PR artifacts yet. + +PR publishing requirements: + +- Add the PR bundle artifact workflow. +- Add the trusted PR publish workflow. +- Add PR comments with the deployed URL. +- Add cleanup for merged/closed PRs and retention rules for stale bundle folders. + +Release job: + +Keep the existing Cloudflare R2 release upload for the public playground. Do not include release bundle mirroring in the initial developer playground workflow. + +## Relationship to `pkg.pr.new` + +`pkg.pr.new` should stay in the plan, but it should not be treated as the browser playground deployment mechanism. + +It publishes installable npm packages for Node-based integration testing. The browser playground needs the js_of_ocaml `compiler.js` bundle and cmij assets, which are not the same artifact. + +Good integration points: + +- Show the matching `pkg.pr.new` install URL in the PR playground header. +- Add it to `catalog-entry.json` so reviewers can copy the package install command. +- Use the same commit SHA for both the npm package link and the browser playground build. + +Do not make the browser UI download compiler code from `pkg.pr.new` unless a dedicated browser-bundle package is introduced later. + +## Testing + +Compiler bundle tests: + +- Existing `yarn workspace playground test`. +- Add tests for every artifact key that the compiler claims to expose. +- Snapshot minimal parsetree, typedtree, lambda, lam, source-map, and GenType outputs. + +Frontend tests: + +- Build test for `packages/dev-playground`. +- Smoke test that `dist/index.html` loads a staged local bundle. +- Browser smoke test for: + - initial compile, + - switching tabs, + - editing `rescript.json`, + - showing unsupported config warnings, + - loading a PR catalog entry. + +CI deployment tests: + +- Validate `catalog.json` schema. +- Validate that each catalog entry has `compiler.js` and `compiler-builtins/cmij.js`. +- For PR artifacts, validate `latest.json` points at an existing SHA directory. + +## Rollout + +Remaining implementation order: + +1. Add PR build publishing. + - Add the untrusted PR artifact build. + - Add the trusted publish job. + - Add PR comments with the deployed URL. + - Add cleanup/retention policy for old PR builds. +2. Add PR cleanup. + - Remove PR bundle folders or R2 prefixes when a PR is closed, whether merged or not. + - Remove the PR entry from `catalog.json`. + - Keep only the latest bundle per open PR unless there is a deliberate retention policy. +3. Decide the long-term storage backend for PR bundles. + - Pages is acceptable only if retention stays small and enforced. + - Prefer R2/CDN for unbounded or longer-lived PR bundle storage. +4. Add the shared highlighting source-of-truth follow-up. + - Generate or validate TextMate grammar, tree-sitter highlight queries, and playground tokenizer tables from shared data. + - Keep semantic-token compatibility checks in scope. +5. Generalize the compiler/frontend result adapter if future outputs need richer artifact metadata. +6. Add `rescript.json` editor support for the safe one-file settings. +7. Add source-map output. +8. Add GenType generated-file output. +9. Prototype lazy-loaded analysis/completion and decide whether it ships to Pages or stays local-only. + +If Pages setup or repository permissions fail on first deployment, fix that before starting PR build publishing. The master-only deployment still avoids the untrusted-code complexity of PR artifacts. + +## Open Questions + +- Should the developer playground live at `rescript-lang.github.io/rescript/dev-playground/` or in a separate `rescript-lang.github.io/dev-playground/` project site? +- How many PR bundle versions should be retained, and who cleans them up after PR close? +- Should PR playground deployment be allowed for forks automatically, or only after maintainer approval? +- Should virtual multi-file projects be URL-shareable, or should large examples require GitHub gist/import support? +- Is the current `compiler/jsoo` build profile the right place for all debug artifacts, or should debug-heavy output be gated behind a separate bundle flavor? diff --git a/package.json b/package.json index 33bc618206b..49112227b6c 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "typescript": "6.0.3" }, "workspaces": [ + "packages/dev-playground", "packages/playground", "packages/@rescript/*", "tests/dependencies/**", diff --git a/packages/dev-playground/index.html b/packages/dev-playground/index.html new file mode 100644 index 00000000000..d663a07e584 --- /dev/null +++ b/packages/dev-playground/index.html @@ -0,0 +1,13 @@ + + + + + + ReScript Developer Playground + + + +
+ + + diff --git a/packages/dev-playground/package.json b/packages/dev-playground/package.json new file mode 100644 index 00000000000..24528385e85 --- /dev/null +++ b/packages/dev-playground/package.json @@ -0,0 +1,22 @@ +{ + "private": true, + "name": "dev-playground", + "version": "0.1.0", + "type": "module", + "scripts": { + "stage-local-bundle": "node scripts/stage-local-bundle.mjs", + "stage-master-bundle": "node scripts/stage-local-bundle.mjs master --clear-other-bundles", + "prepare-pages-site": "node scripts/prepare-pages-site.mjs", + "res:build": "rescript", + "res:watch": "rescript -w", + "dev": "vite --host 127.0.0.1", + "build": "rescript && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@rescript/runtime": "12.3.0", + "rescript": "12.3.0", + "vite": "^7.3.2", + "xote": "6.1.1" + } +} diff --git a/packages/dev-playground/public/playground-bundles/.gitignore b/packages/dev-playground/public/playground-bundles/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/packages/dev-playground/public/playground-bundles/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/packages/dev-playground/rescript.json b/packages/dev-playground/rescript.json new file mode 100644 index 00000000000..c8ae4421cfc --- /dev/null +++ b/packages/dev-playground/rescript.json @@ -0,0 +1,23 @@ +{ + "name": "dev-playground", + "sources": [ + { + "dir": "src", + "subdirs": true + } + ], + "package-specs": { + "module": "esmodule", + "in-source": true + }, + "suffix": ".res.mjs", + "dependencies": ["xote"], + "jsx": { + "version": 4, + "module": "XoteJSX" + }, + "compiler-flags": ["-open Xote"], + "warnings": { + "error": "+8" + } +} diff --git a/packages/dev-playground/scripts/prepare-pages-site.mjs b/packages/dev-playground/scripts/prepare-pages-site.mjs new file mode 100644 index 00000000000..94e00149c5b --- /dev/null +++ b/packages/dev-playground/scripts/prepare-pages-site.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +const devPlaygroundDir = path.join(import.meta.dirname, ".."); +const distDir = path.join(devPlaygroundDir, "dist"); +const siteDir = path.join(devPlaygroundDir, "pages-site"); +const sitePath = process.env.GITHUB_PAGES_PATH ?? "dev-playground"; +const bundleId = process.env.PLAYGROUND_BUNDLE_ID ?? "master"; +const commitSha = process.env.GITHUB_SHA ?? "unknown"; +const targetDir = path.join(siteDir, sitePath); + +async function assertExists(filePath, message) { + try { + await fs.stat(filePath); + } catch { + throw new Error(`${message}: ${filePath}`); + } +} + +await assertExists( + distDir, + "Missing dev playground build. Run `yarn workspace dev-playground build` first", +); + +await fs.rm(siteDir, { recursive: true, force: true }); +await fs.mkdir(targetDir, { recursive: true }); +await fs.cp(distDir, targetDir, { recursive: true }); + +const catalog = { + generatedAt: new Date().toISOString(), + defaultBundle: bundleId, + bundles: [ + { + id: bundleId, + label: bundleId, + channel: bundleId, + commit: commitSha, + root: `playground-bundles/${bundleId}`, + }, + ], +}; + +await fs.writeFile( + path.join(targetDir, "catalog.json"), + `${JSON.stringify(catalog, null, 2)}\n`, +); + +await fs.writeFile( + path.join(siteDir, "index.html"), + ` + + + + + ReScript Developer Playground + + + Open ReScript Developer Playground + + +`, +); + +await assertExists( + path.join(targetDir, "index.html"), + "Missing deployed dev playground index", +); +await assertExists( + path.join(targetDir, "playground-bundles", bundleId, "compiler.js"), + "Missing deployed playground compiler bundle", +); +await assertExists( + path.join( + targetDir, + "playground-bundles", + bundleId, + "compiler-builtins", + "cmij.js", + ), + "Missing deployed compiler-builtins cmij bundle", +); + +console.log(`Prepared GitHub Pages site at ${siteDir}`); diff --git a/packages/dev-playground/scripts/stage-local-bundle.mjs b/packages/dev-playground/scripts/stage-local-bundle.mjs new file mode 100644 index 00000000000..dfaddd0ba54 --- /dev/null +++ b/packages/dev-playground/scripts/stage-local-bundle.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +const devPlaygroundDir = path.join(import.meta.dirname, ".."); +const repoRoot = path.join(devPlaygroundDir, "..", ".."); +const playgroundDir = path.join(repoRoot, "packages", "playground"); +const sourceCompiler = path.join(playgroundDir, "compiler.js"); +const sourcePackages = path.join(playgroundDir, "packages"); +const args = process.argv.slice(2); +const bundleId = args.find(arg => !arg.startsWith("--")) ?? "local"; +const clearOtherBundles = args.includes("--clear-other-bundles"); +const bundlesRoot = path.join(devPlaygroundDir, "public", "playground-bundles"); +const targetRoot = path.join(bundlesRoot, bundleId); + +async function assertExists(filePath, message) { + try { + await fs.stat(filePath); + } catch { + throw new Error(`${message}: ${filePath}`); + } +} + +await assertExists( + sourceCompiler, + "Missing playground compiler bundle. Run `make playground` first", +); +await assertExists( + sourcePackages, + "Missing playground cmij packages. Run `make playground` first", +); + +if (clearOtherBundles) { + for (const entry of await fs.readdir(bundlesRoot)) { + if (entry !== ".gitignore") { + await fs.rm(path.join(bundlesRoot, entry), { + recursive: true, + force: true, + }); + } + } +} + +await fs.rm(targetRoot, { recursive: true, force: true }); +await fs.mkdir(targetRoot, { recursive: true }); +await fs.copyFile(sourceCompiler, path.join(targetRoot, "compiler.js")); +await fs.cp(sourcePackages, targetRoot, { recursive: true }); + +console.log(`Staged ${bundleId} playground bundle at ${targetRoot}`); diff --git a/packages/dev-playground/src/CompilerApi.res b/packages/dev-playground/src/CompilerApi.res new file mode 100644 index 00000000000..dbeac515cc5 --- /dev/null +++ b/packages/dev-playground/src/CompilerApi.res @@ -0,0 +1,45 @@ +type info = { + bundleId: string, + version: string, + apiVersion: string, + moduleSystem: string, + warnFlags: string, + jsxPreserveMode: bool, + experimentalFeatures: array, + libraries: array, +} + +type config = { + compilerVersion: string, + moduleSystem: string, + warnFlags: string, + jsxPreserveMode: bool, + experimentalFeatures: array, +} + +type compilerVersion = { + id: string, + label: string, +} + +type result = { + ok: bool, + kind: string, + jsCode: string, + parsetree: string, + typedtree: string, + @as("lambda") + lambda_: string, + lam: string, + errors: array, + warnings: array, + message: string, + time: float, +} + +@module("./CompilerRuntime.js") external defaultWarnFlags: string = "defaultWarnFlags" +@module("./CompilerRuntime.js") external defaultCompilerVersion: string = "defaultCompilerVersion" +@module("./CompilerRuntime.js") +external availableCompilerVersions: array = "availableCompilerVersions" +@module("./CompilerRuntime.js") external init: string => promise = "init" +@module("./CompilerRuntime.js") external compile: (string, config) => promise = "compile" diff --git a/packages/dev-playground/src/CompilerRuntime.js b/packages/dev-playground/src/CompilerRuntime.js new file mode 100644 index 00000000000..3670db1a2fc --- /dev/null +++ b/packages/dev-playground/src/CompilerRuntime.js @@ -0,0 +1,282 @@ +function pathFromBase(relativePath) { + const origin = globalThis.location?.origin ?? "http://localhost"; + const base = new URL(import.meta.env.BASE_URL || "/", origin); + return new URL(relativePath, base).pathname.replace(/\/$/, ""); +} + +function parseCompilerVersions(defaultVersion) { + const raw = import.meta.env.VITE_COMPILER_VERSIONS; + if (raw == null || raw === "") { + return [{ id: defaultVersion, label: defaultVersion }]; + } + + try { + const versions = JSON.parse(raw); + if ( + Array.isArray(versions) && + versions.every( + version => + typeof version?.id === "string" && typeof version?.label === "string", + ) + ) { + return versions; + } + } catch { + // Fall through to the default list below. + } + + return [{ id: defaultVersion, label: defaultVersion }]; +} + +const compilerRoot = pathFromBase("playground-bundles"); +const loadedScripts = new Map(); +const compilerApis = new Map(); +const compilers = new Map(); +const loadedLibrariesByVersion = new Map(); +let activeLibraryVersion = null; + +export const defaultWarnFlags = "+a-4-9-20-40-41-42-50-61-102-109"; +export const defaultCompilerVersion = + import.meta.env.VITE_DEFAULT_COMPILER_VERSION ?? "local"; +export const availableCompilerVersions = parseCompilerVersions( + defaultCompilerVersion, +); + +function loadScript(src, { cache = true } = {}) { + if (cache && loadedScripts.has(src)) { + return loadedScripts.get(src); + } + + const promise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Could not load ${src}`)); + document.head.appendChild(script); + }); + + if (cache) { + loadedScripts.set(src, promise); + } + return promise; +} + +function hasFunction(value, name) { + return value != null && typeof value[name] === "function"; +} + +function versionRoot(version) { + return `${compilerRoot}/${version || defaultCompilerVersion}`; +} + +function applyConfig(instance, config) { + if (instance == null || config == null) { + return; + } + + if (hasFunction(instance, "setModuleSystem")) { + instance.setModuleSystem(config.moduleSystem || "esmodule"); + } + if (hasFunction(instance, "setWarnFlags")) { + instance.setWarnFlags(config.warnFlags || defaultWarnFlags); + } + if (hasFunction(instance, "setFilename")) { + instance.setFilename("Playground.res"); + } + if (hasFunction(instance, "setJsxPreserveMode")) { + instance.setJsxPreserveMode(Boolean(config.jsxPreserveMode)); + } + if (hasFunction(instance, "setExperimentalFeatures")) { + instance.setExperimentalFeatures(config.experimentalFeatures || []); + } +} + +function normalizeConfig(rawConfig) { + const rawModuleSystem = rawConfig?.module_system ?? "esmodule"; + const moduleSystem = + rawModuleSystem === "es6" + ? "esmodule" + : rawModuleSystem === "nodejs" + ? "commonjs" + : rawModuleSystem; + + return { + moduleSystem, + warnFlags: rawConfig?.warn_flags ?? defaultWarnFlags, + jsxPreserveMode: Boolean(rawConfig?.jsx_preserve_mode), + experimentalFeatures: rawConfig?.experimental_features ?? [], + }; +} + +function formatLocation(item) { + const row = item?.row ?? 0; + const column = item?.column ?? 0; + return row > 0 ? `Line ${row}, ${column}` : "Compiler"; +} + +function warningToText(item) { + const prefix = item?.isError ? "error" : "warning"; + const warnNumber = item?.warnNumber == null ? "" : ` ${item.warnNumber}`; + const message = item?.shortMsg ?? item?.fullMsg ?? "Unknown warning"; + return `${formatLocation(item)}: ${prefix}${warnNumber}: ${message}`; +} + +function errorToText(item) { + const message = item?.shortMsg ?? item?.fullMsg ?? "Unknown compiler error"; + return `${formatLocation(item)}: ${message}`; +} + +function normalizeFailure(result, elapsedMs) { + const errors = Array.isArray(result?.errors) + ? result.errors.map(errorToText) + : []; + const warnings = Array.isArray(result?.warnings) + ? result.warnings.map(warningToText) + : []; + const message = + result?.msg ?? + result?.shortMsg ?? + result?.fullMsg ?? + (errors.length > 0 ? errors[0] : "Compilation failed"); + + return { + ok: false, + kind: result?.type ?? "error", + jsCode: "", + parsetree: "", + typedtree: "", + lambda: "", + lam: "", + errors, + warnings, + message, + time: elapsedMs, + }; +} + +function normalizeSuccess(result, elapsedMs) { + const fallback = + "This local compiler bundle does not expose this debug dump yet."; + + return { + ok: true, + kind: "success", + jsCode: result?.js_code ?? "", + parsetree: result?.parsetree ?? fallback, + typedtree: result?.typedtree ?? fallback, + lambda: result?.lambda ?? fallback, + lam: result?.lam ?? fallback, + errors: [], + warnings: Array.isArray(result?.warnings) + ? result.warnings.map(warningToText) + : [], + message: "Compiled successfully", + time: elapsedMs, + }; +} + +async function loadRuntimeLibraries(version) { + const selectedVersion = version || defaultCompilerVersion; + if (activeLibraryVersion === selectedVersion) { + return; + } + + const root = versionRoot(selectedVersion); + const libraries = ["compiler-builtins"]; + await loadScript(`${root}/compiler-builtins/cmij.js`, { cache: false }); + + try { + await loadScript(`${root}/@rescript/react/cmij.js`, { cache: false }); + libraries.push("@rescript/react"); + } catch { + // React is optional for the developer shell. + } + + loadedLibrariesByVersion.set(selectedVersion, libraries); + activeLibraryVersion = selectedVersion; +} + +async function ensureCompilerApi(version) { + const selectedVersion = version || defaultCompilerVersion; + if (compilerApis.has(selectedVersion)) { + await loadRuntimeLibraries(selectedVersion); + return compilerApis.get(selectedVersion); + } + + const root = versionRoot(selectedVersion); + await loadScript(`${root}/compiler.js`); + await loadRuntimeLibraries(selectedVersion); + + const api = globalThis.rescript_compiler; + if (api == null || typeof api.make !== "function") { + throw new Error( + "rescript_compiler global was not registered by compiler.js", + ); + } + + compilerApis.set(selectedVersion, api); + return api; +} + +async function ensureCompiler(version) { + const selectedVersion = version || defaultCompilerVersion; + const api = await ensureCompilerApi(selectedVersion); + + if (compilers.has(selectedVersion)) { + return compilers.get(selectedVersion); + } + + const instance = api.make(); + applyConfig(instance, { + moduleSystem: "esmodule", + warnFlags: defaultWarnFlags, + jsxPreserveMode: false, + experimentalFeatures: [], + }); + + compilers.set(selectedVersion, instance); + return instance; +} + +export async function init(version) { + const selectedVersion = version || defaultCompilerVersion; + const instance = await ensureCompiler(selectedVersion); + const config = normalizeConfig(instance.getConfig?.()); + + return { + bundleId: selectedVersion, + version: instance.version ?? instance.rescript?.version ?? "unknown", + apiVersion: compilerApis.get(selectedVersion)?.api_version ?? "unknown", + moduleSystem: config.moduleSystem, + warnFlags: config.warnFlags, + jsxPreserveMode: config.jsxPreserveMode, + experimentalFeatures: config.experimentalFeatures, + libraries: loadedLibrariesByVersion.get(selectedVersion) ?? [ + "compiler-builtins", + ], + }; +} + +export async function compile(source, config) { + const selectedVersion = config?.compilerVersion || defaultCompilerVersion; + const instance = await ensureCompiler(selectedVersion); + applyConfig(instance, config); + + const start = performance.now(); + const result = hasFunction(instance.rescript, "compileWithDebug") + ? instance.rescript.compileWithDebug(source, [ + "parsetree", + "typedtree", + "lambda", + "lam", + ]) + : instance.rescript.compile(source); + const elapsedMs = performance.now() - start; + + if (result?.type === "success") { + return normalizeSuccess(result, elapsedMs); + } + + return normalizeFailure(result, elapsedMs); +} diff --git a/packages/dev-playground/src/Main.res b/packages/dev-playground/src/Main.res new file mode 100644 index 00000000000..5219429a73b --- /dev/null +++ b/packages/dev-playground/src/Main.res @@ -0,0 +1,984 @@ +type tab = + | Parsetree + | Typedtree + | Lambda + | Lam + | JavaScript + | Settings + +type compilerStatus = + | Loading + | Ready + | Compiling + | Failed(string) + +type sourcePosition = { + line: int, + col: int, +} + +let tabs: array = [Parsetree, Typedtree, Lambda, Lam, JavaScript, Settings] + +let defaultSource = `type person = { + name: string, + age: int, +} + +let greet = person => + switch person.age { + | age if age < 18 => "Hi " ++ person.name + | _ => "Hello " ++ person.name + } + +let message = greet({name: "Ada", age: 36}) +Console.log(message)` + +let tabLabel = tab => + switch tab { + | Parsetree => "parsetree" + | Typedtree => "typedtree" + | Lambda => "lambda" + | Lam => "lam" + | JavaScript => "js" + | Settings => "settings" + } + +let statusLabel = status => + switch status { + | Loading => "loading compiler" + | Ready => "ready" + | Compiling => "compiling" + | Failed(_) => "compiler error" + } + +module Browser = { + @val external setTimeout: (unit => unit, int) => int = "setTimeout" + @val external clearTimeout: int => unit = "clearTimeout" + + let eventValue = (event: Dom.event): string => { + ignore(event) + let value: string = %raw(`event.target.value`) + value + } + + let eventChecked = (event: Dom.event): bool => { + ignore(event) + let checked: bool = %raw(`event.target.checked`) + checked + } + + let eventSelectionStart = (event: Dom.event): int => { + ignore(event) + let selectionStart: int = %raw(`event.target.selectionStart || 0`) + selectionStart + } + + let eventScrollTop = (event: Dom.event): int => { + ignore(event) + let scrollTop: int = %raw(`Math.round(event.target.scrollTop || 0)`) + scrollTop + } + + let eventScrollLeft = (event: Dom.event): int => { + ignore(event) + let scrollLeft: int = %raw(`Math.round(event.target.scrollLeft || 0)`) + scrollLeft + } + + let insertTabIndent = (event: Dom.event): option => { + ignore(event) + let value: Nullable.t = %raw(` + (() => { + if (event.key !== "Tab") { + return null; + } + + const target = event.target; + if (target == null || typeof target.value !== "string") { + return null; + } + + event.preventDefault(); + + const start = target.selectionStart || 0; + const end = target.selectionEnd || start; + const nextValue = target.value.slice(0, start) + " " + target.value.slice(end); + const cursor = start + 2; + + target.value = nextValue; + target.setSelectionRange(cursor, cursor); + + return nextValue; + })() + `) + value->Nullable.toOption + } + + let configureSourceEditor = (scrollHandler: Dom.event => unit): unit => { + ignore(scrollHandler) + %raw(` + window.requestAnimationFrame(() => { + const editor = document.getElementById("source-editor"); + if (editor == null) { + return; + } + + editor.setAttribute("wrap", "off"); + + if (editor.__devPlaygroundScrollHandler === scrollHandler) { + return; + } + if (editor.__devPlaygroundScrollHandler != null) { + editor.removeEventListener("scroll", editor.__devPlaygroundScrollHandler); + } + editor.__devPlaygroundScrollHandler = scrollHandler; + editor.addEventListener("scroll", scrollHandler); + }) + `) + } + + let jsErrorMessage = obj => + switch JsExn.message(obj) { + | Some(message) => message + | None => "Unknown JavaScript error" + } +} + +let lineNumbersText = source => { + let lineCount = source->String.split("\n")->Array.length + Array.make(~length=lineCount, 0) + ->Array.mapWithIndex((_, index) => (index + 1)->Int.toString) + ->Array.join("\n") +} + +let cursorPositionForOffset = (source, offset): sourcePosition => { + let sourceLength = String.length(source) + let boundedOffset = if offset < 0 { + 0 + } else if offset > sourceLength { + sourceLength + } else { + offset + } + + let rec walk = (index, line, col) => + if index >= boundedOffset { + {line, col} + } else if source->String.charAt(index) === "\n" { + walk(index + 1, line + 1, 0) + } else { + walk(index + 1, line, col + 1) + } + + walk(0, 1, 0) +} + +let editorShellStyle = (activeLine, scrollTop, scrollLeft) => { + let activeLineIndex = activeLine <= 1 ? 0 : activeLine - 1 + let activeLineTop = 18 + activeLineIndex * 22 - scrollTop + `--active-line-top: ${activeLineTop->Int.toString}px; --editor-scroll-y: -${scrollTop->Int.toString}px; --editor-scroll-x: -${scrollLeft->Int.toString}px;` +} + +type tokenKind = + | TokenPlain + | TokenKeyword + | TokenBuiltin + | TokenConstructor + | TokenString + | TokenNumber + | TokenComment + | TokenAttribute + | TokenOperator + +type highlightToken = { + kind: tokenKind, + text: string, +} + +let syntaxKeywords = [ + "and", + "as", + "async", + "await", + "catch", + "constraint", + "else", + "exception", + "external", + "false", + "for", + "if", + "in", + "include", + "let", + "module", + "mutable", + "open", + "private", + "rec", + "switch", + "to", + "true", + "try", + "type", + "when", + "while", +] + +let syntaxBuiltins = [ + "array", + "bigint", + "bool", + "dict", + "float", + "int", + "list", + "option", + "promise", + "result", + "string", + "unit", +] + +let charAt = (source, index) => source->String.charAt(index) + +let isLower = char => char >= "a" && char <= "z" +let isUpper = char => char >= "A" && char <= "Z" +let isDigit = char => char >= "0" && char <= "9" +let isAlpha = char => isLower(char) || isUpper(char) +let isIdentStart = char => isAlpha(char) || char === "_" +let isIdentPart = char => isIdentStart(char) || isDigit(char) || char === "'" + +let isOperatorChar = char => + char === "=" || + char === ">" || + char === "<" || + char === "+" || + char === "-" || + char === "*" || + char === "/" || + char === "|" || + char === "&" || + char === "!" || + char === "?" || + char === ":" || + char === "." || + char === "~" || + char === "^" || + char === "%" || + char === "#" + +let startsWithAt = (source, index, prefix) => + source->String.slice(~start=index, ~end=index + String.length(prefix)) === prefix + +let rec findLineEnd = (source, index, length) => + if index >= length || charAt(source, index) === "\n" { + index + } else { + findLineEnd(source, index + 1, length) + } + +let rec findBlockEnd = (source, index, length, closing) => + if index >= length { + length + } else if startsWithAt(source, index, closing) { + index + String.length(closing) + } else { + findBlockEnd(source, index + 1, length, closing) + } + +let rec findStringEnd = (source, index, length, delimiter, escaped) => + if index >= length { + length + } else { + let char = charAt(source, index) + if escaped { + findStringEnd(source, index + 1, length, delimiter, false) + } else if char === "\\" { + findStringEnd(source, index + 1, length, delimiter, true) + } else if char === delimiter { + index + 1 + } else { + findStringEnd(source, index + 1, length, delimiter, false) + } + } + +let rec findIdentEnd = (source, index, length) => + if index < length && isIdentPart(charAt(source, index)) { + findIdentEnd(source, index + 1, length) + } else { + index + } + +let rec findAttributeEnd = (source, index, length) => + if index < length { + let char = charAt(source, index) + if isIdentPart(char) || char === "." || char === "@" { + findAttributeEnd(source, index + 1, length) + } else { + index + } + } else { + index + } + +let rec findNumberEnd = (source, index, length) => + if index < length { + let char = charAt(source, index) + if isDigit(char) || isAlpha(char) || char === "_" || char === "." { + findNumberEnd(source, index + 1, length) + } else { + index + } + } else { + index + } + +let rec findOperatorEnd = (source, index, length) => + if index < length && isOperatorChar(charAt(source, index)) { + findOperatorEnd(source, index + 1, length) + } else { + index + } + +let tokenKindForIdent = word => + if syntaxKeywords->Array.includes(word) { + TokenKeyword + } else if syntaxBuiltins->Array.includes(word) { + TokenBuiltin + } else if String.length(word) > 0 && isUpper(charAt(word, 0)) { + TokenConstructor + } else { + TokenPlain + } + +let tokenizeRescript = source => { + let tokens: array = [] + let length = String.length(source) + let index = ref(0) + + while index.contents < length { + let start = index.contents + let char = charAt(source, start) + let (next, kind) = if startsWithAt(source, start, "//") { + (findLineEnd(source, start, length), TokenComment) + } else if startsWithAt(source, start, "/*") { + (findBlockEnd(source, start + 2, length, "*/"), TokenComment) + } else if char === "\"" || char === "`" { + (findStringEnd(source, start + 1, length, char, false), TokenString) + } else if char === "@" { + (findAttributeEnd(source, start + 1, length), TokenAttribute) + } else if isDigit(char) { + (findNumberEnd(source, start + 1, length), TokenNumber) + } else if isIdentStart(char) { + let next = findIdentEnd(source, start + 1, length) + let word = source->String.slice(~start, ~end=next) + (next, tokenKindForIdent(word)) + } else if isOperatorChar(char) { + (findOperatorEnd(source, start + 1, length), TokenOperator) + } else { + (start + 1, TokenPlain) + } + + tokens->Array.push({ + kind, + text: source->String.slice(~start, ~end=next), + }) + index := next + } + + tokens +} + +let tokenClass = kind => + switch kind { + | TokenPlain => "syntax-token" + | TokenKeyword => "syntax-token syntax-keyword" + | TokenBuiltin => "syntax-token syntax-builtin" + | TokenConstructor => "syntax-token syntax-constructor" + | TokenString => "syntax-token syntax-string" + | TokenNumber => "syntax-token syntax-number" + | TokenComment => "syntax-token syntax-comment" + | TokenAttribute => "syntax-token syntax-attribute" + | TokenOperator => "syntax-token syntax-operator" + } + +let highlightNodes = source => + tokenizeRescript(source)->Array.map(token => + {Node.text(token.text)} + ) + +let hasFeature = (features: array, feature: string) => features->Array.includes(feature) + +let toggleFeature = (features: array, feature: string) => + hasFeature(features, feature) + ? features->Array.filter(item => item !== feature) + : Array.concat(features, [feature]) + +let selectedOutput = (result: option, activeTab: tab) => + switch result { + | None => "The compiler is loading. Results will appear here after the first compile." + | Some(result) if !result.ok => + let errors = result.errors->Array.join("\n") + errors === "" ? result.message : errors + | Some(result) => + switch activeTab { + | Parsetree => result.parsetree + | Typedtree => result.typedtree + | Lambda => result.lambda_ + | Lam => result.lam + | JavaScript => result.jsCode + | Settings => "" + } + } + +let resultSummary = (result: option) => + switch result { + | None => "No compile result yet" + | Some(result) if result.ok => + let warningCount = result.warnings->Array.length + let warningText = warningCount === 0 ? "no warnings" : `${warningCount->Int.toString} warnings` + `Compiled in ${result.time->Float.toFixed(~digits=1)}ms with ${warningText}` + | Some(result) => result.message + } + +module TabButton = { + @jsx.component + let make = (~tab, ~activeTab: Signal.t, ~onSelect: tab => unit) => { + + } +} + +module Problems = { + @jsx.component + let make = (~compileResult: Signal.t>) => { +
+
{Node.text("Problems")}
+
+        {Node.signalText(() =>
+          switch Signal.get(compileResult) {
+          | Some({warnings}) if warnings->Array.length > 0 => warnings->Array.join("\n")
+          | Some({ok: false, errors}) if errors->Array.length > 0 => errors->Array.join("\n")
+          | Some({ok: false, message}) => message
+          | _ => "No problems reported."
+          }
+        )}
+      
+
+ } +} + +module SettingsPanel = { + @jsx.component + let make = ( + ~activeTab: Signal.t, + ~compilerInfo: Signal.t>, + ~compilerVersion: Signal.t, + ~moduleSystem: Signal.t, + ~warnFlags: Signal.t, + ~jsxPreserveMode: Signal.t, + ~experimentalFeatures: Signal.t>, + ~switchCompiler: string => unit, + ~compileNow: unit => unit, + ~scheduleCompile: unit => unit, + ~scheduleUrlSync: unit => unit, + ) => { +
+ Signal.get(activeTab) === Settings ? "settings-panel" : "settings-panel hidden-panel"} + > +
+ + +
+
+ +
+ {Node.signalText(() => + switch Signal.get(compilerInfo) { + | Some(info) => `${info.version} / API ${info.apiVersion} / ${info.bundleId}` + | None => "loading" + } + )} +
+
+
+ + +
+
+ + { + Signal.set(warnFlags, Browser.eventValue(event)) + scheduleUrlSync() + scheduleCompile() + }} + /> + +
+
+ { + Signal.set(jsxPreserveMode, Browser.eventChecked(event)) + scheduleUrlSync() + compileNow() + }} + /> + +
+
+ Signal.get(experimentalFeatures)->hasFeature("LetUnwrap")} + onChange={_ => { + Signal.update(experimentalFeatures, features => toggleFeature(features, "LetUnwrap")) + scheduleUrlSync() + compileNow() + }} + /> + +
+
+ +
+ {Node.signalText(() => + switch Signal.get(compilerInfo) { + | Some(info) => info.libraries->Array.join(", ") + | None => "loading" + } + )} +
+
+
+ } +} + +module StatusBadge = { + @jsx.component + let make = (~status: Signal.t) => { +
+ switch Signal.get(status) { + | Failed(_) => "status status-error" + | Compiling | Loading => "status status-busy" + | Ready => "status" + }} + > + {Node.signalText(() => + switch Signal.get(status) { + | Failed(message) => message + | other => statusLabel(other) + } + )} +
+ } +} + +module App = { + @jsx.component + let make = () => { + let requestedCompilerVersion = UrlState.queryCompilerVersion(CompilerApi.defaultCompilerVersion) + let initialCompilerVersion = + CompilerApi.availableCompilerVersions->Array.some(version => + version.id === requestedCompilerVersion + ) + ? requestedCompilerVersion + : CompilerApi.defaultCompilerVersion + let initialModuleSystem = UrlState.queryModuleSystem("esmodule") + let initialWarnFlags = UrlState.queryWarnFlags(CompilerApi.defaultWarnFlags) + let initialJsxPreserveMode = UrlState.queryJsxPreserveMode(false) + let initialExperimentalFeatures = UrlState.queryExperimentalFeatures() + + let source = Signal.make(defaultSource) + let activeTab = Signal.make(JavaScript) + let status = Signal.make(Loading) + let compilerInfo: Signal.t> = Signal.make(None) + let compileResult: Signal.t> = Signal.make(None) + let compilerVersion = Signal.make(initialCompilerVersion) + let moduleSystem = Signal.make(initialModuleSystem) + let warnFlags = Signal.make(initialWarnFlags) + let jsxPreserveMode = Signal.make(initialJsxPreserveMode) + let experimentalFeatures: Signal.t> = Signal.make(initialExperimentalFeatures) + let activeLine = Signal.make(1) + let editorScrollTop = Signal.make(0) + let editorScrollLeft = Signal.make(0) + let highlightedSource: Signal.t> = Obj.magic( + Computed.make(() => highlightNodes(Signal.get(source))), + ) + let timerId: ref> = ref(None) + let urlTimerId: ref> = ref(None) + let toastTimerId: ref> = ref(None) + let firstCompilerLoad = ref(true) + let compilerLoadSequence = ref(0) + let compileSequence = ref(0) + let shareToast: Signal.t> = Signal.make(None) + + let syncEditorState = event => { + let currentSource = Browser.eventValue(event) + let cursorPosition = cursorPositionForOffset( + currentSource, + Browser.eventSelectionStart(event), + ) + Signal.set(editorScrollTop, Browser.eventScrollTop(event)) + Signal.set(editorScrollLeft, Browser.eventScrollLeft(event)) + Signal.set(activeLine, cursorPosition.line) + } + + let syncEditorScroll = event => { + Signal.set(editorScrollTop, Browser.eventScrollTop(event)) + Signal.set(editorScrollLeft, Browser.eventScrollLeft(event)) + } + + let currentConfig = (): CompilerApi.config => { + compilerVersion: Signal.peek(compilerVersion), + moduleSystem: Signal.peek(moduleSystem), + warnFlags: Signal.peek(warnFlags), + jsxPreserveMode: Signal.peek(jsxPreserveMode), + experimentalFeatures: Signal.peek(experimentalFeatures), + } + + let compileNow = () => { + compileSequence := compileSequence.contents + 1 + let sequence = compileSequence.contents + + let run = async () => { + switch Signal.peek(status) { + | Loading => () + | Failed(_) => () + | Ready | Compiling => + Signal.set(status, Compiling) + try { + let result = await CompilerApi.compile(Signal.peek(source), currentConfig()) + if sequence === compileSequence.contents { + Signal.set(compileResult, Some(result)) + Signal.set(status, Ready) + } + } catch { + | JsExn(obj) => + if sequence === compileSequence.contents { + Signal.set(status, Failed(Browser.jsErrorMessage(obj))) + } + | _ => + if sequence === compileSequence.contents { + Signal.set(status, Failed("Compilation failed")) + } + } + } + } + + run()->ignore + } + + let scheduleCompile = () => { + switch timerId.contents { + | Some(id) => Browser.clearTimeout(id) + | None => () + } + timerId := Some(Browser.setTimeout(compileNow, 280)) + } + + let syncUrlNow = () => { + UrlState.replaceUrlState( + Signal.peek(source), + Signal.peek(compilerVersion), + Signal.peek(moduleSystem), + Signal.peek(warnFlags), + Signal.peek(jsxPreserveMode), + Signal.peek(experimentalFeatures), + )->ignore + } + + let scheduleUrlSync = () => { + switch urlTimerId.contents { + | Some(id) => Browser.clearTimeout(id) + | None => () + } + urlTimerId := Some(Browser.setTimeout(syncUrlNow, 360)) + } + + let showToast = message => { + switch toastTimerId.contents { + | Some(id) => Browser.clearTimeout(id) + | None => () + } + Signal.set(shareToast, Some(message)) + toastTimerId := Some(Browser.setTimeout(() => Signal.set(shareToast, None), 1800)) + } + + let shareCurrentUrl = () => { + switch urlTimerId.contents { + | Some(id) => Browser.clearTimeout(id) + | None => () + } + + let share = async () => { + try { + let _ = await UrlState.copyUrlState( + Signal.peek(source), + Signal.peek(compilerVersion), + Signal.peek(moduleSystem), + Signal.peek(warnFlags), + Signal.peek(jsxPreserveMode), + Signal.peek(experimentalFeatures), + ) + showToast("Link copied") + } catch { + | JsExn(_) => showToast("Could not copy link") + | _ => showToast("Could not copy link") + } + } + + share()->ignore + } + + let loadCompiler = (version, compileAfterLoad) => { + compilerLoadSequence := compilerLoadSequence.contents + 1 + compileSequence := compileSequence.contents + 1 + let sequence = compilerLoadSequence.contents + + let load = async () => { + try { + Signal.set(status, Loading) + Signal.set(compileResult, None) + let info = await CompilerApi.init(version) + if sequence === compilerLoadSequence.contents { + let useInitialSettings = firstCompilerLoad.contents + firstCompilerLoad := false + Signal.set(compilerInfo, Some(info)) + Signal.set(compilerVersion, info.bundleId) + Signal.set(moduleSystem, useInitialSettings ? initialModuleSystem : info.moduleSystem) + Signal.set(warnFlags, useInitialSettings ? initialWarnFlags : info.warnFlags) + Signal.set( + jsxPreserveMode, + useInitialSettings ? initialJsxPreserveMode : info.jsxPreserveMode, + ) + Signal.set( + experimentalFeatures, + useInitialSettings ? initialExperimentalFeatures : info.experimentalFeatures, + ) + Signal.set(status, Ready) + if !useInitialSettings { + scheduleUrlSync() + } + if compileAfterLoad { + compileNow() + } + } + } catch { + | JsExn(obj) => + if sequence === compilerLoadSequence.contents { + Signal.set(status, Failed(Browser.jsErrorMessage(obj))) + } + | _ => + if sequence === compilerLoadSequence.contents { + Signal.set(status, Failed("Compiler failed to load")) + } + } + } + + load()->ignore + } + + let switchCompiler = version => loadCompiler(version, true) + + Effect.run(() => { + let start = async () => { + let initialSource = await UrlState.initialSource(defaultSource) + Signal.set(source, initialSource) + Signal.set(activeLine, 1) + Signal.set(editorScrollTop, 0) + Signal.set(editorScrollLeft, 0) + loadCompiler(Signal.peek(compilerVersion), true) + } + + start()->ignore + None + }) + + Effect.run(() => { + Browser.configureSourceEditor(syncEditorScroll) + None + }) + +
+
+
+

{Node.text("ReScript Developer Playground")}

+
+ +
+
+
+
+

{Node.text("Source")}

+
+ + +
+
+
+ editorShellStyle( + Signal.get(activeLine), + Signal.get(editorScrollTop), + Signal.get(editorScrollLeft), + )} + > +
+
+
+                {Node.signalText(() => lineNumbersText(Signal.get(source)))}
+              
+
+
+              {Node.signalFragment(highlightedSource)}
+            
+